From 1a5f66fe464a5e254a6f31cb9bbbfb3795651169 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Fri, 25 Oct 2024 20:33:23 +0800 Subject: [PATCH 1/6] feat: added support for sap hana dynamic secrets --- backend/package-lock.json | 23 ++ backend/package.json | 1 + backend/src/@types/hdb.d.ts | 4 + .../dynamic-secret/providers/index.ts | 4 +- .../dynamic-secret/providers/models.ts | 15 +- .../dynamic-secret/providers/sap-hana.ts | 183 ++++++++++ frontend/src/hooks/api/dynamicSecret/types.ts | 20 +- .../CreateDynamicSecretForm.tsx | 45 ++- .../SapHanaInputForm.tsx | 329 +++++++++++++++++ .../CreateDynamicSecretLease.tsx | 18 + .../EditDynamicSecretForm.tsx | 38 +- .../EditDynamicSecretSapHanaForm.tsx | 337 ++++++++++++++++++ 12 files changed, 993 insertions(+), 24 deletions(-) create mode 100644 backend/src/@types/hdb.d.ts create mode 100644 backend/src/ee/services/dynamic-secret/providers/sap-hana.ts create mode 100644 frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/SapHanaInputForm.tsx create mode 100644 frontend/src/views/SecretMainPage/components/DynamicSecretListView/EditDynamicSecretForm/EditDynamicSecretSapHanaForm.tsx diff --git a/backend/package-lock.json b/backend/package-lock.json index dbd8c8073..792e6b217 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -56,6 +56,7 @@ "google-auth-library": "^9.9.0", "googleapis": "^137.1.0", "handlebars": "^4.7.8", + "hdb": "^0.19.10", "ioredis": "^5.3.2", "jmespath": "^0.16.0", "jsonwebtoken": "^9.0.2", @@ -12196,6 +12197,28 @@ "node": ">= 0.4" } }, + "node_modules/hdb": { + "version": "0.19.10", + "resolved": "https://registry.npmjs.org/hdb/-/hdb-0.19.10.tgz", + "integrity": "sha512-er0oyute1aMjf6v41JU7z1a6Zo8lqj3muC7C4Uoi81Xf4WNdjPb424wUnXIhaf4HS8H9ARDyWrMGJTvPU2jjPw==", + "dependencies": { + "iconv-lite": "^0.4.18" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/hdb/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/helmet": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/helmet/-/helmet-7.1.0.tgz", diff --git a/backend/package.json b/backend/package.json index a713728e9..9620279ca 100644 --- a/backend/package.json +++ b/backend/package.json @@ -161,6 +161,7 @@ "google-auth-library": "^9.9.0", "googleapis": "^137.1.0", "handlebars": "^4.7.8", + "hdb": "^0.19.10", "ioredis": "^5.3.2", "jmespath": "^0.16.0", "jsonwebtoken": "^9.0.2", diff --git a/backend/src/@types/hdb.d.ts b/backend/src/@types/hdb.d.ts new file mode 100644 index 000000000..4d8f78567 --- /dev/null +++ b/backend/src/@types/hdb.d.ts @@ -0,0 +1,4 @@ +declare module "hdb" { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Untyped, the function returns `any`. + function createClient(options): any; +} diff --git a/backend/src/ee/services/dynamic-secret/providers/index.ts b/backend/src/ee/services/dynamic-secret/providers/index.ts index b3c243b5e..007ca9d49 100644 --- a/backend/src/ee/services/dynamic-secret/providers/index.ts +++ b/backend/src/ee/services/dynamic-secret/providers/index.ts @@ -9,6 +9,7 @@ import { MongoAtlasProvider } from "./mongo-atlas"; import { MongoDBProvider } from "./mongo-db"; import { RabbitMqProvider } from "./rabbit-mq"; import { RedisDatabaseProvider } from "./redis"; +import { SapHanaProvider } from "./sap-hana"; import { SqlDatabaseProvider } from "./sql-database"; export const buildDynamicSecretProviders = () => ({ @@ -22,5 +23,6 @@ export const buildDynamicSecretProviders = () => ({ [DynamicSecretProviders.ElasticSearch]: ElasticSearchProvider(), [DynamicSecretProviders.RabbitMq]: RabbitMqProvider(), [DynamicSecretProviders.AzureEntraID]: AzureEntraIDProvider(), - [DynamicSecretProviders.Ldap]: LdapProvider() + [DynamicSecretProviders.Ldap]: LdapProvider(), + [DynamicSecretProviders.SapHana]: SapHanaProvider() }); diff --git a/backend/src/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts index c204333ce..f092b70e4 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -166,6 +166,17 @@ export const DynamicSecretMongoDBSchema = z.object({ ) }); +export const DynamicSecretSapHanaSchema = z.object({ + host: z.string().trim().toLowerCase(), + port: z.number(), + username: z.string().trim(), + password: z.string().trim(), + creationStatement: z.string().trim(), + revocationStatement: z.string().trim(), + renewStatement: z.string().trim().optional(), + ca: z.string().optional() +}); + export const AzureEntraIDSchema = z.object({ tenantId: z.string().trim().min(1), userId: z.string().trim().min(1), @@ -196,7 +207,8 @@ export enum DynamicSecretProviders { MongoDB = "mongo-db", RabbitMq = "rabbit-mq", AzureEntraID = "azure-entra-id", - Ldap = "ldap" + Ldap = "ldap", + SapHana = "sap-hana" } export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ @@ -204,6 +216,7 @@ export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal(DynamicSecretProviders.Cassandra), inputs: DynamicSecretCassandraSchema }), z.object({ type: z.literal(DynamicSecretProviders.AwsIam), inputs: DynamicSecretAwsIamSchema }), z.object({ type: z.literal(DynamicSecretProviders.Redis), inputs: DynamicSecretRedisDBSchema }), + z.object({ type: z.literal(DynamicSecretProviders.SapHana), inputs: DynamicSecretSapHanaSchema }), z.object({ type: z.literal(DynamicSecretProviders.AwsElastiCache), inputs: DynamicSecretAwsElastiCacheSchema }), z.object({ type: z.literal(DynamicSecretProviders.MongoAtlas), inputs: DynamicSecretMongoAtlasSchema }), z.object({ type: z.literal(DynamicSecretProviders.ElasticSearch), inputs: DynamicSecretElasticSearchSchema }), diff --git a/backend/src/ee/services/dynamic-secret/providers/sap-hana.ts b/backend/src/ee/services/dynamic-secret/providers/sap-hana.ts new file mode 100644 index 000000000..ce2ffae78 --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/providers/sap-hana.ts @@ -0,0 +1,183 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ + +import handlebars from "handlebars"; +import hdb from "hdb"; +import { customAlphabet } from "nanoid"; +import { z } from "zod"; + +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError } from "@app/lib/errors"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; + +import { DynamicSecretSapHanaSchema, TDynamicProviderFns } from "./models"; + +const generatePassword = (size = 48) => { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + return customAlphabet(charset, 48)(size); +}; + +const generateUsername = () => { + return alphaNumericNanoId(32); +}; + +export const SapHanaProvider = (): TDynamicProviderFns => { + const validateProviderInputs = async (inputs: unknown) => { + const appCfg = getConfig(); + const providerInputs = await DynamicSecretSapHanaSchema.parseAsync(inputs); + + if ( + appCfg.isCloud && + // localhost + // internal ips + (providerInputs.host === "host.docker.internal" || + providerInputs.host.match(/^10\.\d+\.\d+\.\d+/) || + providerInputs.host.match(/^192\.168\.\d+\.\d+/)) + ) + throw new BadRequestError({ message: "Invalid db host" }); + + if (providerInputs.host === "localhost" || providerInputs.host === "127.0.0.1") { + throw new BadRequestError({ message: "Invalid db host" }); + } + + return providerInputs; + }; + + const getClient = async (providerInputs: z.infer) => { + const client = hdb.createClient({ + host: providerInputs.host, + port: providerInputs.port, + user: providerInputs.username, + password: providerInputs.password + }); + + await new Promise((resolve, reject) => { + client.connect((err: any) => { + if (err) { + return reject(err); + } + + if (client.readyState) { + return resolve(true); + } + + reject(new Error("SAP HANA client not ready")); + }); + }); + + return client; + }; + + const validateConnection = async (inputs: unknown) => { + const providerInputs = await validateProviderInputs(inputs); + const client = await getClient(providerInputs); + + const testResult: boolean = await new Promise((resolve, reject) => { + client.exec("SELECT 1 FROM DUMMY;", (err: any) => { + if (err) { + reject(); + } + + resolve(true); + }); + }); + + return testResult; + }; + + const create = async (inputs: unknown, expireAt: number) => { + const providerInputs = await validateProviderInputs(inputs); + + const username = generateUsername(); + const password = generatePassword(); + const expiration = new Date(expireAt).toISOString(); + + const client = await getClient(providerInputs); + const creationStatement = handlebars.compile(providerInputs.creationStatement, { noEscape: true })({ + username, + password, + expiration + }); + + const queries = creationStatement.toString().split(";").filter(Boolean); + for await (const query of queries) { + await new Promise((resolve, reject) => { + client.exec(query, (err: any) => { + if (err) { + reject( + new BadRequestError({ + message: err.message + }) + ); + } + resolve(true); + }); + }); + } + + return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: password } }; + }; + + const revoke = async (inputs: unknown, username: string) => { + const providerInputs = await validateProviderInputs(inputs); + const client = await getClient(providerInputs); + const revokeStatement = handlebars.compile(providerInputs.revocationStatement)({ username }); + const queries = revokeStatement.toString().split(";").filter(Boolean); + for await (const query of queries) { + await new Promise((resolve, reject) => { + client.exec(query, (err: any) => { + if (err) { + reject( + new BadRequestError({ + message: err.message + }) + ); + } + resolve(true); + }); + }); + } + + return { entityId: username }; + }; + + const renew = async (inputs: unknown, username: string, expireAt: number) => { + const providerInputs = await validateProviderInputs(inputs); + const client = await getClient(providerInputs); + try { + const expiration = new Date(expireAt).toISOString(); + + const renewStatement = handlebars.compile(providerInputs.renewStatement)({ username, expiration }); + const queries = renewStatement.toString().split(";").filter(Boolean); + for await (const query of queries) { + await new Promise((resolve, reject) => { + client.exec(query, (err: any) => { + if (err) { + reject( + new BadRequestError({ + message: err.message + }) + ); + } + resolve(true); + }); + }); + } + } finally { + client.disconnect(); + } + + return { entityId: username }; + }; + + return { + validateProviderInputs, + validateConnection, + create, + revoke, + renew + }; +}; diff --git a/frontend/src/hooks/api/dynamicSecret/types.ts b/frontend/src/hooks/api/dynamicSecret/types.ts index 792fad8d3..9e096bcc0 100644 --- a/frontend/src/hooks/api/dynamicSecret/types.ts +++ b/frontend/src/hooks/api/dynamicSecret/types.ts @@ -26,7 +26,8 @@ export enum DynamicSecretProviders { MongoDB = "mongo-db", RabbitMq = "rabbit-mq", AzureEntraId = "azure-entra-id", - Ldap = "ldap" + Ldap = "ldap", + SapHana = "sap-hana" } export enum SqlProviders { @@ -189,7 +190,7 @@ export type TDynamicSecretProvider = applicationId: string; clientSecret: string; }; - } + } | { type: DynamicSecretProviders.Ldap; inputs: { @@ -201,9 +202,20 @@ export type TDynamicSecretProvider = revocationLdif: string; rollbackLdif?: string; }; + } + | { + type: DynamicSecretProviders.SapHana; + inputs: { + host: string; + port: number; + username: string; + password: string; + creationStatement: string; + revocationStatement: string; + renewStatement?: string; + ca?: string | undefined; + }; }; - ; - export type TCreateDynamicSecretDTO = { projectSlug: string; provider: TDynamicSecretProvider; diff --git a/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx b/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx index 029ab6251..08987c00c 100644 --- a/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx +++ b/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx @@ -1,6 +1,14 @@ import { useState } from "react"; import { DiRedis } from "react-icons/di"; -import { SiApachecassandra, SiElasticsearch, SiFiles, SiMicrosoftazure, SiMongodb, SiRabbitmq } from "react-icons/si"; +import { + SiApachecassandra, + SiElasticsearch, + SiFiles, + SiMicrosoftazure, + SiMongodb, + SiRabbitmq, + SiSap +} from "react-icons/si"; import { faAws } from "@fortawesome/free-brands-svg-icons"; import { faDatabase } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -19,6 +27,7 @@ import { MongoAtlasInputForm } from "./MongoAtlasInputForm"; import { MongoDBDatabaseInputForm } from "./MongoDBInputForm"; import { RabbitMqInputForm } from "./RabbitMqInputForm"; import { RedisInputForm } from "./RedisInputForm"; +import { SapHanaInputForm } from "./SapHanaInputForm"; import { SqlDatabaseInputForm } from "./SqlDatabaseInputForm"; type Props = { @@ -83,12 +92,17 @@ const DYNAMIC_SECRET_LIST = [ { icon: , provider: DynamicSecretProviders.AzureEntraId, - title: "Azure Entra ID", + title: "Azure Entra ID" }, { icon: , provider: DynamicSecretProviders.Ldap, - title: "LDAP", + title: "LDAP" + }, + { + icon: , + provider: DynamicSecretProviders.SapHana, + title: "SAP HANA" } ]; @@ -329,8 +343,7 @@ export const CreateDynamicSecretForm = ({ environment={environment} /> - ) - } + )} {wizardStep === WizardSteps.ProviderInputs && selectedProvider === DynamicSecretProviders.Ldap && ( - ) - } - + )} + {wizardStep === WizardSteps.ProviderInputs && + selectedProvider === DynamicSecretProviders.SapHana && ( + + + + )} diff --git a/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/SapHanaInputForm.tsx b/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/SapHanaInputForm.tsx new file mode 100644 index 000000000..f955b2735 --- /dev/null +++ b/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/SapHanaInputForm.tsx @@ -0,0 +1,329 @@ +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import ms from "ms"; +import { z } from "zod"; + +import { TtlFormLabel } from "@app/components/features"; +import { createNotification } from "@app/components/notifications"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, + Button, + FormControl, + Input, + SecretInput, + TextArea +} from "@app/components/v2"; +import { useCreateDynamicSecret } from "@app/hooks/api"; +import { DynamicSecretProviders } from "@app/hooks/api/dynamicSecret/types"; + +const formSchema = z.object({ + provider: z.object({ + host: z.string().toLowerCase().min(1), + port: z.coerce.number(), + username: z.string().min(1), + password: z.string().min(1), + creationStatement: z.string().min(1), + revocationStatement: z.string().min(1), + renewStatement: z.string().optional(), + ca: z.string().optional() + }), + defaultTTL: z.string().superRefine((val, ctx) => { + const valMs = ms(val); + if (valMs < 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); + // a day + if (valMs > 24 * 60 * 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + maxTTL: z + .string() + .optional() + .superRefine((val, ctx) => { + if (!val) return; + const valMs = ms(val); + if (valMs < 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); + // a day + if (valMs > 24 * 60 * 60 * 1000) + ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); + }), + name: z.string().refine((val) => val.toLowerCase() === val, "Must be lowercase") +}); +type TForm = z.infer; + +type Props = { + onCompleted: () => void; + onCancel: () => void; + secretPath: string; + projectSlug: string; + environment: string; +}; + +export const SapHanaInputForm = ({ + onCompleted, + onCancel, + environment, + secretPath, + projectSlug +}: Props) => { + const { + control, + formState: { isSubmitting }, + handleSubmit + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + provider: { + creationStatement: `CREATE USER {{username}} PASSWORD {{password}} NO FORCE_FIRST_PASSWORD_CHANGE VALID UNTIL '{{expiration}}'; +GRANT "MONITORING" TO {{username}};`, + revocationStatement: `REVOKE "MONITORING" FROM {{username}}; +DROP USER {{username}};`, + renewStatement: "ALTER USER {{username}} VALID UNTIL '{{expiration}}';" + } + } + }); + + const createDynamicSecret = useCreateDynamicSecret(); + + const handleCreateDynamicSecret = async ({ name, maxTTL, provider, defaultTTL }: TForm) => { + // wait till previous request is finished + if (createDynamicSecret.isLoading) return; + try { + await createDynamicSecret.mutateAsync({ + provider: { type: DynamicSecretProviders.SapHana, inputs: provider }, + maxTTL, + name, + path: secretPath, + defaultTTL, + projectSlug, + environmentSlug: environment + }); + onCompleted(); + } catch (err) { + createNotification({ + type: "error", + text: "Failed to create dynamic secret" + }); + } + }; + + return ( +
+
+
+
+
+ ( + + + + )} + /> +
+
+ ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+
+ ( + } + isError={Boolean(error?.message)} + errorText={error?.message} + > + + + )} + /> +
+
+
+
+ Configuration +
+
+
+ ( + + + + )} + /> + ( + + + + )} + /> +
+
+ ( + + + + )} + /> + ( + + + + )} + /> +
+
+ ( + + + + )} + /> + + + Modify SQL Statements + + ( + +