diff --git a/backend/package-lock.json b/backend/package-lock.json index adda83043..a887e2f7b 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -94,7 +94,7 @@ "lodash.isequal": "^4.5.0", "mongodb": "^6.8.1", "ms": "^2.1.3", - "mysql2": "^3.9.8", + "mysql2": "^3.15.2", "nanoid": "^3.3.8", "nodemailer": "^6.9.9", "oci-sdk": "^2.108.0", @@ -17225,6 +17225,15 @@ "node": ">=4.0" } }, + "node_modules/aws-ssl-profiles": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", + "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/axios": { "version": "1.12.2", "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", @@ -24063,6 +24072,21 @@ "lru-cache": "6.0.0" } }, + "node_modules/lru.min": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.2.tgz", + "integrity": "sha512-Nv9KddBcQSlQopmBHXSsZVY5xsdlZkdH/Iey0BlcBYggMd4two7cZnKOK9vmy3nY0O5RGH99z1PCeTpPqszUYg==", + "license": "MIT", + "engines": { + "bun": ">=1.0.0", + "deno": ">=1.30.0", + "node": ">=8.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wellwelwel" + } + }, "node_modules/luxon": { "version": "3.4.4", "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz", @@ -24765,16 +24789,17 @@ } }, "node_modules/mysql2": { - "version": "3.9.8", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.9.8.tgz", - "integrity": "sha512-+5JKNjPuks1FNMoy9TYpl77f+5frbTklz7eb3XDwbpsERRLEeXiW2PDEkakYF50UuKU2qwfGnyXpKYvukv8mGA==", + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.2.tgz", + "integrity": "sha512-kFm5+jbwR5mC+lo+3Cy46eHiykWSpUtTLOH3GE+AR7GeLq8PgfJcvpMiyVWk9/O53DjQsqm6a3VOOfq7gYWFRg==", "license": "MIT", "dependencies": { + "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", "generate-function": "^2.3.1", - "iconv-lite": "^0.6.3", + "iconv-lite": "^0.7.0", "long": "^5.2.1", - "lru-cache": "^8.0.0", + "lru.min": "^1.0.0", "named-placeholders": "^1.1.3", "seq-queue": "^0.0.5", "sqlstring": "^2.3.2" @@ -24783,12 +24808,20 @@ "node": ">= 8.0" } }, - "node_modules/mysql2/node_modules/lru-cache": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-8.0.5.tgz", - "integrity": "sha512-MhWWlVnuab1RG5/zMRRcVGXZLCXrZTgfwMikgzCegsPnG62yDQo5JnqKkrK4jO5iKqDAZGItAqN5CtKBCBWRUA==", + "node_modules/mysql2/node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, "engines": { - "node": ">=16.14" + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/mz": { diff --git a/backend/package.json b/backend/package.json index 17be524f2..d3b5ad1d8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -221,7 +221,7 @@ "lodash.isequal": "^4.5.0", "mongodb": "^6.8.1", "ms": "^2.1.3", - "mysql2": "^3.9.8", + "mysql2": "^3.15.2", "nanoid": "^3.3.8", "nodemailer": "^6.9.9", "oci-sdk": "^2.108.0", diff --git a/backend/src/ee/routes/v1/pam-account-routers/index.ts b/backend/src/ee/routes/v1/pam-account-routers/index.ts index 568412c84..60d621467 100644 --- a/backend/src/ee/routes/v1/pam-account-routers/index.ts +++ b/backend/src/ee/routes/v1/pam-account-routers/index.ts @@ -1,3 +1,8 @@ +import { + CreateMySQLAccountSchema, + SanitizedMySQLAccountWithResourceSchema, + UpdateMySQLAccountSchema +} from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas"; import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums"; import { CreatePostgresAccountSchema, @@ -16,5 +21,14 @@ export const PAM_ACCOUNT_REGISTER_ROUTER_MAP: Record { + registerPamResourceEndpoints({ + server, + resourceType: PamResource.MySQL, + accountResponseSchema: SanitizedMySQLAccountWithResourceSchema, + createAccountSchema: CreateMySQLAccountSchema, + updateAccountSchema: UpdateMySQLAccountSchema + }); } }; diff --git a/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts b/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts index 647f39d8d..d2e0183ff 100644 --- a/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts +++ b/backend/src/ee/routes/v1/pam-account-routers/pam-account-router.ts @@ -2,6 +2,7 @@ import { z } from "zod"; import { PamFoldersSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { SanitizedMySQLAccountWithResourceSchema } from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas"; import { PamResource } from "@app/ee/services/pam-resource/pam-resource-enums"; import { SanitizedPostgresAccountWithResourceSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas"; import { BadRequestError } from "@app/lib/errors"; @@ -10,8 +11,10 @@ import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; -// Use z.union([...]) when more resources are added -const SanitizedAccountSchema = SanitizedPostgresAccountWithResourceSchema; +const SanitizedAccountSchema = z.union([ + SanitizedPostgresAccountWithResourceSchema, + SanitizedMySQLAccountWithResourceSchema +]); export const registerPamAccountRouter = async (server: FastifyZodProvider) => { server.route({ diff --git a/backend/src/ee/routes/v1/pam-resource-routers/index.ts b/backend/src/ee/routes/v1/pam-resource-routers/index.ts index 6b53781ae..c6c0afcca 100644 --- a/backend/src/ee/routes/v1/pam-resource-routers/index.ts +++ b/backend/src/ee/routes/v1/pam-resource-routers/index.ts @@ -4,6 +4,11 @@ import { SanitizedPostgresResourceSchema, UpdatePostgresResourceSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas"; +import { + CreateMySQLResourceSchema, + MySQLResourceSchema, + UpdateMySQLResourceSchema +} from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas"; import { registerPamResourceEndpoints } from "./pam-resource-endpoints"; @@ -16,5 +21,14 @@ export const PAM_RESOURCE_REGISTER_ROUTER_MAP: Record { + registerPamResourceEndpoints({ + server, + resourceType: PamResource.MySQL, + resourceResponseSchema: MySQLResourceSchema, + createResourceSchema: CreateMySQLResourceSchema, + updateResourceSchema: UpdateMySQLResourceSchema + }); } }; diff --git a/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-router.ts b/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-router.ts index d42a73021..6563c86c7 100644 --- a/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-router.ts +++ b/backend/src/ee/routes/v1/pam-resource-routers/pam-resource-router.ts @@ -1,6 +1,10 @@ import { z } from "zod"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { + MySQLResourceListItemSchema, + SanitizedMySQLResourceSchema +} from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas"; import { PostgresResourceListItemSchema, SanitizedPostgresResourceSchema @@ -9,10 +13,12 @@ import { readLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; -// Use z.union([...]) when more resources are added -const SanitizedResourceSchema = SanitizedPostgresResourceSchema; +const SanitizedResourceSchema = z.union([SanitizedPostgresResourceSchema, SanitizedMySQLResourceSchema]); -const ResourceOptionsSchema = z.discriminatedUnion("resource", [PostgresResourceListItemSchema]); +const ResourceOptionsSchema = z.discriminatedUnion("resource", [ + PostgresResourceListItemSchema, + MySQLResourceListItemSchema +]); export const registerPamResourceRouter = async (server: FastifyZodProvider) => { server.route({ diff --git a/backend/src/ee/routes/v1/pam-session-router.ts b/backend/src/ee/routes/v1/pam-session-router.ts index c353fddfa..5fe10e434 100644 --- a/backend/src/ee/routes/v1/pam-session-router.ts +++ b/backend/src/ee/routes/v1/pam-session-router.ts @@ -2,14 +2,14 @@ import { z } from "zod"; import { PamSessionsSchema } from "@app/db/schemas"; import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { MySQLSessionCredentialsSchema } from "@app/ee/services/pam-resource/mysql/mysql-resource-schemas"; import { PostgresSessionCredentialsSchema } from "@app/ee/services/pam-resource/postgres/postgres-resource-schemas"; import { PamSessionCommandLogSchema, SanitizedSessionSchema } from "@app/ee/services/pam-session/pam-session-schemas"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; -// Use z.union([]) once there's multiple -const SessionCredentialsSchema = PostgresSessionCredentialsSchema; +const SessionCredentialsSchema = z.union([PostgresSessionCredentialsSchema, MySQLSessionCredentialsSchema]); export const registerPamSessionRouter = async (server: FastifyZodProvider) => { // Meant to be hit solely by gateway identities diff --git a/backend/src/ee/services/pam-resource/mysql/mysql-resource-fns.ts b/backend/src/ee/services/pam-resource/mysql/mysql-resource-fns.ts new file mode 100644 index 000000000..4010d208b --- /dev/null +++ b/backend/src/ee/services/pam-resource/mysql/mysql-resource-fns.ts @@ -0,0 +1,8 @@ +import { MySQLResourceListItemSchema } from "./mysql-resource-schemas"; + +export const getMySQLResourceListItem = () => { + return { + name: MySQLResourceListItemSchema.shape.name.value, + resource: MySQLResourceListItemSchema.shape.resource.value + }; +}; diff --git a/backend/src/ee/services/pam-resource/mysql/mysql-resource-schemas.ts b/backend/src/ee/services/pam-resource/mysql/mysql-resource-schemas.ts new file mode 100644 index 000000000..8d3589a8a --- /dev/null +++ b/backend/src/ee/services/pam-resource/mysql/mysql-resource-schemas.ts @@ -0,0 +1,76 @@ +import { z } from "zod"; + +import { PamResource } from "../pam-resource-enums"; +import { + BaseCreatePamAccountSchema, + BaseCreatePamResourceSchema, + BasePamAccountSchema, + BasePamAccountSchemaWithResource, + BasePamResourceSchema, + BaseUpdatePamAccountSchema, + BaseUpdatePamResourceSchema +} from "../pam-resource-schemas"; +import { + BaseSqlAccountCredentialsSchema, + BaseSqlResourceConnectionDetailsSchema +} from "../shared/sql/sql-resource-schemas"; + +// Resources +export const MySQLResourceConnectionDetailsSchema = BaseSqlResourceConnectionDetailsSchema.extend({ + // MySQL db in many cases the db will not be provided when making connection + database: z.string().trim() +}); +export const MySQLAccountCredentialsSchema = BaseSqlAccountCredentialsSchema; + +const BaseMySQLResourceSchema = BasePamResourceSchema.extend({ resourceType: z.literal(PamResource.MySQL) }); + +export const MySQLResourceSchema = BaseMySQLResourceSchema.extend({ + connectionDetails: MySQLResourceConnectionDetailsSchema, + rotationAccountCredentials: MySQLAccountCredentialsSchema.nullable().optional() +}); + +export const SanitizedMySQLResourceSchema = BaseMySQLResourceSchema.extend({ + connectionDetails: MySQLResourceConnectionDetailsSchema, + rotationAccountCredentials: MySQLAccountCredentialsSchema.pick({ + username: true + }) + .nullable() + .optional() +}); + +export const MySQLResourceListItemSchema = z.object({ + name: z.literal("MySQL"), + resource: z.literal(PamResource.MySQL) +}); + +export const CreateMySQLResourceSchema = BaseCreatePamResourceSchema.extend({ + connectionDetails: MySQLResourceConnectionDetailsSchema, + rotationAccountCredentials: MySQLAccountCredentialsSchema.nullable().optional() +}); + +export const UpdateMySQLResourceSchema = BaseUpdatePamResourceSchema.extend({ + connectionDetails: MySQLResourceConnectionDetailsSchema.optional(), + rotationAccountCredentials: MySQLAccountCredentialsSchema.nullable().optional() +}); + +// Accounts +export const MySQLAccountSchema = BasePamAccountSchema.extend({ + credentials: MySQLAccountCredentialsSchema +}); + +export const CreateMySQLAccountSchema = BaseCreatePamAccountSchema.extend({ + credentials: MySQLAccountCredentialsSchema +}); + +export const UpdateMySQLAccountSchema = BaseUpdatePamAccountSchema.extend({ + credentials: MySQLAccountCredentialsSchema.optional() +}); + +export const SanitizedMySQLAccountWithResourceSchema = BasePamAccountSchemaWithResource.extend({ + credentials: MySQLAccountCredentialsSchema.pick({ + username: true + }) +}); + +// Sessions +export const MySQLSessionCredentialsSchema = MySQLResourceConnectionDetailsSchema.and(MySQLAccountCredentialsSchema); diff --git a/backend/src/ee/services/pam-resource/mysql/mysql-resource-types.ts b/backend/src/ee/services/pam-resource/mysql/mysql-resource-types.ts new file mode 100644 index 000000000..43c2eea51 --- /dev/null +++ b/backend/src/ee/services/pam-resource/mysql/mysql-resource-types.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +import { + MySQLAccountCredentialsSchema, + MySQLAccountSchema, + MySQLResourceConnectionDetailsSchema, + MySQLResourceSchema +} from "./mysql-resource-schemas"; + +// Resources +export type TMySQLResource = z.infer; +export type TMySQLResourceConnectionDetails = z.infer; + +// Accounts +export type TMySQLAccount = z.infer; +export type TMySQLAccountCredentials = z.infer; diff --git a/backend/src/ee/services/pam-resource/pam-resource-enums.ts b/backend/src/ee/services/pam-resource/pam-resource-enums.ts index fbc260fba..dff1cc650 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-enums.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-enums.ts @@ -1,3 +1,4 @@ export enum PamResource { - Postgres = "postgres" + Postgres = "postgres", + MySQL = "mysql" } diff --git a/backend/src/ee/services/pam-resource/pam-resource-factory.ts b/backend/src/ee/services/pam-resource/pam-resource-factory.ts index 298b1664c..151fa7ea1 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-factory.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-factory.ts @@ -5,5 +5,6 @@ import { sqlResourceFactory } from "./shared/sql/sql-resource-factory"; type TPamResourceFactoryImplementation = TPamResourceFactory; export const PAM_RESOURCE_FACTORY_MAP: Record = { - [PamResource.Postgres]: sqlResourceFactory as TPamResourceFactoryImplementation + [PamResource.Postgres]: sqlResourceFactory as TPamResourceFactoryImplementation, + [PamResource.MySQL]: sqlResourceFactory as TPamResourceFactoryImplementation }; diff --git a/backend/src/ee/services/pam-resource/pam-resource-fns.ts b/backend/src/ee/services/pam-resource/pam-resource-fns.ts index 9d7493e68..cad087d2f 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-fns.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-fns.ts @@ -3,11 +3,12 @@ import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; import { decryptAccountCredentials } from "../pam-account/pam-account-fns"; +import { getMySQLResourceListItem } from "./mysql/mysql-resource-fns"; import { TPamResource, TPamResourceConnectionDetails } from "./pam-resource-types"; import { getPostgresResourceListItem } from "./postgres/postgres-resource-fns"; export const listResourceOptions = () => { - return [getPostgresResourceListItem()].sort((a, b) => a.name.localeCompare(b.name)); + return [getPostgresResourceListItem(), getMySQLResourceListItem()].sort((a, b) => a.name.localeCompare(b.name)); }; // Resource diff --git a/backend/src/ee/services/pam-resource/pam-resource-types.ts b/backend/src/ee/services/pam-resource/pam-resource-types.ts index f2016420a..1ca9db3e2 100644 --- a/backend/src/ee/services/pam-resource/pam-resource-types.ts +++ b/backend/src/ee/services/pam-resource/pam-resource-types.ts @@ -1,4 +1,10 @@ import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service"; +import { + TMySQLAccount, + TMySQLAccountCredentials, + TMySQLResource, + TMySQLResourceConnectionDetails +} from "./mysql/mysql-resource-types"; import { PamResource } from "./pam-resource-enums"; import { TPostgresAccount, @@ -8,12 +14,13 @@ import { } from "./postgres/postgres-resource-types"; // Resource types -export type TPamResource = TPostgresResource; -export type TPamResourceConnectionDetails = TPostgresResourceConnectionDetails; +export type TPamResource = TPostgresResource | TMySQLResource; +export type TPamResourceConnectionDetails = TPostgresResourceConnectionDetails | TMySQLResourceConnectionDetails; // Account types -export type TPamAccount = TPostgresAccount; -export type TPamAccountCredentials = TPostgresAccountCredentials; +export type TPamAccount = TPostgresAccount | TMySQLAccount; +// eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents +export type TPamAccountCredentials = TPostgresAccountCredentials | TMySQLAccountCredentials; // Resource DTOs export type TCreateResourceDTO = Pick< diff --git a/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts b/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts index 73defd6e6..6cb65854a 100644 --- a/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts +++ b/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts @@ -1,4 +1,5 @@ -import knex, { Knex } from "knex"; +import knex from "knex"; +import mysql, { Connection } from "mysql2/promise"; import tls, { PeerCertificate } from "tls"; import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic-secret-fns"; @@ -20,30 +21,134 @@ const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000; const TEST_CONNECTION_USERNAME = "infisical-gateway-connection-test"; const TEST_CONNECTION_PASSWORD = "infisical-gateway-connection-test-password"; +const SIMPLE_QUERY = "select 1"; -const SQL_CONNECTION_CLIENT_MAP = { - [PamResource.Postgres]: "pg" -}; +export interface SqlResourceConnection { + /** + * Check and see if the connection is good or not. + * + * @param connectOnly when true, if we only want to know that making the connection is possible or not, + * we don't care about authentication failures + * @returns Promise to be resolved when the connection is good, otherwise an error will be errbacked + */ + validate: (connectOnly: boolean) => Promise; -const getConnectionConfig = ( - resourceType: PamResource, - { host, sslEnabled, sslRejectUnauthorized, sslCertificate }: TSqlResourceConnectionDetails -) => { - switch (resourceType) { + /** + * Close the connection. + * + * @returns Promise for closing the connection + */ + close: () => Promise; +} + +const makeSqlConnection = ( + proxyPort: number, + config: { + connectionDetails: TSqlResourceConnectionDetails; + resourceType: PamResource; + username?: string; + password?: string; + } +): SqlResourceConnection => { + const { connectionDetails, resourceType, username, password } = config; + const { host, sslEnabled, sslRejectUnauthorized, sslCertificate } = connectionDetails; + const actualUsername = username ?? TEST_CONNECTION_USERNAME; // Use provided username or fallback + const actualPassword = password ?? TEST_CONNECTION_PASSWORD; // Use provided password or fallback + switch (config.resourceType) { case PamResource.Postgres: { + const client = knex({ + client: "pg", + connection: { + host: "localhost", + port: proxyPort, + user: actualUsername, + password: actualPassword, + database: connectionDetails.database, + connectionTimeoutMillis: EXTERNAL_REQUEST_TIMEOUT, + ssl: sslEnabled + ? { + rejectUnauthorized: sslRejectUnauthorized, + ca: sslCertificate, + servername: host, + // When using proxy, we need to bypass hostname validation since we connect to localhost + // but validate the certificate against the actual hostname + checkServerIdentity: (hostname: string, cert: PeerCertificate) => { + return tls.checkServerIdentity(host, cert); + } + } + : false + } + }); return { - ssl: sslEnabled - ? { - rejectUnauthorized: sslRejectUnauthorized, - ca: sslCertificate, - servername: host, - // When using proxy, we need to bypass hostname validation since we connect to localhost - // but validate the certificate against the actual hostname - checkServerIdentity: (hostname: string, cert: PeerCertificate) => { - return tls.checkServerIdentity(host, cert); + validate: async (connectOnly) => { + try { + await client.raw(SIMPLE_QUERY); + } catch (error) { + if (error instanceof BadRequestError) { + // Hacky way to know if we successfully hit the database. + // TODO: potentially two approaches to solve the problem. + // 1. change the work flow, add account first then resource + // 2. modify relay to add a new endpoint for returning if the target host is healthy or not + // (like being able to do an auth handshake regardless pass or not) + if ( + connectOnly && + (error.message === `password authentication failed for user "${TEST_CONNECTION_USERNAME}"` || + error.message.includes("no pg_hba.conf entry for host")) + ) { + return; } } - : false + throw new BadRequestError({ + message: `Unable to validate connection to ${resourceType}: ${(error as Error).message || String(error)}` + }); + } + }, + close: () => client.destroy() + }; + } + case PamResource.MySQL: { + return { + validate: async (connectOnly) => { + let client: Connection | null = null; + try { + // Notice: the reason we are not using Knex for mysql2 is because we don't need any fancy feature from Knex. + // mysql2 doesn't provide custom ssl verification function pass in. + // ref: https://github.com/sidorares/node-mysql2/blob/2543272a2ada8d8a07f74582549d7dd3fe948e2d/lib/base/connection.js#L358-L362 + // and then even I tried to workaround it with Knex's pool afterCreate hook, but then encounter a bug: + // ref: https://github.com/knex/knex/issues/5352 + // It appears that using Knex causing more troubles than not, we are just checking the connections, + // so it's much easier to create raw connection with the driver lib directly + client = await mysql.createConnection({ + host: "localhost", + port: proxyPort, + user: actualUsername, // Use provided username or fallback + password: actualPassword, // Use provided password or fallback + database: connectionDetails.database, + ssl: sslEnabled + ? { + rejectUnauthorized: sslRejectUnauthorized, + ca: sslCertificate + } + : undefined + }); + await client.query(SIMPLE_QUERY); + } catch (error) { + if (connectOnly) { + // Hacky way to know if we successfully hit the database. + if ( + error instanceof Error && + error.message.startsWith(`Access denied for user '${TEST_CONNECTION_USERNAME}'@`) + ) { + return; + } + } + // TODO: handle other errors, and throw standardlized errors providing user-friendly msg + throw error; + } finally { + await client?.end(); + } + }, + close: async () => {} }; } default: @@ -62,10 +167,9 @@ export const executeWithGateway = async ( password?: string; }, gatewayV2Service: Pick, - operation: (client: Knex) => Promise + operation: (connection: SqlResourceConnection) => Promise ): Promise => { - const { connectionDetails, resourceType, gatewayId, username, password } = config; - + const { connectionDetails, gatewayId } = config; const [targetHost] = await verifyHostInputValidity(connectionDetails.host, true); const platformConnectionDetails = await gatewayV2Service.getPlatformConnectionDetailsByGatewayId({ gatewayId, @@ -79,22 +183,11 @@ export const executeWithGateway = async ( return withGatewayV2Proxy( async (proxyPort) => { - const client = knex({ - client: SQL_CONNECTION_CLIENT_MAP[resourceType], - connection: { - database: connectionDetails.database, - port: proxyPort, - host: "localhost", - user: username ?? TEST_CONNECTION_USERNAME, // Use provided username or fallback - password: password ?? TEST_CONNECTION_PASSWORD, // Use provided password or fallback - connectionTimeoutMillis: EXTERNAL_REQUEST_TIMEOUT, - ...getConnectionConfig(resourceType, connectionDetails) - } - }); + const connection = makeSqlConnection(proxyPort, config); try { - return await operation(client); + return await operation(connection); } finally { - await client.destroy(); + await connection.close(); } }, { @@ -115,25 +208,14 @@ export const sqlResourceFactory: TPamResourceFactory { try { await executeWithGateway({ connectionDetails, gatewayId, resourceType }, gatewayV2Service, async (client) => { - await client.raw("Select 1"); + await client.validate(true); }); return connectionDetails; } catch (error) { - // Hacky way to know if we successfully hit the database - if (error instanceof BadRequestError) { - if (error.message === `password authentication failed for user "${TEST_CONNECTION_USERNAME}"`) { - return connectionDetails; - } - - if (error.message.includes("no pg_hba.conf entry for host")) { - return connectionDetails; - } - - if (error.message === "Connection terminated unexpectedly") { - throw new BadRequestError({ - message: "Connection terminated unexpectedly. Verify that host and port are correct" - }); - } + if (error instanceof BadRequestError && error.message === "Connection terminated unexpectedly") { + throw new BadRequestError({ + message: "Connection terminated unexpectedly. Verify that host and port are correct" + }); } throw new BadRequestError({ @@ -156,11 +238,12 @@ export const sqlResourceFactory: TPamResourceFactory { - await client.raw("Select 1"); + await client.validate(false); } ); return credentials; } catch (error) { + // TODO: extract these logic into each SQL connection if (error instanceof BadRequestError) { if (error.message === `password authentication failed for user "${credentials.username}"`) { throw new BadRequestError({ diff --git a/backend/src/ee/services/pam-resource/shared/sql/sql-resource-types.ts b/backend/src/ee/services/pam-resource/shared/sql/sql-resource-types.ts index f56a2a3dc..a9ff49013 100644 --- a/backend/src/ee/services/pam-resource/shared/sql/sql-resource-types.ts +++ b/backend/src/ee/services/pam-resource/shared/sql/sql-resource-types.ts @@ -1,7 +1,9 @@ +import { TMySQLAccountCredentials, TMySQLResourceConnectionDetails } from "../../mysql/mysql-resource-types"; import { TPostgresAccountCredentials, TPostgresResourceConnectionDetails } from "../../postgres/postgres-resource-types"; -export type TSqlResourceConnectionDetails = TPostgresResourceConnectionDetails; -export type TSqlAccountCredentials = TPostgresAccountCredentials; +export type TSqlResourceConnectionDetails = TPostgresResourceConnectionDetails | TMySQLResourceConnectionDetails; +// eslint-disable-next-line @typescript-eslint/no-duplicate-type-constituents +export type TSqlAccountCredentials = TPostgresAccountCredentials | TMySQLAccountCredentials; diff --git a/frontend/src/hooks/api/pam/enums.ts b/frontend/src/hooks/api/pam/enums.ts index b6e5ce64c..0684f6073 100644 --- a/frontend/src/hooks/api/pam/enums.ts +++ b/frontend/src/hooks/api/pam/enums.ts @@ -1,5 +1,6 @@ export enum PamResourceType { Postgres = "postgres", + MySQL = "mysql", RDP = "rdp", SSH = "ssh", Kubernetes = "kubernetes" diff --git a/frontend/src/hooks/api/pam/maps.ts b/frontend/src/hooks/api/pam/maps.ts index a27507d7d..c240a12ad 100644 --- a/frontend/src/hooks/api/pam/maps.ts +++ b/frontend/src/hooks/api/pam/maps.ts @@ -5,6 +5,7 @@ export const PAM_RESOURCE_TYPE_MAP: Record< { name: string; image: string; size?: number } > = { [PamResourceType.Postgres]: { name: "PostgreSQL", image: "Postgres.png" }, + [PamResourceType.MySQL]: { name: "MySQL", image: "MySql.png" }, [PamResourceType.RDP]: { name: "RDP", image: "RDP.png" }, [PamResourceType.SSH]: { name: "SSH", image: "SSH.png" }, [PamResourceType.Kubernetes]: { name: "Kubernetes", image: "Kubernetes.png" } diff --git a/frontend/src/hooks/api/pam/types/index.ts b/frontend/src/hooks/api/pam/types/index.ts index 1b4acf6d4..23cdf389d 100644 --- a/frontend/src/hooks/api/pam/types/index.ts +++ b/frontend/src/hooks/api/pam/types/index.ts @@ -1,11 +1,13 @@ import { PamResourceType, PamSessionStatus } from "../enums"; import { TPostgresAccount, TPostgresResource } from "./postgres-resource"; +import { TMySQLAccount, TMySQLResource } from "./mysql-resource"; export * from "./postgres-resource"; +export * from "./mysql-resource"; -export type TPamResource = TPostgresResource; +export type TPamResource = TPostgresResource | TMySQLResource; -export type TPamAccount = TPostgresAccount; +export type TPamAccount = TPostgresAccount | TMySQLAccount; export type TPamFolder = { id: string; diff --git a/frontend/src/hooks/api/pam/types/mysql-resource.ts b/frontend/src/hooks/api/pam/types/mysql-resource.ts new file mode 100644 index 000000000..f1a7c09c8 --- /dev/null +++ b/frontend/src/hooks/api/pam/types/mysql-resource.ts @@ -0,0 +1,14 @@ +import { PamResourceType } from "../enums"; +import { TBaseSqlConnectionDetails, TBaseSqlCredentials } from "./shared/sql-resource"; +import { TBasePamAccount } from "./base-account"; +import { TBasePamResource } from "./base-resource"; + +// Resources +export type TMySQLResource = TBasePamResource & { resourceType: PamResourceType.MySQL } & { + connectionDetails: TBaseSqlConnectionDetails; +}; + +// Accounts +export type TMySQLAccount = TBasePamAccount & { + credentials: TBaseSqlCredentials; +}; diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/MySQLAccountForm.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/MySQLAccountForm.tsx new file mode 100644 index 000000000..4420f7f1c --- /dev/null +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/MySQLAccountForm.tsx @@ -0,0 +1,94 @@ +import { useEffect, useState } from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { Button, ModalClose } from "@app/components/v2"; +import { PamResourceType, TMySQLAccount, useGetPamResourceById } from "@app/hooks/api/pam"; +import { UNCHANGED_PASSWORD_SENTINEL } from "@app/hooks/api/pam/constants"; + +import { BaseSqlAccountSchema } from "./shared/sql-account-schemas"; +import { SqlAccountFields } from "./shared/SqlAccountFields"; +import { GenericAccountFields, genericAccountFieldsSchema } from "./GenericAccountFields"; +import { RotateAccountFields, rotateAccountFieldsSchema } from "./RotateAccountFields"; + + +type Props = { + account?: TMySQLAccount; + resourceId?: string; + resourceType?: PamResourceType; + onSubmit: (formData: FormData) => Promise; +}; + +const formSchema = genericAccountFieldsSchema.extend(rotateAccountFieldsSchema.shape).extend({ + credentials: BaseSqlAccountSchema +}); + +type FormData = z.infer; + +export const MySQLAccountForm = ({ account, resourceId, resourceType, onSubmit }: Props) => { + const isUpdate = Boolean(account); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: account + ? { + ...account, + credentials: { + ...account.credentials, + password: UNCHANGED_PASSWORD_SENTINEL + } + } + : undefined + }); + + const { + handleSubmit, + formState: { isSubmitting, isDirty } + } = form; + + const [rotationCredentialsConfigured, setRotationCredentialsConfigured] = useState(false); + + const { data: resource } = useGetPamResourceById(resourceType, resourceId, { + enabled: !account && !!resourceId && !!resourceType + }); + + useEffect(() => { + if (account) { + setRotationCredentialsConfigured(account.resource.rotationCredentialsConfigured); + } else { + setRotationCredentialsConfigured(!!resource?.rotationAccountCredentials); + } + }, [account, resource]); + + return ( + +
{ + handleSubmit(onSubmit)(e); + }} + > + + + +
+ + + + +
+ +
+ ); +}; diff --git a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PamAccountForm.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PamAccountForm.tsx index 8b553e656..7a1c1658e 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PamAccountForm.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PamAccountForm.tsx @@ -8,6 +8,7 @@ import { import { DiscriminativePick } from "@app/types"; import { PamAccountHeader } from "../PamAccountHeader"; +import { MySQLAccountForm } from "./MySQLAccountForm"; import { PostgresAccountForm } from "./PostgresAccountForm"; type FormProps = { @@ -72,6 +73,13 @@ const CreateForm = ({ resourceType={resourceType} /> ); + case PamResourceType.MySQL: + return ( + ); default: throw new Error(`Unhandled resource: ${resourceType}`); } @@ -110,6 +118,8 @@ const UpdateForm = ({ account, onComplete }: UpdateFormProps) => { switch (account.resource.resourceType) { case PamResourceType.Postgres: return ; + case PamResourceType.MySQL: + return ; default: throw new Error(`Unhandled resource: ${account.resource.resourceType}`); } diff --git a/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/MySQLResourceForm.tsx b/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/MySQLResourceForm.tsx new file mode 100644 index 000000000..b7c2996d3 --- /dev/null +++ b/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/MySQLResourceForm.tsx @@ -0,0 +1,84 @@ +import { useState } from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { Button, ModalClose } from "@app/components/v2"; +import { PamResourceType, TMySQLResource } from "@app/hooks/api/pam"; + +import { BaseSqlResourceSchema } from "./shared/sql-resource-schemas"; +import { SqlResourceFields } from "./shared/SqlResourceFields"; +import { GenericResourceFields, genericResourceFieldsSchema } from "./GenericResourceFields"; + +type Props = { + resource?: TMySQLResource; + onSubmit: (formData: FormData) => Promise; +}; + +const formSchema = genericResourceFieldsSchema.extend({ + resourceType: z.literal(PamResourceType.MySQL), + connectionDetails: BaseSqlResourceSchema.extend({ + database: z.string().trim().optional().default("") + }) +}); + +type FormData = z.infer; + +export const MySQLResourceForm = ({ resource, onSubmit }: Props) => { + const isUpdate = Boolean(resource); + const [selectedTabIndex, setSelectedTabIndex] = useState(0); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: resource ?? { + resourceType: PamResourceType.MySQL, + connectionDetails: { + host: "", + port: 3306, + database: "", + sslEnabled: true, + sslRejectUnauthorized: true, + sslCertificate: undefined + } + } + }); + + const { + handleSubmit, + formState: { isSubmitting, isDirty } + } = form; + + return ( + +
{ + setSelectedTabIndex(0); + handleSubmit(onSubmit)(e); + }} + > + + +
+ + + + +
+ +
+ ); +}; diff --git a/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/PamResourceForm.tsx b/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/PamResourceForm.tsx index 8cfdc8582..2bc54e7cd 100644 --- a/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/PamResourceForm.tsx +++ b/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/PamResourceForm.tsx @@ -9,6 +9,7 @@ import { import { DiscriminativePick } from "@app/types"; import { PamResourceHeader } from "../PamResourceHeader"; +import { MySQLResourceForm } from "./MySQLResourceForm"; import { PostgresResourceForm } from "./PostgresResourceForm"; type FormProps = { @@ -57,6 +58,8 @@ const CreateForm = ({ resourceType, onComplete, projectId }: CreateFormProps) => switch (resourceType) { case PamResourceType.Postgres: return ; + case PamResourceType.MySQL: + return ; default: throw new Error(`Unhandled resource: ${resourceType}`); } @@ -92,8 +95,10 @@ const UpdateForm = ({ resource, onComplete }: UpdateFormProps) => { switch (resource.resourceType) { case PamResourceType.Postgres: return ; + case PamResourceType.MySQL: + return ; default: - throw new Error(`Unhandled resource: ${resource.resourceType}`); + throw new Error(`Unhandled resource: ${(resource as any).resourceType}`); } }; diff --git a/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/shared/SqlResourceFields.tsx b/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/shared/SqlResourceFields.tsx index 888b62984..c3f3982ec 100644 --- a/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/shared/SqlResourceFields.tsx +++ b/frontend/src/pages/pam/PamResourcesPage/components/PamResourceForm/shared/SqlResourceFields.tsx @@ -110,7 +110,7 @@ export const SqlResourceFields = ({ setSelectedTabIndex, selectedTabIndex }: Pro errorText={error?.message} isError={Boolean(error?.message)} className={sslEnabled ? "" : "opacity-50"} - label="SSL Certificate" + label="Trusted CA SSL Certificate" isOptional >