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;