diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev index 97bc2c6a3..3eda2ad03 100644 --- a/backend/Dockerfile.dev +++ b/backend/Dockerfile.dev @@ -7,7 +7,7 @@ ARG SOFTHSM2_VERSION=2.5.0 ENV SOFTHSM2_VERSION=${SOFTHSM2_VERSION} \ SOFTHSM2_SOURCES=/tmp/softhsm2 -# install build dependencies including python3 +# install build dependencies including python3 (required for pkcs11js and partially TDS driver) RUN apk --update add \ alpine-sdk \ autoconf \ @@ -19,7 +19,19 @@ RUN apk --update add \ make \ g++ +# install dependencies for TDS driver (required for SAP ASE dynamic secrets) +RUN apk add --no-cache \ + unixodbc \ + freetds \ + unixodbc-dev \ + libc-dev \ + freetds-dev + + +RUN printf "[FreeTDS]\nDescription = FreeTDS Driver\nDriver = /usr/lib/libtdsodbc.so\nSetup = /usr/lib/libtdsodbc.so\nFileUsage = 1\n" > /etc/odbcinst.ini + # build and install SoftHSM2 + RUN git clone https://github.com/opendnssec/SoftHSMv2.git ${SOFTHSM2_SOURCES} WORKDIR ${SOFTHSM2_SOURCES} diff --git a/backend/package-lock.json b/backend/package-lock.json index ef7203d2c..5fba9833f 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -81,6 +81,7 @@ "mysql2": "^3.9.8", "nanoid": "^3.3.4", "nodemailer": "^6.9.9", + "odbc": "^2.4.9", "openid-client": "^5.6.5", "ora": "^7.0.1", "oracledb": "^6.4.0", @@ -17872,6 +17873,27 @@ "jsonwebtoken": "^9.0.2" } }, + "node_modules/odbc": { + "version": "2.4.9", + "resolved": "https://registry.npmjs.org/odbc/-/odbc-2.4.9.tgz", + "integrity": "sha512-sHFWOKfyj4oFYds7YBlN+fq9ZjC2J6CsCN5CNMABpKLp+NZdb8bnanb57OaoDy1VFXEOTE91S+F900J/aIPu6w==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.5", + "async": "^3.0.1", + "node-addon-api": "^3.0.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/odbc/node_modules/node-addon-api": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-3.2.1.tgz", + "integrity": "sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A==", + "license": "MIT" + }, "node_modules/oidc-token-hash": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz", diff --git a/backend/package.json b/backend/package.json index 7db48d56d..24d4e0775 100644 --- a/backend/package.json +++ b/backend/package.json @@ -189,6 +189,7 @@ "mysql2": "^3.9.8", "nanoid": "^3.3.4", "nodemailer": "^6.9.9", + "odbc": "^2.4.9", "openid-client": "^5.6.5", "ora": "^7.0.1", "oracledb": "^6.4.0", diff --git a/backend/src/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts index d803aab5b..af8148fdd 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -4,7 +4,8 @@ export enum SqlProviders { Postgres = "postgres", MySQL = "mysql2", Oracle = "oracledb", - MsSQL = "mssql" + MsSQL = "mssql", + SapAse = "sap-ase" } export enum ElasticSearchAuthTypes { @@ -118,6 +119,17 @@ export const DynamicSecretCassandraSchema = z.object({ ca: z.string().optional() }); +export const DynamicSecretSapAseSchema = z.object({ + host: z.string().trim().toLowerCase(), + port: z.number(), + database: z.string().trim(), + username: z.string().trim(), + password: z.string().trim(), + creationStatement: z.string().trim(), + revocationStatement: z.string().trim(), + ca: z.string().optional() +}); + export const DynamicSecretAwsIamSchema = z.object({ accessKey: z.string().trim().min(1), secretAccessKey: z.string().trim().min(1), @@ -274,12 +286,14 @@ export enum DynamicSecretProviders { Ldap = "ldap", SapHana = "sap-hana", Snowflake = "snowflake", - Totp = "totp" + Totp = "totp", + SapAse = "sap-ase" } export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal(DynamicSecretProviders.SqlDatabase), inputs: DynamicSecretSqlDBSchema }), z.object({ type: z.literal(DynamicSecretProviders.Cassandra), inputs: DynamicSecretCassandraSchema }), + z.object({ type: z.literal(DynamicSecretProviders.SapAse), inputs: DynamicSecretSapAseSchema }), 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 }), diff --git a/backend/src/ee/services/dynamic-secret/providers/sap-ase.ts b/backend/src/ee/services/dynamic-secret/providers/sap-ase.ts new file mode 100644 index 000000000..f349d36bd --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/providers/sap-ase.ts @@ -0,0 +1,145 @@ +import handlebars from "handlebars"; +import { customAlphabet } from "nanoid"; +import odbc from "odbc"; +import { z } from "zod"; + +import { BadRequestError } from "@app/lib/errors"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; + +import { verifyHostInputValidity } from "../dynamic-secret-fns"; +import { DynamicSecretSapAseSchema, TDynamicProviderFns } from "./models"; + +const generatePassword = (size = 48) => { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + return customAlphabet(charset, 48)(size); +}; + +const generateUsername = () => { + return alphaNumericNanoId(25); +}; + +enum SapCommands { + CreateLogin = "sp_addlogin", + DropLogin = "sp_droplogin" +} + +export const SapAseProvider = (): TDynamicProviderFns => { + const validateProviderInputs = async (inputs: unknown) => { + const providerInputs = await DynamicSecretSapAseSchema.parseAsync(inputs); + + verifyHostInputValidity(providerInputs.host); + return providerInputs; + }; + + const $getClient = async (providerInputs: z.infer, useMaster?: boolean) => { + const connectionString = + `DRIVER={FreeTDS};` + + `SERVER=${providerInputs.host};` + + `PORT=${providerInputs.port};` + + `DATABASE=${useMaster ? "master" : providerInputs.database};` + + `UID=${providerInputs.username};` + + `PWD=${providerInputs.password};` + + `TDS_VERSION=5.0`; + + const client = await odbc.connect(connectionString); + + return client; + }; + + const validateConnection = async (inputs: unknown) => { + const providerInputs = await validateProviderInputs(inputs); + const masterClient = await $getClient(providerInputs, true); + const client = await $getClient(providerInputs); + + const [resultFromMasterDatabase] = await masterClient.query<{ version: string }>("SELECT @@VERSION AS version"); + const [resultFromSelectedDatabase] = await client.query<{ version: string }>("SELECT @@VERSION AS version"); + + if (!resultFromSelectedDatabase.version) { + throw new BadRequestError({ + message: "Failed to validate SAP ASE connection, version query failed" + }); + } + + if (resultFromMasterDatabase.version !== resultFromSelectedDatabase.version) { + throw new BadRequestError({ + message: "Failed to validate SAP ASE connection (master), version mismatch" + }); + } + + return true; + }; + + const create = async (inputs: unknown) => { + const providerInputs = await validateProviderInputs(inputs); + + const username = `inf_${generateUsername()}`; + const password = `${generatePassword()}`; + + const client = await $getClient(providerInputs); + const masterClient = await $getClient(providerInputs, true); + + const creationStatement = handlebars.compile(providerInputs.creationStatement, { noEscape: true })({ + username, + password + }); + + const queries = creationStatement.trim().replace(/\n/g, "").split(";").filter(Boolean); + + for await (const query of queries) { + // If it's an adduser query, we need to first call sp_addlogin on the MASTER database. + // If not done, then the newly created user won't be able to authenticate. + await (query.startsWith(SapCommands.CreateLogin) ? masterClient : client).query(query); + } + + await masterClient.close(); + await client.close(); + + return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: password } }; + }; + + const revoke = async (inputs: unknown, username: string) => { + const providerInputs = await validateProviderInputs(inputs); + + const revokeStatement = handlebars.compile(providerInputs.revocationStatement, { noEscape: true })({ + username + }); + + const queries = revokeStatement.trim().replace(/\n/g, "").split(";").filter(Boolean); + + const client = await $getClient(providerInputs); + const masterClient = await $getClient(providerInputs, true); + + // Get all processes for this login and kill them. If there are active connections to the database when drop login happens, it will throw an error. + const result = await masterClient.query<{ spid?: string }>(`sp_who '${username}'`); + + if (result && result.length > 0) { + for await (const row of result) { + if (row.spid) { + await masterClient.query(`KILL ${row.spid.trim()}`); + } + } + } + + for await (const query of queries) { + await (query.startsWith(SapCommands.DropLogin) ? masterClient : client).query(query); + } + + await masterClient.close(); + await client.close(); + + return { entityId: username }; + }; + + const renew = async (_: unknown, username: string) => { + // No need for renewal + 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 35b08df32..60dda08e4 100644 --- a/frontend/src/hooks/api/dynamicSecret/types.ts +++ b/frontend/src/hooks/api/dynamicSecret/types.ts @@ -29,7 +29,8 @@ export enum DynamicSecretProviders { Ldap = "ldap", SapHana = "sap-hana", Snowflake = "snowflake", - Totp = "totp" + Totp = "totp", + SapAse = "sap-ase" } export enum SqlProviders { @@ -212,6 +213,7 @@ export type TDynamicSecretProvider = inputs: { host: string; port: number; + database: string; username: string; password: string; creationStatement: string; @@ -220,6 +222,18 @@ export type TDynamicSecretProvider = ca?: string | undefined; }; } + | { + type: DynamicSecretProviders.SapAse; + inputs: { + host: string; + port: number; + username: string; + password: string; + creationStatement: string; + revocationStatement: string; + ca?: string | undefined; + }; + } | { type: DynamicSecretProviders.Snowflake; inputs: { diff --git a/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx b/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx index 44a435958..66855e815 100644 --- a/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx +++ b/frontend/src/views/SecretMainPage/components/ActionBar/CreateDynamicSecretForm/CreateDynamicSecretForm.tsx @@ -29,6 +29,7 @@ import { MongoAtlasInputForm } from "./MongoAtlasInputForm"; import { MongoDBDatabaseInputForm } from "./MongoDBInputForm"; import { RabbitMqInputForm } from "./RabbitMqInputForm"; import { RedisInputForm } from "./RedisInputForm"; +import { SapAseInputForm } from "./SapAseInputForm"; import { SapHanaInputForm } from "./SapHanaInputForm"; import { SqlDatabaseInputForm } from "./SqlDatabaseInputForm"; import { TotpInputForm } from "./TotpInputForm"; @@ -107,6 +108,11 @@ const DYNAMIC_SECRET_LIST = [ provider: DynamicSecretProviders.SapHana, title: "SAP HANA" }, + { + icon: , + provider: DynamicSecretProviders.SapAse, + title: "SAP ASE" + }, { icon: , provider: DynamicSecretProviders.Snowflake, @@ -393,6 +399,25 @@ export const CreateDynamicSecretForm = ({ /> )} + + {wizardStep === WizardSteps.ProviderInputs && + selectedProvider === DynamicSecretProviders.SapAse && ( + + + + )} + {wizardStep === WizardSteps.ProviderInputs && selectedProvider === DynamicSecretProviders.Snowflake && ( { + 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 SapAseInputForm = ({ + onCompleted, + onCancel, + environment, + secretPath, + projectSlug +}: Props) => { + const { + control, + formState: { isSubmitting }, + handleSubmit + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + provider: { + database: "master", + port: 5000, + creationStatement: `sp_addlogin '{{username}}', '{{password}}'; +sp_adduser '{{username}}', '{{username}}', null; +sp_role 'grant', 'mon_role', '{{username}}';`, + revocationStatement: `sp_dropuser '{{username}}'; +sp_droplogin '{{username}}';` + } + } + }); + + 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.SapAse, 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 + + ( + +