mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
Merge pull request #4655 from Infisical/ENG-3913-add-mysql-pam
[ENG-3913] Add mysql pam
This commit is contained in:
@@ -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
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { MySQLResourceListItemSchema } from "./mysql-resource-schemas";
|
||||
|
||||
export const getMySQLResourceListItem = () => {
|
||||
return {
|
||||
name: MySQLResourceListItemSchema.shape.name.value,
|
||||
resource: MySQLResourceListItemSchema.shape.resource.value
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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>;
|
||||
@@ -1,3 +1,4 @@
|
||||
export enum PamResource {
|
||||
Postgres = "postgres"
|
||||
Postgres = "postgres",
|
||||
MySQL = "mysql"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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<void>;
|
||||
|
||||
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<TSqlAccountCredentials>;
|
||||
|
||||
/**
|
||||
* 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 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 <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 +210,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 +235,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 +265,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({
|
||||
@@ -186,9 +296,7 @@ export const sqlResourceFactory: TPamResourceFactory<TSqlResourceConnectionDetai
|
||||
currentCredentials
|
||||
) => {
|
||||
try {
|
||||
const newPassword = alphaNumericNanoId(32);
|
||||
|
||||
await executeWithGateway(
|
||||
return await executeWithGateway(
|
||||
{
|
||||
connectionDetails,
|
||||
gatewayId,
|
||||
@@ -197,20 +305,8 @@ export const sqlResourceFactory: TPamResourceFactory<TSqlResourceConnectionDetai
|
||||
password: rotationAccountCredentials.password
|
||||
},
|
||||
gatewayV2Service,
|
||||
async (client) => {
|
||||
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}"`) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export enum PamResourceType {
|
||||
Postgres = "postgres",
|
||||
MySQL = "mysql",
|
||||
RDP = "rdp",
|
||||
SSH = "ssh",
|
||||
Kubernetes = "kubernetes"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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;
|
||||
|
||||
14
frontend/src/hooks/api/pam/types/mysql-resource.ts
Normal file
14
frontend/src/hooks/api/pam/types/mysql-resource.ts
Normal 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;
|
||||
};
|
||||
@@ -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]
|
||||
|
||||
@@ -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<void>;
|
||||
};
|
||||
|
||||
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<typeof formSchema>;
|
||||
|
||||
export const MySQLAccountForm = ({ account, 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;
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
handleSubmit(onSubmit)(e);
|
||||
}}
|
||||
>
|
||||
<GenericAccountFields />
|
||||
<SqlAccountFields isUpdate={isUpdate} />
|
||||
<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>
|
||||
);
|
||||
};
|
||||
@@ -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<TPamAccount, "name" | "description" | "credentials">
|
||||
) => {
|
||||
try {
|
||||
const account = await createPamAccount.mutateAsync({
|
||||
@@ -72,6 +70,10 @@ const CreateForm = ({
|
||||
resourceType={resourceType}
|
||||
/>
|
||||
);
|
||||
case PamResourceType.MySQL:
|
||||
return (
|
||||
<MySQLAccountForm onSubmit={onSubmit} resourceId={resourceId} resourceType={resourceType} />
|
||||
);
|
||||
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<TPamAccount, "name" | "description" | "credentials">
|
||||
) => {
|
||||
try {
|
||||
const updatedAccount = await updatePamAccount.mutateAsync({
|
||||
@@ -110,6 +109,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}`);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user