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..a171b39b5 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,6 @@ -import knex, { Knex } from "knex"; +import knex from "knex"; +import mysql, { Connection } from "mysql2/promise"; +import * as pg from "pg"; import tls, { PeerCertificate } from "tls"; import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic-secret-fns"; @@ -20,30 +22,160 @@ 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) { + /** + * Rotate password and return the new credentials. + * + * @param currentCredentials the current credentials to rotate + * + * @returns Promise to be resolved with the new credentials + */ + rotateCredentials: (currentCredentials: TSqlAccountCredentials) => Promise; + + /** + * 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 pg.DatabaseError) { + // 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)}` + }); + } + }, + rotateCredentials: async (currentCredentials) => { + const newPassword = alphaNumericNanoId(32); + // Note: The generated random password is not really going to make SQL Injection possible. + // The reason we are not using parameters binding is that the "ALTER USER" syntax is DDL, + // parameters binding is not supported. But just in case if the this code got copied + // around and repurposed, let's just do some naive escaping regardless + await client.raw(`ALTER USER :username: WITH PASSWORD '${newPassword.replace(/'/g, "''")}'`, { + username: currentCredentials.username + }); + return { username: currentCredentials.username, password: newPassword }; + }, + 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(); + } + }, + rotateCredentials: async () => { + // TODO: the pwd rotation for MySQL is not supported yet + throw new BadRequestError({ + message: "Unsupported operation" + }); + }, + close: async () => {} }; } default: @@ -62,10 +194,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 +210,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 +235,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 +265,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({ @@ -186,9 +296,7 @@ export const sqlResourceFactory: TPamResourceFactory { try { - const newPassword = alphaNumericNanoId(32); - - await executeWithGateway( + return await executeWithGateway( { connectionDetails, gatewayId, @@ -197,20 +305,8 @@ export const sqlResourceFactory: TPamResourceFactory { - switch (resourceType) { - case PamResource.Postgres: - await client.raw(`ALTER USER ?? WITH PASSWORD '${newPassword}'`, [currentCredentials.username]); - break; - default: - throw new BadRequestError({ - message: `Password rotation for ${resourceType as PamResource} is not supported.` - }); - } - } + (client) => client.rotateCredentials(currentCredentials) ); - - return { username: currentCredentials.username, password: newPassword }; } catch (error) { if (error instanceof BadRequestError) { if (error.message === `password authentication failed for user "${rotationAccountCredentials.username}"`) { 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/PamAccessAccountModal.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccessAccountModal.tsx index 2e875979d..a1bf76888 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamAccessAccountModal.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccessAccountModal.tsx @@ -60,7 +60,9 @@ export const PamAccessAccountModal = ({ isOpen, onOpenChange, account }: Props) const command = useMemo( () => - account && account.resource.resourceType === PamResourceType.Postgres + account && + (account.resource.resourceType === PamResourceType.Postgres || + account.resource.resourceType === PamResourceType.MySQL) ? `infisical pam db access-account ${account.id} --duration ${cliDuration}` : "", [account, cliDuration] 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..131da7ef3 --- /dev/null +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/MySQLAccountForm.tsx @@ -0,0 +1,79 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { FormProvider, useForm } from "react-hook-form"; +import { z } from "zod"; + +import { Button, ModalClose } from "@app/components/v2"; +import { PamResourceType, TMySQLAccount } from "@app/hooks/api/pam"; +import { UNCHANGED_PASSWORD_SENTINEL } from "@app/hooks/api/pam/constants"; + +import { GenericAccountFields, genericAccountFieldsSchema } from "./GenericAccountFields"; +import { BaseSqlAccountSchema } from "./shared/sql-account-schemas"; +import { SqlAccountFields } from "./shared/SqlAccountFields"; + +type Props = { + account?: TMySQLAccount; + resourceId?: string; + resourceType?: PamResourceType; + onSubmit: (formData: FormData) => Promise; +}; + +const formSchema = genericAccountFieldsSchema.extend({ + credentials: BaseSqlAccountSchema, + // We don't support rotation for now, just feed a false value to + // make the schema happy + rotationEnabled: z.boolean().default(false) +}); + +type FormData = z.infer; + +export const MySQLAccountForm = ({ account, 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; + + 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..b9599be2d 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 = { @@ -35,10 +36,7 @@ const CreateForm = ({ const createPamAccount = useCreatePamAccount(); const onSubmit = async ( - formData: DiscriminativePick< - TPamAccount, - "name" | "description" | "credentials" | "rotationEnabled" | "rotationIntervalSeconds" - > + formData: DiscriminativePick ) => { try { const account = await createPamAccount.mutateAsync({ @@ -72,6 +70,10 @@ const CreateForm = ({ resourceType={resourceType} /> ); + case PamResourceType.MySQL: + return ( + + ); default: throw new Error(`Unhandled resource: ${resourceType}`); } @@ -81,10 +83,7 @@ const UpdateForm = ({ account, onComplete }: UpdateFormProps) => { const updatePamAccount = useUpdatePamAccount(); const onSubmit = async ( - formData: DiscriminativePick< - TPamAccount, - "name" | "description" | "credentials" | "rotationEnabled" | "rotationIntervalSeconds" - > + formData: DiscriminativePick ) => { try { const updatedAccount = await updatePamAccount.mutateAsync({ @@ -110,6 +109,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/PamAccountsPage/components/PamAccountForm/PostgresAccountForm.tsx b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PostgresAccountForm.tsx index 3e5994344..6d361877e 100644 --- a/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PostgresAccountForm.tsx +++ b/frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PostgresAccountForm.tsx @@ -1,16 +1,21 @@ +import { zodResolver } from "@hookform/resolvers/zod"; 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, TPostgresAccount, useGetPamResourceById } from "@app/hooks/api/pam"; +import { + PamResourceType, + TPostgresAccount, + TPostgresResource, + 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"; +import { BaseSqlAccountSchema } from "./shared/sql-account-schemas"; +import { SqlAccountFields } from "./shared/SqlAccountFields"; type Props = { account?: TPostgresAccount; @@ -56,7 +61,9 @@ export const PostgresAccountForm = ({ account, resourceId, resourceType, onSubmi if (account) { setRotationCredentialsConfigured(account.resource.rotationCredentialsConfigured); } else { - setRotationCredentialsConfigured(!!resource?.rotationAccountCredentials); + setRotationCredentialsConfigured( + !!(resource as TPostgresResource)?.rotationAccountCredentials + ); } }, [account, resource]); 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 >