diff --git a/backend/package-lock.json b/backend/package-lock.json index 9937e8549..d3f1d0601 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", @@ -12214,6 +12215,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/dynamic-secret-fns.ts b/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts new file mode 100644 index 000000000..04aeb3950 --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/dynamic-secret-fns.ts @@ -0,0 +1,20 @@ +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError } from "@app/lib/errors"; +import { getDbConnectionHost } from "@app/lib/knex"; + +export const verifyHostInputValidity = (host: string) => { + const appCfg = getConfig(); + const dbHost = appCfg.DB_HOST || getDbConnectionHost(appCfg.DB_CONNECTION_URI); + + if ( + appCfg.isCloud && + // localhost + // internal ips + (host === "host.docker.internal" || host.match(/^10\.\d+\.\d+\.\d+/) || host.match(/^192\.168\.\d+\.\d+/)) + ) + throw new BadRequestError({ message: "Invalid db host" }); + + if (host === "localhost" || host === "127.0.0.1" || dbHost === host) { + throw new BadRequestError({ message: "Invalid db host" }); + } +}; diff --git a/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts b/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts index 3247ef21b..bfe0ac443 100644 --- a/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts +++ b/backend/src/ee/services/dynamic-secret/providers/elastic-search.ts @@ -2,10 +2,9 @@ import { Client as ElasticSearchClient } from "@elastic/elasticsearch"; 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 { verifyHostInputValidity } from "../dynamic-secret-fns"; import { DynamicSecretElasticSearchSchema, ElasticSearchAuthTypes, TDynamicProviderFns } from "./models"; const generatePassword = () => { @@ -19,23 +18,8 @@ const generateUsername = () => { export const ElasticSearchProvider = (): TDynamicProviderFns => { const validateProviderInputs = async (inputs: unknown) => { - const appCfg = getConfig(); - const isCloud = Boolean(appCfg.LICENSE_SERVER_KEY); // quick and dirty way to check if its cloud or not - const providerInputs = await DynamicSecretElasticSearchSchema.parseAsync(inputs); - if ( - 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" }); - } + verifyHostInputValidity(providerInputs.host); return providerInputs; }; 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/mongo-db.ts b/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts index ea9430846..b824f5aa8 100644 --- a/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts +++ b/backend/src/ee/services/dynamic-secret/providers/mongo-db.ts @@ -2,10 +2,9 @@ import { MongoClient } from "mongodb"; 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 { verifyHostInputValidity } from "../dynamic-secret-fns"; import { DynamicSecretMongoDBSchema, TDynamicProviderFns } from "./models"; const generatePassword = (size = 48) => { @@ -19,22 +18,8 @@ const generateUsername = () => { export const MongoDBProvider = (): TDynamicProviderFns => { const validateProviderInputs = async (inputs: unknown) => { - const appCfg = getConfig(); const providerInputs = await DynamicSecretMongoDBSchema.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" }); - } - + verifyHostInputValidity(providerInputs.host); return providerInputs; }; diff --git a/backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts b/backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts index 15492cd2d..00d3b538f 100644 --- a/backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts +++ b/backend/src/ee/services/dynamic-secret/providers/rabbit-mq.ts @@ -3,12 +3,11 @@ import https from "https"; import { customAlphabet } from "nanoid"; import { z } from "zod"; -import { getConfig } from "@app/lib/config/env"; -import { BadRequestError } from "@app/lib/errors"; import { removeTrailingSlash } from "@app/lib/fn"; import { logger } from "@app/lib/logger"; import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { verifyHostInputValidity } from "../dynamic-secret-fns"; import { DynamicSecretRabbitMqSchema, TDynamicProviderFns } from "./models"; const generatePassword = () => { @@ -79,23 +78,8 @@ async function deleteRabbitMqUser({ axiosInstance, usernameToDelete }: TDeleteRa export const RabbitMqProvider = (): TDynamicProviderFns => { const validateProviderInputs = async (inputs: unknown) => { - const appCfg = getConfig(); - const isCloud = Boolean(appCfg.LICENSE_SERVER_KEY); // quick and dirty way to check if its cloud or not - const providerInputs = await DynamicSecretRabbitMqSchema.parseAsync(inputs); - if ( - 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" }); - } + verifyHostInputValidity(providerInputs.host); return providerInputs; }; diff --git a/backend/src/ee/services/dynamic-secret/providers/redis.ts b/backend/src/ee/services/dynamic-secret/providers/redis.ts index 1338e62e3..0e7ae99a0 100644 --- a/backend/src/ee/services/dynamic-secret/providers/redis.ts +++ b/backend/src/ee/services/dynamic-secret/providers/redis.ts @@ -3,11 +3,10 @@ import { Redis } from "ioredis"; 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 { verifyHostInputValidity } from "../dynamic-secret-fns"; import { DynamicSecretRedisDBSchema, TDynamicProviderFns } from "./models"; const generatePassword = () => { @@ -51,22 +50,8 @@ const executeTransactions = async (connection: Redis, commands: string[]): Promi export const RedisDatabaseProvider = (): TDynamicProviderFns => { const validateProviderInputs = async (inputs: unknown) => { - const appCfg = getConfig(); - const isCloud = Boolean(appCfg.LICENSE_SERVER_KEY); // quick and dirty way to check if its cloud or not - const dbHost = appCfg.DB_HOST || getDbConnectionHost(appCfg.DB_CONNECTION_URI); - const providerInputs = await DynamicSecretRedisDBSchema.parseAsync(inputs); - if ( - 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" || dbHost === providerInputs.host) - throw new BadRequestError({ message: "Invalid db host" }); + verifyHostInputValidity(providerInputs.host); return providerInputs; }; 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..d120cf4fe --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/providers/sap-hana.ts @@ -0,0 +1,174 @@ +/* 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 { BadRequestError } from "@app/lib/errors"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; + +import { verifyHostInputValidity } from "../dynamic-secret-fns"; +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 providerInputs = await DynamicSecretSapHanaSchema.parseAsync(inputs); + + verifyHostInputValidity(providerInputs.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, + ...(providerInputs.ca + ? { + ca: providerInputs.ca + } + : {}) + }); + + 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/backend/src/ee/services/dynamic-secret/providers/sql-database.ts b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts index 6745f573b..6acf23b06 100644 --- a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts +++ b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts @@ -3,11 +3,9 @@ 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 { verifyHostInputValidity } from "../dynamic-secret-fns"; import { DynamicSecretSqlDBSchema, SqlProviders, TDynamicProviderFns } from "./models"; const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000; @@ -29,27 +27,8 @@ const generateUsername = (provider: SqlProviders) => { export const SqlDatabaseProvider = (): TDynamicProviderFns => { const validateProviderInputs = async (inputs: unknown) => { - const appCfg = getConfig(); - const isCloud = Boolean(appCfg.LICENSE_SERVER_KEY); // quick and dirty way to check if its cloud or not - const dbHost = appCfg.DB_HOST || getDbConnectionHost(appCfg.DB_CONNECTION_URI); - const providerInputs = await DynamicSecretSqlDBSchema.parseAsync(inputs); - if ( - 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" || - // database infisical uses - dbHost === providerInputs.host - ) - throw new BadRequestError({ message: "Invalid db host" }); + verifyHostInputValidity(providerInputs.host); return providerInputs; }; diff --git a/docs/documentation/platform/dynamic-secrets/sap-hana.mdx b/docs/documentation/platform/dynamic-secrets/sap-hana.mdx new file mode 100644 index 000000000..3c2a837d3 --- /dev/null +++ b/docs/documentation/platform/dynamic-secrets/sap-hana.mdx @@ -0,0 +1,121 @@ +--- +title: "SAP HANA" +description: "Learn how to dynamically generate SAP HANA database account credentials." +--- + +The Infisical SAP HANA dynamic secret allows you to generate SAP HANA database credentials on demand. + +## Prerequisite + +- Infisical requires a SAP HANA database user in your instance with the necessary permissions. This user will facilitate the creation of new accounts as needed. + Ensure the user possesses privileges for creating, dropping, and granting permissions to roles for it to be able to create dynamic secrets. + +- The SAP HANA instance should be reachable by Infisical. + +## Set up Dynamic Secrets with SAP HANA + + + + Open the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret. + + + ![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button.png) + + + ![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/dynamic-secret-modal-sap-hana.png) + + + + Name by which you want the secret to be referenced + + + + Default time-to-live for a generated secret (it is possible to modify this value when a secret is generate) + + + + Maximum time-to-live for a generated secret + + + + SAP HANA Host + + + + + SAP HANA Port + + + + Username that will be used to create dynamic secrets + + + + Password that will be used to create dynamic secrets + + + + A CA may be required for SSL if you are self-hosting SAP HANA + + + + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/dynamic-secret-setup-modal-sap-hana.png) + + + + If you want to provide specific privileges for the generated dynamic credentials, you can modify the SQL statement to your needs. + ![Modify SQL Statements Modal](../../../images/platform/dynamic-secrets/modify-sap-hana-sql-statements.png) + + + Due to SAP HANA limitations, the attached SQL statements are not executed as a transaction. + + + + + After submitting the form, you will see a dynamic secret created in the dashboard. + + + If this step fails, you may have to add the CA certficate. + + + + + Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. + To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. + Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section. + + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate.png) + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-lease-empty.png) + + When generating these secrets, it's important to specify a Time-to-Live (TTL) duration. This will dictate how long the credentials are valid for. + + ![Provision Lease](/images/platform/dynamic-secrets/provision-lease.png) + + + Ensure that the TTL for the lease fall within the maximum TTL defined when configuring the dynamic secret in step 4. + + + + Once you click the `Submit` button, a new secret lease will be generated and the credentials for it will be shown to you. + + ![Provision Lease](/images/platform/dynamic-secrets/lease-values.png) + + + + +## Audit or Revoke Leases + +Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. +This will allow you see the lease details and delete the lease ahead of its expiration time. + +![Provision Lease](/images/platform/dynamic-secrets/lease-data.png) + +## Renew Leases + +To extend the life of the generated dynamic secret lease past its initial time to live, simply click on the **Renew** as illustrated below. +![Provision Lease](/images/platform/dynamic-secrets/dynamic-secret-lease-renew.png) + + + Lease renewals cannot exceed the maximum TTL set when configuring the dynamic + secret. + diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-modal-sap-hana.png b/docs/images/platform/dynamic-secrets/dynamic-secret-modal-sap-hana.png new file mode 100644 index 000000000..202431cc2 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-modal-sap-hana.png differ diff --git a/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-sap-hana.png b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-sap-hana.png new file mode 100644 index 000000000..1c97efe1e Binary files /dev/null and b/docs/images/platform/dynamic-secrets/dynamic-secret-setup-modal-sap-hana.png differ diff --git a/docs/images/platform/dynamic-secrets/modify-sap-hana-sql-statements.png b/docs/images/platform/dynamic-secrets/modify-sap-hana-sql-statements.png new file mode 100644 index 000000000..973fcf731 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/modify-sap-hana-sql-statements.png differ diff --git a/docs/mint.json b/docs/mint.json index 33e5f5c9f..4e3049f78 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -178,7 +178,8 @@ "documentation/platform/dynamic-secrets/mongo-atlas", "documentation/platform/dynamic-secrets/mongo-db", "documentation/platform/dynamic-secrets/azure-entra-id", - "documentation/platform/dynamic-secrets/ldap" + "documentation/platform/dynamic-secrets/ldap", + "documentation/platform/dynamic-secrets/sap-hana" ] }, { 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 + + ( + +