From 69f36d1df62ebb4ce0762420629a72f81b8b6a67 Mon Sep 17 00:00:00 2001 From: Akhil Mohan <akhilmhdh@gmail.com> Date: Wed, 20 Mar 2024 14:58:22 +0530 Subject: [PATCH] feat(server): service for dynamic secret lease and queue service for revocation --- backend/src/lib/errors/index.ts | 12 + backend/src/queue/queue-service.ts | 20 +- .../dynamic-secret-lease-dal.ts | 10 + .../dynamic-secret-lease-queue.ts | 135 +++++++++ .../dynamic-secret-lease-service.ts | 256 ++++++++++++++++++ .../dynamic-secret-lease-types.ts | 27 ++ 6 files changed, 458 insertions(+), 2 deletions(-) create mode 100644 backend/src/services/dynamic-secret-lease/dynamic-secret-lease-dal.ts create mode 100644 backend/src/services/dynamic-secret-lease/dynamic-secret-lease-queue.ts create mode 100644 backend/src/services/dynamic-secret-lease/dynamic-secret-lease-service.ts create mode 100644 backend/src/services/dynamic-secret-lease/dynamic-secret-lease-types.ts diff --git a/backend/src/lib/errors/index.ts b/backend/src/lib/errors/index.ts index d93244bbd..18b40acfd 100644 --- a/backend/src/lib/errors/index.ts +++ b/backend/src/lib/errors/index.ts @@ -59,6 +59,18 @@ export class BadRequestError extends Error { } } +export class DisableRotationErrors extends Error { + name: string; + + error: unknown; + + constructor({ name, error, message }: { message: string; name?: string; error?: unknown }) { + super(message); + this.name = name || "DisableRotationErrors"; + this.error = error; + } +} + export class ScimRequestError extends Error { name: string; diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index 45c135b77..7cb443ae1 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -18,7 +18,8 @@ export enum QueueName { SecretWebhook = "secret-webhook", SecretFullRepoScan = "secret-full-repo-scan", SecretPushEventScan = "secret-push-event-scan", - UpgradeProjectToGhost = "upgrade-project-to-ghost" + UpgradeProjectToGhost = "upgrade-project-to-ghost", + DynamicSecretRevocation = "dynamic-secret-revocation" } export enum QueueJobs { @@ -30,7 +31,9 @@ export enum QueueJobs { TelemetryInstanceStats = "telemetry-self-hosted-stats", IntegrationSync = "secret-integration-pull", SecretScan = "secret-scan", - UpgradeProjectToGhost = "upgrade-project-to-ghost-job" + UpgradeProjectToGhost = "upgrade-project-to-ghost-job", + DynamicSecretRevocation = "dynamic-secret-revocation", + DynamicSecretPruning = "dynamic-secret-pruning" } export type TQueueJobTypes = { @@ -86,6 +89,19 @@ export type TQueueJobTypes = { name: QueueJobs.TelemetryInstanceStats; payload: undefined; }; + [QueueName.DynamicSecretRevocation]: + | { + name: QueueJobs.DynamicSecretRevocation; + payload: { + leaseId: string; + }; + } + | { + name: QueueJobs.DynamicSecretPruning; + payload: { + dynamicSecretCfgId: string; + }; + }; }; export type TQueueServiceFactory = ReturnType<typeof queueServiceFactory>; diff --git a/backend/src/services/dynamic-secret-lease/dynamic-secret-lease-dal.ts b/backend/src/services/dynamic-secret-lease/dynamic-secret-lease-dal.ts new file mode 100644 index 000000000..acffc96a7 --- /dev/null +++ b/backend/src/services/dynamic-secret-lease/dynamic-secret-lease-dal.ts @@ -0,0 +1,10 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TDynamicSecretLeaseDALFactory = ReturnType<typeof dynamicSecretLeaseDALFactory>; + +export const dynamicSecretLeaseDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.DynamicSecretLease); + return orm; +}; diff --git a/backend/src/services/dynamic-secret-lease/dynamic-secret-lease-queue.ts b/backend/src/services/dynamic-secret-lease/dynamic-secret-lease-queue.ts new file mode 100644 index 000000000..c5bb051fe --- /dev/null +++ b/backend/src/services/dynamic-secret-lease/dynamic-secret-lease-queue.ts @@ -0,0 +1,135 @@ +import { SecretKeyEncoding } from "@app/db/schemas"; +import { DisableRotationErrors } from "@app/ee/services/secret-rotation/secret-rotation-queue"; +import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; +import { logger } from "@app/lib/logger"; +import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; + +import { TDynamicSecretDALFactory } from "../dynamic-secret/dynamic-secret-dal"; +import { DynamicSecretProviders, TDynamicProviderFns } from "../dynamic-secret/providers/models"; +import { TDynamicSecretLeaseDALFactory } from "./dynamic-secret-lease-dal"; + +type TDynamicSecretLeaseQueueServiceFactoryDep = { + queueService: TQueueServiceFactory; + dynamicSecretLeaseDAL: Pick<TDynamicSecretLeaseDALFactory, "findById" | "deleteById" | "find">; + dynamicSecretDAL: Pick<TDynamicSecretDALFactory, "findById" | "deleteById">; + dynamicSecretProviders: Record<DynamicSecretProviders, TDynamicProviderFns>; +}; + +export type TDynamicSecretLeaseQueueServiceFactory = ReturnType<typeof dynamicSecretLeaseQueueServiceFactory>; + +export const dynamicSecretLeaseQueueServiceFactory = ({ + queueService, + dynamicSecretDAL, + dynamicSecretProviders, + dynamicSecretLeaseDAL +}: TDynamicSecretLeaseQueueServiceFactoryDep) => { + const pruneDynamicSecret = async (dynamicSecretCfgId: string) => { + await queueService.queue( + QueueName.DynamicSecretRevocation, + QueueJobs.DynamicSecretPruning, + { dynamicSecretCfgId }, + { + jobId: dynamicSecretCfgId, + removeOnFail: { + count: 3 + }, + removeOnComplete: true + } + ); + }; + + const setLeaseRevocation = async (leaseId: string, expiry: number) => { + await queueService.queue( + QueueName.DynamicSecretRevocation, + QueueJobs.DynamicSecretRevocation, + { leaseId }, + { + jobId: leaseId, + delay: expiry, + removeOnFail: { + count: 3 + }, + removeOnComplete: true + } + ); + }; + + const unsetLeaseRevocation = async (leaseId: string) => { + await queueService.stopJobById(QueueName.DynamicSecretRevocation, leaseId); + }; + + queueService.start(QueueName.DynamicSecretRevocation, async (job) => { + try { + if (job.name === QueueJobs.DynamicSecretRevocation) { + const { leaseId } = job.data as { leaseId: string }; + logger.info("Dynamic secret lease revocation started: ", leaseId, job.id); + const dynamicSecretLease = await dynamicSecretLeaseDAL.findById(leaseId); + if (!dynamicSecretLease) throw new DisableRotationErrors({ message: "Dynamic secret lease not found" }); + + const dynamicSecretCfg = await dynamicSecretDAL.findById(dynamicSecretLease.dynamicSecretId); + if (!dynamicSecretCfg) throw new DisableRotationErrors({ message: "Dynamic secret not found" }); + + const selectedProvider = dynamicSecretProviders[dynamicSecretCfg.type as DynamicSecretProviders]; + const decryptedStoredInput = JSON.parse( + infisicalSymmetricDecrypt({ + keyEncoding: dynamicSecretCfg.keyEncoding as SecretKeyEncoding, + ciphertext: dynamicSecretCfg.inputCiphertext, + tag: dynamicSecretCfg.inputTag, + iv: dynamicSecretCfg.inputIV + }) + ) as object; + + await selectedProvider.revoke(decryptedStoredInput, dynamicSecretLease.externalEntityId); + await dynamicSecretLeaseDAL.deleteById(dynamicSecretLease.id); + return; + } + + if (job.name === QueueJobs.DynamicSecretPruning) { + const { dynamicSecretCfgId } = job.data as { dynamicSecretCfgId: string }; + logger.info("Dynamic secret pruning started: ", dynamicSecretCfgId, job.id); + const dynamicSecretCfg = await dynamicSecretDAL.findById(dynamicSecretCfgId); + if (!dynamicSecretCfg) throw new DisableRotationErrors({ message: "Dynamic secret not found" }); + if (!dynamicSecretCfg.isDeleting) throw new DisableRotationErrors({ message: "Document not deleted" }); + + const dynamicSecretLeases = await dynamicSecretLeaseDAL.find({ dynamicSecretId: dynamicSecretCfgId }); + if (dynamicSecretLeases.length) throw new DisableRotationErrors({ message: "Dynamic secret lease not found" }); + + const selectedProvider = dynamicSecretProviders[dynamicSecretCfg.type as DynamicSecretProviders]; + const decryptedStoredInput = JSON.parse( + infisicalSymmetricDecrypt({ + keyEncoding: dynamicSecretCfg.keyEncoding as SecretKeyEncoding, + ciphertext: dynamicSecretCfg.inputCiphertext, + tag: dynamicSecretCfg.inputTag, + iv: dynamicSecretCfg.inputIV + }) + ) as object; + + await Promise.allSettled(dynamicSecretLeases.map(({ id }) => unsetLeaseRevocation(id))); + await Promise.allSettled( + dynamicSecretLeases.map(({ externalEntityId }) => + selectedProvider.revoke(decryptedStoredInput, externalEntityId) + ) + ); + + await dynamicSecretDAL.deleteById(dynamicSecretCfgId); + } + logger.info("Finished dynamic secret job", job.id); + } catch (error) { + if (error instanceof DisableRotationErrors) { + if (job.id) { + await queueService.stopRepeatableJobByJobId(QueueName.DynamicSecretRevocation, job.id); + } + } + } + }); + + queueService.listen(QueueName.AuditLogPrune, "failed", (err) => { + logger.error(err, `${QueueName.AuditLogPrune}: log pruning failed`); + }); + + return { + pruneDynamicSecret, + setLeaseRevocation, + unsetLeaseRevocation + }; +}; diff --git a/backend/src/services/dynamic-secret-lease/dynamic-secret-lease-service.ts b/backend/src/services/dynamic-secret-lease/dynamic-secret-lease-service.ts new file mode 100644 index 000000000..1aace52c1 --- /dev/null +++ b/backend/src/services/dynamic-secret-lease/dynamic-secret-lease-service.ts @@ -0,0 +1,256 @@ +import { ForbiddenError, subject } from "@casl/ability"; +import ms from "ms"; + +import { SecretKeyEncoding } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; +import { BadRequestError } from "@app/lib/errors"; + +import { TDynamicSecretDALFactory } from "../dynamic-secret/dynamic-secret-dal"; +import { DynamicSecretProviders, TDynamicProviderFns } from "../dynamic-secret/providers/models"; +import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; +import { TDynamicSecretLeaseDALFactory } from "./dynamic-secret-lease-dal"; +import { TDynamicSecretLeaseQueueServiceFactory } from "./dynamic-secret-lease-queue"; +import { + TCreateDynamicSecretLeaseDTO, + TDeleteDynamicSecretLeaseDTO, + TListDynamicSecretLeasesDTO, + TRenewDynamicSecretLeaseDTO +} from "./dynamic-secret-lease-types"; + +type TDynamicSecretLeaseServiceFactoryDep = { + dynamicSecretLeaseDAL: TDynamicSecretLeaseDALFactory; + dynamicSecretDAL: Pick<TDynamicSecretDALFactory, "findOne">; + dynamicSecretProviders: Record<DynamicSecretProviders, TDynamicProviderFns>; + dynamicSecretQueueService: TDynamicSecretLeaseQueueServiceFactory; + folderDAL: Pick<TSecretFolderDALFactory, "findBySecretPath">; + permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">; +}; + +export type TDynamicSecretLeaseServiceFactory = ReturnType<typeof dynamicSecretLeaseServiceFactory>; + +export const dynamicSecretLeaseServiceFactory = ({ + dynamicSecretLeaseDAL, + dynamicSecretProviders, + dynamicSecretDAL, + folderDAL, + permissionService, + dynamicSecretQueueService +}: TDynamicSecretLeaseServiceFactoryDep) => { + const create = async ({ + environment, + path, + slug, + projectId, + actor, + actorId, + actorOrgId, + actorAuthMethod, + ttl + }: TCreateDynamicSecretLeaseDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) + ); + + const folder = await folderDAL.findBySecretPath(projectId, environment, path); + if (!folder) throw new BadRequestError({ message: "Folder not found" }); + + const dynamicSecretCfg = await dynamicSecretDAL.findOne({ slug, folderId: folder.id }); + if (!dynamicSecretCfg) throw new BadRequestError({ message: "Dynamic secret not found" }); + + const selectedProvider = dynamicSecretProviders[dynamicSecretCfg.type as DynamicSecretProviders]; + const decryptedStoredInput = JSON.parse( + infisicalSymmetricDecrypt({ + keyEncoding: dynamicSecretCfg.keyEncoding as SecretKeyEncoding, + ciphertext: dynamicSecretCfg.inputCiphertext, + tag: dynamicSecretCfg.inputTag, + iv: dynamicSecretCfg.inputIV + }) + ) as object; + + const selectedTTL = ttl ?? dynamicSecretCfg.defaultTTL; + const { maxTTL } = dynamicSecretCfg; + const expireAt = new Date(new Date().getTime() + ms(selectedTTL)); + if (maxTTL) { + const maxExpiryDate = new Date(new Date().getTime() + ms(maxTTL)); + if (expireAt > maxExpiryDate) throw new BadRequestError({ message: "TTL cannot be larger than max TTL" }); + } + + const { entityId, data } = await selectedProvider.create(decryptedStoredInput, expireAt.getTime()); + const dynamicSecretLease = await dynamicSecretLeaseDAL.create({ + expireAt, + version: 1, + dynamicSecretId: dynamicSecretCfg.id, + externalEntityId: entityId + }); + await dynamicSecretQueueService.setLeaseRevocation(dynamicSecretLease.id, Number(expireAt) - Number(new Date())); + return { lease: dynamicSecretLease, data }; + }; + + const renewLease = async ({ + ttl, + actorAuthMethod, + actorOrgId, + actorId, + actor, + projectId, + path, + environment, + leaseId + }: TRenewDynamicSecretLeaseDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) + ); + + const folder = await folderDAL.findBySecretPath(projectId, environment, path); + if (!folder) throw new BadRequestError({ message: "Folder not found" }); + + const dynamicSecretLease = await dynamicSecretLeaseDAL.findById(leaseId); + if (!dynamicSecretLease) throw new BadRequestError({ message: "Dynamic secret lease not found" }); + + const dynamicSecretCfg = await dynamicSecretDAL.findOne({ + id: dynamicSecretLease.dynamicSecretId, + folderId: folder.id + }); + if (!dynamicSecretCfg) throw new BadRequestError({ message: "Dynamic secret not found" }); + + const selectedProvider = dynamicSecretProviders[dynamicSecretCfg.type as DynamicSecretProviders]; + const decryptedStoredInput = JSON.parse( + infisicalSymmetricDecrypt({ + keyEncoding: dynamicSecretCfg.keyEncoding as SecretKeyEncoding, + ciphertext: dynamicSecretCfg.inputCiphertext, + tag: dynamicSecretCfg.inputTag, + iv: dynamicSecretCfg.inputIV + }) + ) as object; + + const selectedTTL = ttl ?? dynamicSecretCfg.defaultTTL; + const { maxTTL } = dynamicSecretCfg; + const expireAt = new Date(dynamicSecretLease.expireAt.getTime() + ms(selectedTTL)); + if (maxTTL) { + const maxExpiryDate = new Date(dynamicSecretLease.createdAt.getTime() + ms(maxTTL)); + if (expireAt > maxExpiryDate) throw new BadRequestError({ message: "TTL cannot be larger than max ttl" }); + } + + const { entityId } = await selectedProvider.renew( + decryptedStoredInput, + dynamicSecretLease.externalEntityId, + expireAt.getTime() + ); + + await dynamicSecretQueueService.unsetLeaseRevocation(dynamicSecretLease.id); + await dynamicSecretQueueService.setLeaseRevocation(dynamicSecretLease.id, Number(new Date()) - Number(expireAt)); + const updatedDynamicSecretLease = await dynamicSecretLeaseDAL.updateById(dynamicSecretLease.id, { + expireAt, + externalEntityId: entityId + }); + return updatedDynamicSecretLease; + }; + + const revokeLease = async ({ + leaseId, + environment, + path, + projectId, + actor, + actorId, + actorOrgId, + actorAuthMethod + }: TDeleteDynamicSecretLeaseDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) + ); + + const folder = await folderDAL.findBySecretPath(projectId, environment, path); + if (!folder) throw new BadRequestError({ message: "Folder not found" }); + + const dynamicSecretLease = await dynamicSecretLeaseDAL.findById(leaseId); + if (!dynamicSecretLease) throw new BadRequestError({ message: "Dynamic secret lease not found" }); + + const dynamicSecretCfg = await dynamicSecretDAL.findOne({ + id: dynamicSecretLease.dynamicSecretId, + folderId: folder.id + }); + if (!dynamicSecretCfg) throw new BadRequestError({ message: "Dynamic secret not found" }); + + const selectedProvider = dynamicSecretProviders[dynamicSecretCfg.type as DynamicSecretProviders]; + const decryptedStoredInput = JSON.parse( + infisicalSymmetricDecrypt({ + keyEncoding: dynamicSecretCfg.keyEncoding as SecretKeyEncoding, + ciphertext: dynamicSecretCfg.inputCiphertext, + tag: dynamicSecretCfg.inputTag, + iv: dynamicSecretCfg.inputIV + }) + ) as object; + + await selectedProvider.revoke(decryptedStoredInput, dynamicSecretLease.externalEntityId); + + await dynamicSecretQueueService.unsetLeaseRevocation(dynamicSecretLease.id); + const deletedDynamicSecretLease = await dynamicSecretLeaseDAL.deleteById(dynamicSecretLease.id); + return deletedDynamicSecretLease; + }; + + const listLeases = async ({ + path, + slug, + actor, + actorId, + projectId, + actorOrgId, + environment, + actorAuthMethod + }: TListDynamicSecretLeasesDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Edit, + subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) + ); + + const folder = await folderDAL.findBySecretPath(projectId, environment, path); + if (!folder) throw new BadRequestError({ message: "Folder not found" }); + + const dynamicSecretCfg = await dynamicSecretDAL.findOne({ slug, folderId: folder.id }); + if (!dynamicSecretCfg) throw new BadRequestError({ message: "Dynamic secret not found" }); + + const dynamicSecretLeases = await dynamicSecretLeaseDAL.find({ dynamicSecretId: dynamicSecretCfg.id }); + return dynamicSecretLeases; + }; + + return { + create, + listLeases, + revokeLease, + renewLease + }; +}; diff --git a/backend/src/services/dynamic-secret-lease/dynamic-secret-lease-types.ts b/backend/src/services/dynamic-secret-lease/dynamic-secret-lease-types.ts new file mode 100644 index 000000000..decc7e3d1 --- /dev/null +++ b/backend/src/services/dynamic-secret-lease/dynamic-secret-lease-types.ts @@ -0,0 +1,27 @@ +import { TProjectPermission } from "@app/lib/types"; + +export type TCreateDynamicSecretLeaseDTO = { + slug: string; + path: string; + environment: string; + ttl?: string; +} & TProjectPermission; + +export type TListDynamicSecretLeasesDTO = { + slug: string; + path: string; + environment: string; +} & TProjectPermission; + +export type TDeleteDynamicSecretLeaseDTO = { + leaseId: string; + path: string; + environment: string; +} & TProjectPermission; + +export type TRenewDynamicSecretLeaseDTO = { + leaseId: string; + path: string; + environment: string; + ttl?: string; +} & TProjectPermission;