From 11c7b5c67416ca24d62ba3cfac3c0087f7c062a3 Mon Sep 17 00:00:00 2001 From: Akhil Mohan Date: Wed, 20 Mar 2024 14:57:16 +0530 Subject: [PATCH] feat(server): service for dynamic secret config --- .../dynamic-secret/dynamic-secret-dal.ts | 10 + .../dynamic-secret/dynamic-secret-service.ts | 230 ++++++++++++++++++ .../dynamic-secret/dynamic-secret-types.ts | 36 +++ .../dynamic-secret/providers/index.ts | 6 + .../dynamic-secret/providers/models.ts | 33 +++ .../dynamic-secret/providers/sql-database.ts | 101 ++++++++ 6 files changed, 416 insertions(+) create mode 100644 backend/src/services/dynamic-secret/dynamic-secret-dal.ts create mode 100644 backend/src/services/dynamic-secret/dynamic-secret-service.ts create mode 100644 backend/src/services/dynamic-secret/dynamic-secret-types.ts create mode 100644 backend/src/services/dynamic-secret/providers/index.ts create mode 100644 backend/src/services/dynamic-secret/providers/models.ts create mode 100644 backend/src/services/dynamic-secret/providers/sql-database.ts diff --git a/backend/src/services/dynamic-secret/dynamic-secret-dal.ts b/backend/src/services/dynamic-secret/dynamic-secret-dal.ts new file mode 100644 index 000000000..0cc4aca2f --- /dev/null +++ b/backend/src/services/dynamic-secret/dynamic-secret-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 TDynamicSecretDALFactory = ReturnType; + +export const dynamicSecretDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.DynamicSecret); + return orm; +}; diff --git a/backend/src/services/dynamic-secret/dynamic-secret-service.ts b/backend/src/services/dynamic-secret/dynamic-secret-service.ts new file mode 100644 index 000000000..c9ce0428e --- /dev/null +++ b/backend/src/services/dynamic-secret/dynamic-secret-service.ts @@ -0,0 +1,230 @@ +import { ForbiddenError, subject } from "@casl/ability"; + +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, infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; +import { BadRequestError } from "@app/lib/errors"; + +import { TDynamicSecretLeaseDALFactory } from "../dynamic-secret-lease/dynamic-secret-lease-dal"; +import { TDynamicSecretLeaseQueueServiceFactory } from "../dynamic-secret-lease/dynamic-secret-lease-queue"; +import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; +import { TDynamicSecretDALFactory } from "./dynamic-secret-dal"; +import { + TCreateDynamicSecretDTO, + TDeleteDyanmicSecretDTO, + TListDyanmicSecretsDTO, + TUpdateDynamicSecretDTO +} from "./dynamic-secret-types"; +import { DynamicSecretProviders, TDynamicProviderFns } from "./providers/models"; + +type TDynamicSecretServiceFactoryDep = { + dynamicSecretDAL: TDynamicSecretDALFactory; + dynamicSecretLeaseDAL: Pick; + dynamicSecretProviders: Record; + dynamicSecretQueueService: Pick; + folderDAL: Pick; + permissionService: Pick; +}; + +export type TDynamicSecretServiceFactory = ReturnType; + +export const dynamicSecretServiceFactory = ({ + dynamicSecretDAL, + dynamicSecretLeaseDAL, + folderDAL, + dynamicSecretProviders, + permissionService, + dynamicSecretQueueService +}: TDynamicSecretServiceFactoryDep) => { + const create = async ({ + path, + actor, + slug, + actorId, + maxTTL, + provider, + environment, + projectId, + actorOrgId, + defaultTTL, + actorAuthMethod + }: TCreateDynamicSecretDTO) => { + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Create, + subject(ProjectPermissionSub.Secrets, { environment, secretPath: path }) + ); + + const folder = await folderDAL.findBySecretPath(projectId, environment, path); + if (!folder) throw new BadRequestError({ message: "Folder not found" }); + + const existingDynamicSecret = await dynamicSecretDAL.findOne({ slug, folderId: folder.id }); + if (existingDynamicSecret) + throw new BadRequestError({ message: "Provided dynamic secret already exist under the folder" }); + + const selectedProvider = dynamicSecretProviders[provider.type]; + const inputs = await selectedProvider.validateProviderInputs(provider.inputs); + const encryptedInput = infisicalSymmetricEncypt(JSON.stringify(inputs)); + const dynamicSecretCfg = await dynamicSecretDAL.create({ + type: provider.type, + version: 1, + inputIV: encryptedInput.iv, + inputTag: encryptedInput.tag, + inputCiphertext: encryptedInput.ciphertext, + algorithm: encryptedInput.algorithm, + keyEncoding: encryptedInput.encoding, + maxTTL, + defaultTTL, + folderId: folder.id, + slug + }); + return dynamicSecretCfg; + }; + + const updateBySlug = async ({ + slug, + maxTTL, + defaultTTL, + inputs, + environment, + projectId, + path, + actor, + actorId, + newSlug, + actorOrgId, + actorAuthMethod + }: TUpdateDynamicSecretDTO) => { + 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" }); + + if (newSlug) { + const existingDynamicSecret = await dynamicSecretDAL.findOne({ slug: newSlug, folderId: folder.id }); + if (existingDynamicSecret) + throw new BadRequestError({ message: "Provided dynamic secret already exist under the folder" }); + } + + 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 newInput = { ...decryptedStoredInput, ...(inputs || {}) }; + const updatedInput = await selectedProvider.validateProviderInputs(newInput); + const encryptedInput = infisicalSymmetricEncypt(JSON.stringify(updatedInput)); + const updatedDynamicCfg = await dynamicSecretDAL.updateById(dynamicSecretCfg.id, { + inputIV: encryptedInput.iv, + inputTag: encryptedInput.tag, + inputCiphertext: encryptedInput.ciphertext, + algorithm: encryptedInput.algorithm, + keyEncoding: encryptedInput.encoding, + maxTTL, + defaultTTL, + slug: newSlug ?? slug + }); + + return updatedDynamicCfg; + }; + + const deleteBySlug = async ({ + actorAuthMethod, + actorOrgId, + actorId, + actor, + projectId, + slug, + path, + environment + }: TDeleteDyanmicSecretDTO) => { + 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 leases = await dynamicSecretLeaseDAL.find({ dynamicSecretId: dynamicSecretCfg.id }); + // if leases exist we should flag it as deleting and then remove leases in background + // then delete the main one + if (leases.length) { + const updatedDynamicSecretCfg = await dynamicSecretDAL.updateById(dynamicSecretCfg.id, { isDeleting: true }); + await dynamicSecretQueueService.pruneDynamicSecret(updatedDynamicSecretCfg.id); + return updatedDynamicSecretCfg; + } + // if no leases just delete the config + const deletedDynamicSecretCfg = await dynamicSecretDAL.deleteById(dynamicSecretCfg.id); + return deletedDynamicSecretCfg; + }; + + const list = async ({ + actorAuthMethod, + actorOrgId, + actorId, + actor, + projectId, + path, + environment + }: TListDyanmicSecretsDTO) => { + 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.find({ folderId: folder.id }); + return dynamicSecretCfg; + }; + + return { + create, + updateBySlug, + deleteBySlug, + list + }; +}; diff --git a/backend/src/services/dynamic-secret/dynamic-secret-types.ts b/backend/src/services/dynamic-secret/dynamic-secret-types.ts new file mode 100644 index 000000000..7ce31684a --- /dev/null +++ b/backend/src/services/dynamic-secret/dynamic-secret-types.ts @@ -0,0 +1,36 @@ +import { z } from "zod"; + +import { TProjectPermission } from "@app/lib/types"; + +import { DynamicSecretProviderSchema } from "./providers/models"; + +type TProvider = z.infer; +export type TCreateDynamicSecretDTO = { + provider: TProvider; + defaultTTL: string; + maxTTL?: string; + path: string; + environment: string; + slug: string; +} & TProjectPermission; + +export type TUpdateDynamicSecretDTO = { + slug: string; + newSlug?: string; + defaultTTL?: string; + maxTTL?: string; + path: string; + environment: string; + inputs?: TProvider["inputs"]; +} & TProjectPermission; + +export type TDeleteDyanmicSecretDTO = { + slug: string; + path: string; + environment: string; +} & TProjectPermission; + +export type TListDyanmicSecretsDTO = { + path: string; + environment: string; +} & TProjectPermission; diff --git a/backend/src/services/dynamic-secret/providers/index.ts b/backend/src/services/dynamic-secret/providers/index.ts new file mode 100644 index 000000000..d66e60802 --- /dev/null +++ b/backend/src/services/dynamic-secret/providers/index.ts @@ -0,0 +1,6 @@ +import { DynamicSecretProviders } from "./models"; +import { SqlDatabaseProvider } from "./sql-database"; + +export const buildDynamicSecretProviders = () => ({ + [DynamicSecretProviders.SqlDatabase]: SqlDatabaseProvider() +}); diff --git a/backend/src/services/dynamic-secret/providers/models.ts b/backend/src/services/dynamic-secret/providers/models.ts new file mode 100644 index 000000000..3b9eb7e16 --- /dev/null +++ b/backend/src/services/dynamic-secret/providers/models.ts @@ -0,0 +1,33 @@ +import { z } from "zod"; + +export enum SqlProviders { + Postgres = "postgres" +} + +export const DynamicSecretSqlDBSchema = z.object({ + client: z.nativeEnum(SqlProviders), + host: z.string().toLowerCase(), + port: z.number(), + database: z.string(), + username: z.string(), + password: z.string(), + creationStatement: z.string(), + revocationStatement: z.string(), + renewStatement: z.string(), + ca: z.string().optional() +}); + +export enum DynamicSecretProviders { + SqlDatabase = "sql-database" +} + +export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal(DynamicSecretProviders.SqlDatabase), inputs: DynamicSecretSqlDBSchema }) +]); + +export type TDynamicProviderFns = { + create: (inputs: unknown, expireAt: number) => Promise<{ entityId: string; data: unknown }>; + validateProviderInputs: (inputs: object) => Promise; + revoke: (inputs: unknown, entityId: string) => Promise<{ entityId: string }>; + renew: (inputs: unknown, entityId: string, expireAt: number) => Promise<{ entityId: string }>; +}; diff --git a/backend/src/services/dynamic-secret/providers/sql-database.ts b/backend/src/services/dynamic-secret/providers/sql-database.ts new file mode 100644 index 000000000..5425f64b6 --- /dev/null +++ b/backend/src/services/dynamic-secret/providers/sql-database.ts @@ -0,0 +1,101 @@ +import handlebars from "handlebars"; +import knex from "knex"; +import { customAlphabet } from "nanoid"; +import { z } from "zod"; + +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError } from "@app/lib/errors"; +import { getDbConnectionHost } from "@app/lib/knex"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; + +import { DynamicSecretSqlDBSchema, TDynamicProviderFns } from "./models"; + +const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000; + +const generatePassword = (size?: number) => { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!#$%^&*()_+-=[]{}|;,./<>"; + return customAlphabet(charset, 20)(size); +}; + +export const SqlDatabaseProvider = (): TDynamicProviderFns => { + const validateProviderInputs = async (inputs: unknown) => { + const appCfg = getConfig(); + const dbHost = appCfg.DB_HOST || getDbConnectionHost(appCfg.DB_CONNECTION_URI); + + const providerInputs = await DynamicSecretSqlDBSchema.parseAsync(inputs); + if (providerInputs.host === "localhost" || providerInputs.host === "127.0.0.1" || dbHost === providerInputs.host) + throw new BadRequestError({ message: "Invalid db host" }); + return providerInputs; + }; + + const getClient = async (providerInputs: z.infer) => { + const ssl = providerInputs.ca ? { rejectUnauthorized: false, ca: providerInputs.ca } : undefined; + const db = knex({ + client: providerInputs.client, + connection: { + database: providerInputs.database, + port: providerInputs.port, + host: providerInputs.host, + user: providerInputs.username, + password: providerInputs.password, + connectionTimeoutMillis: EXTERNAL_REQUEST_TIMEOUT, + ssl, + pool: { min: 0, max: 1 } + } + }); + return db; + }; + + const create = async (inputs: unknown, expireAt: number) => { + const providerInputs = await validateProviderInputs(inputs); + const db = await getClient(providerInputs); + + const username = alphaNumericNanoId(16); + const password = generatePassword(); + const expiration = new Date(expireAt).toISOString(); + + const creationStatement = handlebars.compile(providerInputs.creationStatement, { noEscape: true })({ + username, + password: "infisical", + expiration + }); + + await db.raw(creationStatement.toString()); + await db.destroy(); + return { entityId: username, data: { username, password } }; + }; + + const revoke = async (inputs: unknown, entityId: string) => { + const providerInputs = await validateProviderInputs(inputs); + const db = await getClient(providerInputs); + + const username = entityId; + + const revokeStatement = handlebars.compile(providerInputs.revocationStatement)({ username }); + await db.raw(revokeStatement); + + await db.destroy(); + return { entityId: username }; + }; + + const renew = async (inputs: unknown, entityId: string, expireAt: number) => { + const providerInputs = await validateProviderInputs(inputs); + const db = await getClient(providerInputs); + + const username = entityId; + const expiration = new Date(expireAt).toISOString(); + + const renewStatement = handlebars.compile(providerInputs.renewStatement)({ username, expiration }); + await db.raw(renewStatement); + + await db.destroy(); + return { entityId: username }; + }; + + return { + validateProviderInputs, + create, + revoke, + renew + }; +};