Add mysql stuff

Add missing mysql types

Add missing mysql import

Add more mysql stuff

Add mysql options

Add missing mysql stuff

Update more for mysql stuff

Fix wrong psql stuff

Add mysql as deps

Use mysql2 driver

Add missing mysql stuff

Fix mysql form

Fix db conn details schema

Add more missing stuff for mysql

Add more missing stuff

Add TODO

Refactor

refactor

Refactor code

Refactor

Connect only for mysql

Upgrade mysql2

Try built in approach

Rename the field

Make ssl default

Clean up

Remove dev code

Add todo

Rename the right one

Fix linter

Add missing await

Fix linter issue

Fix linter complains

Fix linter complains

Fix more linters

Fix more linter issues

Fix linter issues

Fix linters

# Conflicts:
#	backend/src/ee/routes/v1/pam-resource-routers/pam-resource-router.ts
#	backend/src/ee/services/pam-resource/pam-resource-fns.ts
#	frontend/src/pages/pam/PamAccountsPage/components/PamAccountForm/PamAccountForm.tsx

# Conflicts:
#	frontend/src/pages/pam/PamAccountsPage/components/PamAccessAccountModal.tsx
This commit is contained in:
Fang-Pen Lin
2025-10-10 16:58:03 -07:00
committed by Fang-Pen Lin
parent abd4cb6217
commit cc6f0e79dd
25 changed files with 561 additions and 85 deletions

View File

@@ -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": {

View File

@@ -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",

View File

@@ -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<PamResource, (server: Fasti
createAccountSchema: CreatePostgresAccountSchema,
updateAccountSchema: UpdatePostgresAccountSchema
});
},
[PamResource.MySQL]: async (server: FastifyZodProvider) => {
registerPamResourceEndpoints({
server,
resourceType: PamResource.MySQL,
accountResponseSchema: SanitizedMySQLAccountWithResourceSchema,
createAccountSchema: CreateMySQLAccountSchema,
updateAccountSchema: UpdateMySQLAccountSchema
});
}
};

View File

@@ -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({

View File

@@ -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<PamResource, (server: Fast
createResourceSchema: CreatePostgresResourceSchema,
updateResourceSchema: UpdatePostgresResourceSchema
});
},
[PamResource.MySQL]: async (server: FastifyZodProvider) => {
registerPamResourceEndpoints({
server,
resourceType: PamResource.MySQL,
resourceResponseSchema: MySQLResourceSchema,
createResourceSchema: CreateMySQLResourceSchema,
updateResourceSchema: UpdateMySQLResourceSchema
});
}
};

View File

@@ -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({

View File

@@ -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

View File

@@ -0,0 +1,8 @@
import { MySQLResourceListItemSchema } from "./mysql-resource-schemas";
export const getMySQLResourceListItem = () => {
return {
name: MySQLResourceListItemSchema.shape.name.value,
resource: MySQLResourceListItemSchema.shape.resource.value
};
};

View File

@@ -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);

View File

@@ -0,0 +1,16 @@
import { z } from "zod";
import {
MySQLAccountCredentialsSchema,
MySQLAccountSchema,
MySQLResourceConnectionDetailsSchema,
MySQLResourceSchema
} from "./mysql-resource-schemas";
// Resources
export type TMySQLResource = z.infer<typeof MySQLResourceSchema>;
export type TMySQLResourceConnectionDetails = z.infer<typeof MySQLResourceConnectionDetailsSchema>;
// Accounts
export type TMySQLAccount = z.infer<typeof MySQLAccountSchema>;
export type TMySQLAccountCredentials = z.infer<typeof MySQLAccountCredentialsSchema>;

View File

@@ -1,3 +1,4 @@
export enum PamResource {
Postgres = "postgres"
Postgres = "postgres",
MySQL = "mysql"
}

View File

@@ -5,5 +5,6 @@ import { sqlResourceFactory } from "./shared/sql/sql-resource-factory";
type TPamResourceFactoryImplementation = TPamResourceFactory<TPamResourceConnectionDetails, TPamAccountCredentials>;
export const PAM_RESOURCE_FACTORY_MAP: Record<PamResource, TPamResourceFactoryImplementation> = {
[PamResource.Postgres]: sqlResourceFactory as TPamResourceFactoryImplementation
[PamResource.Postgres]: sqlResourceFactory as TPamResourceFactoryImplementation,
[PamResource.MySQL]: sqlResourceFactory as TPamResourceFactoryImplementation
};

View File

@@ -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

View File

@@ -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<

View File

@@ -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<void>;
const getConnectionConfig = (
resourceType: PamResource,
{ host, sslEnabled, sslRejectUnauthorized, sslCertificate }: TSqlResourceConnectionDetails
) => {
switch (resourceType) {
/**
* Close the connection.
*
* @returns Promise for closing the connection
*/
close: () => Promise<void>;
}
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 <T>(
password?: string;
},
gatewayV2Service: Pick<TGatewayV2ServiceFactory, "getPlatformConnectionDetailsByGatewayId">,
operation: (client: Knex) => Promise<T>
operation: (connection: SqlResourceConnection) => Promise<T>
): Promise<T> => {
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 <T>(
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<TSqlResourceConnectionDetai
const validateConnection = async () => {
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<TSqlResourceConnectionDetai
},
gatewayV2Service,
async (client) => {
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({

View File

@@ -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;

View File

@@ -1,5 +1,6 @@
export enum PamResourceType {
Postgres = "postgres",
MySQL = "mysql",
RDP = "rdp",
SSH = "ssh",
Kubernetes = "kubernetes"

View File

@@ -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" }

View File

@@ -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;

View File

@@ -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;
};

View File

@@ -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<void>;
};
const formSchema = genericAccountFieldsSchema.extend(rotateAccountFieldsSchema.shape).extend({
credentials: BaseSqlAccountSchema
});
type FormData = z.infer<typeof formSchema>;
export const MySQLAccountForm = ({ account, resourceId, resourceType, onSubmit }: Props) => {
const isUpdate = Boolean(account);
const form = useForm<FormData>({
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 (
<FormProvider {...form}>
<form
onSubmit={(e) => {
handleSubmit(onSubmit)(e);
}}
>
<GenericAccountFields />
<SqlAccountFields isUpdate={isUpdate} />
<RotateAccountFields rotationCredentialsConfigured={rotationCredentialsConfigured} />
<div className="mt-6 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
{isUpdate ? "Update Account" : "Create Account"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};

View File

@@ -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 (
<MySQLAccountForm
onSubmit={onSubmit}
resourceId={resourceId}
resourceType={resourceType}
/>);
default:
throw new Error(`Unhandled resource: ${resourceType}`);
}
@@ -110,6 +118,8 @@ const UpdateForm = ({ account, onComplete }: UpdateFormProps) => {
switch (account.resource.resourceType) {
case PamResourceType.Postgres:
return <PostgresAccountForm account={account} onSubmit={onSubmit} />;
case PamResourceType.MySQL:
return <MySQLAccountForm account={account} onSubmit={onSubmit} />;
default:
throw new Error(`Unhandled resource: ${account.resource.resourceType}`);
}

View File

@@ -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<void>;
};
const formSchema = genericResourceFieldsSchema.extend({
resourceType: z.literal(PamResourceType.MySQL),
connectionDetails: BaseSqlResourceSchema.extend({
database: z.string().trim().optional().default("")
})
});
type FormData = z.infer<typeof formSchema>;
export const MySQLResourceForm = ({ resource, onSubmit }: Props) => {
const isUpdate = Boolean(resource);
const [selectedTabIndex, setSelectedTabIndex] = useState(0);
const form = useForm<FormData>({
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 (
<FormProvider {...form}>
<form
onSubmit={(e) => {
setSelectedTabIndex(0);
handleSubmit(onSubmit)(e);
}}
>
<GenericResourceFields />
<SqlResourceFields
selectedTabIndex={selectedTabIndex}
setSelectedTabIndex={setSelectedTabIndex}
/>
<div className="mt-6 flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
colorSchema="secondary"
isLoading={isSubmitting}
isDisabled={isSubmitting || !isDirty}
>
{isUpdate ? "Update Details" : "Create Resource"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
</FormProvider>
);
};

View File

@@ -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 <PostgresResourceForm onSubmit={onSubmit} />;
case PamResourceType.MySQL:
return <MySQLResourceForm onSubmit={onSubmit} />;
default:
throw new Error(`Unhandled resource: ${resourceType}`);
}
@@ -92,8 +95,10 @@ const UpdateForm = ({ resource, onComplete }: UpdateFormProps) => {
switch (resource.resourceType) {
case PamResourceType.Postgres:
return <PostgresResourceForm resource={resource} onSubmit={onSubmit} />;
case PamResourceType.MySQL:
return <MySQLResourceForm resource={resource} onSubmit={onSubmit} />;
default:
throw new Error(`Unhandled resource: ${resource.resourceType}`);
throw new Error(`Unhandled resource: ${(resource as any).resourceType}`);
}
};

View File

@@ -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
>
<TextArea className="h-14 resize-none!" {...field} isDisabled={!sslEnabled} />