Merge remote-tracking branch 'origin/main' into feat/gateway-v2

This commit is contained in:
Sheen Capadngan
2025-09-03 18:48:54 +08:00
327 changed files with 13734 additions and 4728 deletions
@@ -8,6 +8,7 @@ export enum AppConnection {
AzureAppConfiguration = "azure-app-configuration",
AzureClientSecrets = "azure-client-secrets",
AzureDevOps = "azure-devops",
AzureADCS = "azure-adcs",
Humanitec = "humanitec",
TerraformCloud = "terraform-cloud",
Vercel = "vercel",
@@ -32,6 +32,11 @@ import {
} from "./app-connection-types";
import { Auth0ConnectionMethod, getAuth0ConnectionListItem, validateAuth0ConnectionCredentials } from "./auth0";
import { AwsConnectionMethod, getAwsConnectionListItem, validateAwsConnectionCredentials } from "./aws";
import { AzureADCSConnectionMethod } from "./azure-adcs";
import {
getAzureADCSConnectionListItem,
validateAzureADCSConnectionCredentials
} from "./azure-adcs/azure-adcs-connection-fns";
import {
AzureAppConfigurationConnectionMethod,
getAzureAppConfigurationConnectionListItem,
@@ -137,6 +142,7 @@ export const listAppConnectionOptions = () => {
getAzureKeyVaultConnectionListItem(),
getAzureAppConfigurationConnectionListItem(),
getAzureDevopsConnectionListItem(),
getAzureADCSConnectionListItem(),
getDatabricksConnectionListItem(),
getHumanitecConnectionListItem(),
getTerraformCloudConnectionListItem(),
@@ -229,6 +235,7 @@ export const validateAppConnectionCredentials = async (
[AppConnection.AzureClientSecrets]:
validateAzureClientSecretsConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.AzureDevOps]: validateAzureDevOpsConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.AzureADCS]: validateAzureADCSConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Humanitec]: validateHumanitecConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Postgres]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.MsSql]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator,
@@ -302,6 +309,7 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) =>
case MsSqlConnectionMethod.UsernameAndPassword:
case MySqlConnectionMethod.UsernameAndPassword:
case OracleDBConnectionMethod.UsernameAndPassword:
case AzureADCSConnectionMethod.UsernamePassword:
return "Username & Password";
case WindmillConnectionMethod.AccessToken:
case HCVaultConnectionMethod.AccessToken:
@@ -359,6 +367,7 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record<
[AppConnection.AzureKeyVault]: platformManagedCredentialsNotSupported,
[AppConnection.AzureAppConfiguration]: platformManagedCredentialsNotSupported,
[AppConnection.AzureDevOps]: platformManagedCredentialsNotSupported,
[AppConnection.AzureADCS]: platformManagedCredentialsNotSupported,
[AppConnection.Humanitec]: platformManagedCredentialsNotSupported,
[AppConnection.Postgres]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform,
[AppConnection.MsSql]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform,
@@ -9,6 +9,7 @@ export const APP_CONNECTION_NAME_MAP: Record<AppConnection, string> = {
[AppConnection.AzureAppConfiguration]: "Azure App Configuration",
[AppConnection.AzureClientSecrets]: "Azure Client Secrets",
[AppConnection.AzureDevOps]: "Azure DevOps",
[AppConnection.AzureADCS]: "Azure ADCS",
[AppConnection.Databricks]: "Databricks",
[AppConnection.Humanitec]: "Humanitec",
[AppConnection.TerraformCloud]: "Terraform Cloud",
@@ -49,6 +50,7 @@ export const APP_CONNECTION_PLAN_MAP: Record<AppConnection, AppConnectionPlanTyp
[AppConnection.AzureAppConfiguration]: AppConnectionPlanType.Regular,
[AppConnection.AzureClientSecrets]: AppConnectionPlanType.Regular,
[AppConnection.AzureDevOps]: AppConnectionPlanType.Regular,
[AppConnection.AzureADCS]: AppConnectionPlanType.Regular,
[AppConnection.Databricks]: AppConnectionPlanType.Regular,
[AppConnection.Humanitec]: AppConnectionPlanType.Regular,
[AppConnection.TerraformCloud]: AppConnectionPlanType.Regular,
@@ -47,6 +47,7 @@ import {
import { ValidateAuth0ConnectionCredentialsSchema } from "./auth0";
import { ValidateAwsConnectionCredentialsSchema } from "./aws";
import { awsConnectionService } from "./aws/aws-connection-service";
import { ValidateAzureADCSConnectionCredentialsSchema } from "./azure-adcs/azure-adcs-connection-schemas";
import { ValidateAzureAppConfigurationConnectionCredentialsSchema } from "./azure-app-configuration";
import { ValidateAzureClientSecretsConnectionCredentialsSchema } from "./azure-client-secrets";
import { azureClientSecretsConnectionService } from "./azure-client-secrets/azure-client-secrets-service";
@@ -126,6 +127,7 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record<AppConnection, TValidateAp
[AppConnection.AzureKeyVault]: ValidateAzureKeyVaultConnectionCredentialsSchema,
[AppConnection.AzureAppConfiguration]: ValidateAzureAppConfigurationConnectionCredentialsSchema,
[AppConnection.AzureDevOps]: ValidateAzureDevOpsConnectionCredentialsSchema,
[AppConnection.AzureADCS]: ValidateAzureADCSConnectionCredentialsSchema,
[AppConnection.Databricks]: ValidateDatabricksConnectionCredentialsSchema,
[AppConnection.Humanitec]: ValidateHumanitecConnectionCredentialsSchema,
[AppConnection.TerraformCloud]: ValidateTerraformCloudConnectionCredentialsSchema,
@@ -609,7 +611,7 @@ export const appConnectionServiceFactory = ({
azureClientSecrets: azureClientSecretsConnectionService(connectAppConnectionById, appConnectionDAL, kmsService),
azureDevOps: azureDevOpsConnectionService(connectAppConnectionById, appConnectionDAL, kmsService),
auth0: auth0ConnectionService(connectAppConnectionById, appConnectionDAL, kmsService),
hcvault: hcVaultConnectionService(connectAppConnectionById),
hcvault: hcVaultConnectionService(connectAppConnectionById, gatewayService),
windmill: windmillConnectionService(connectAppConnectionById),
teamcity: teamcityConnectionService(connectAppConnectionById),
oci: ociConnectionService(connectAppConnectionById, licenseService),
@@ -34,6 +34,12 @@ import {
TAwsConnectionInput,
TValidateAwsConnectionCredentialsSchema
} from "./aws";
import {
TAzureADCSConnection,
TAzureADCSConnectionConfig,
TAzureADCSConnectionInput,
TValidateAzureADCSConnectionCredentialsSchema
} from "./azure-adcs/azure-adcs-connection-types";
import {
TAzureAppConfigurationConnection,
TAzureAppConfigurationConnectionConfig,
@@ -224,6 +230,7 @@ export type TAppConnection = { id: string } & (
| TAzureKeyVaultConnection
| TAzureAppConfigurationConnection
| TAzureDevOpsConnection
| TAzureADCSConnection
| TDatabricksConnection
| THumanitecConnection
| TTerraformCloudConnection
@@ -268,6 +275,7 @@ export type TAppConnectionInput = { id: string } & (
| TAzureKeyVaultConnectionInput
| TAzureAppConfigurationConnectionInput
| TAzureDevOpsConnectionInput
| TAzureADCSConnectionInput
| TDatabricksConnectionInput
| THumanitecConnectionInput
| TTerraformCloudConnectionInput
@@ -323,6 +331,7 @@ export type TAppConnectionConfig =
| TAzureKeyVaultConnectionConfig
| TAzureAppConfigurationConnectionConfig
| TAzureDevOpsConnectionConfig
| TAzureADCSConnectionConfig
| TAzureClientSecretsConnectionConfig
| TDatabricksConnectionConfig
| THumanitecConnectionConfig
@@ -360,6 +369,7 @@ export type TValidateAppConnectionCredentialsSchema =
| TValidateAzureAppConfigurationConnectionCredentialsSchema
| TValidateAzureClientSecretsConnectionCredentialsSchema
| TValidateAzureDevOpsConnectionCredentialsSchema
| TValidateAzureADCSConnectionCredentialsSchema
| TValidateDatabricksConnectionCredentialsSchema
| TValidateHumanitecConnectionCredentialsSchema
| TValidatePostgresConnectionCredentialsSchema
@@ -91,7 +91,7 @@ export const validateAuth0ConnectionCredentials = async ({ credentials }: TAuth0
};
} catch (e: unknown) {
throw new BadRequestError({
message: (e as Error).message ?? `Unable to validate connection: verify credentials`
message: (e as Error).message ?? "Unable to validate connection: verify credentials"
});
}
};
@@ -0,0 +1,3 @@
export enum AzureADCSConnectionMethod {
UsernamePassword = "username-password"
}
@@ -0,0 +1,455 @@
/* eslint-disable no-case-declarations, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-var-requires, no-await-in-loop, no-continue */
import { NtlmClient } from "axios-ntlm";
import https from "https";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator/validate-url";
import { decryptAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TAppConnectionDALFactory } from "../app-connection-dal";
import { AppConnection } from "../app-connection-enums";
import { AzureADCSConnectionMethod } from "./azure-adcs-connection-enums";
import { TAzureADCSConnectionConfig } from "./azure-adcs-connection-types";
// Type definitions for axios-ntlm
interface AxiosNtlmConfig {
ntlm: {
domain: string;
username: string;
password: string;
};
httpsAgent?: https.Agent;
url: string;
method?: string;
data?: string;
headers?: Record<string, string>;
}
interface AxiosNtlmResponse {
status: number;
data: string;
headers: unknown;
}
// Types for credential parsing
interface ParsedCredentials {
domain: string;
username: string;
fullUsername: string; // domain\username format
}
// Helper function to parse and normalize credentials for Windows authentication
const parseCredentials = (inputUsername: string): ParsedCredentials => {
// Ensure inputUsername is a string
if (typeof inputUsername !== "string" || !inputUsername.trim()) {
throw new BadRequestError({
message: "Username must be a non-empty string"
});
}
let domain = "";
let username = "";
let fullUsername = "";
if (inputUsername.includes("\\")) {
// Already in domain\username format
const parts = inputUsername.split("\\");
if (parts.length === 2) {
[domain, username] = parts;
fullUsername = inputUsername;
} else {
throw new BadRequestError({
message: "Invalid domain\\username format. Expected format: DOMAIN\\username"
});
}
} else if (inputUsername.includes("@")) {
// UPN format: [email protected]
const [user, domainPart] = inputUsername.split("@");
if (!user || !domainPart) {
throw new BadRequestError({
message: "Invalid UPN format. Expected format: [email protected]"
});
}
username = user;
// Extract NetBIOS name from FQDN
domain = domainPart.split(".")[0].toUpperCase();
fullUsername = `${domain}\\${username}`;
} else {
// Plain username - assume local account or current domain
username = inputUsername;
domain = "";
fullUsername = inputUsername;
}
return { domain, username, fullUsername };
};
// Helper to normalize URL
const normalizeAdcsUrl = (url: string): string => {
let normalizedUrl = url.trim();
// Remove trailing slash
normalizedUrl = normalizedUrl.replace(/\/$/, "");
// Ensure HTTPS protocol
if (normalizedUrl.startsWith("http://")) {
normalizedUrl = normalizedUrl.replace("http://", "https://");
} else if (!normalizedUrl.startsWith("https://")) {
normalizedUrl = `https://${normalizedUrl}`;
}
return normalizedUrl;
};
// NTLM request wrapper
const createHttpsAgent = (sslRejectUnauthorized: boolean, sslCertificate?: string): https.Agent => {
const agentOptions: https.AgentOptions = {
rejectUnauthorized: sslRejectUnauthorized,
keepAlive: true, // axios-ntlm needs keepAlive for NTLM handshake
ca: sslCertificate ? [sslCertificate.trim()] : undefined,
// Disable hostname verification as Microsoft servers by default use local IPs for certificates
// which may not match the hostname used to connect
checkServerIdentity: () => undefined
};
return new https.Agent(agentOptions);
};
const axiosNtlmRequest = async (config: AxiosNtlmConfig): Promise<AxiosNtlmResponse> => {
const method = config.method || "GET";
const credentials = {
username: config.ntlm.username,
password: config.ntlm.password,
domain: config.ntlm.domain || "",
workstation: ""
};
const axiosConfig = {
httpsAgent: config.httpsAgent,
timeout: 30000
};
const client = NtlmClient(credentials, axiosConfig);
const requestOptions: { url: string; method: string; data?: string; headers?: Record<string, string> } = {
url: config.url,
method
};
if (config.data) {
requestOptions.data = config.data;
}
if (config.headers) {
requestOptions.headers = config.headers;
}
const response = await client(requestOptions);
return {
status: response.status,
data: response.data,
headers: response.headers
};
};
// Test ADCS connectivity and authentication using NTLM
const testAdcsConnection = async (
credentials: ParsedCredentials,
password: string,
baseUrl: string,
sslRejectUnauthorized: boolean = true,
sslCertificate?: string
): Promise<boolean> => {
// Test endpoints in order of preference
const testEndpoints = [
"/certsrv/certrqus.asp", // Certificate request status (most reliable)
"/certsrv/certfnsh.asp", // Certificate finalization
"/certsrv/default.asp", // Main ADCS page
"/certsrv/" // Root certsrv
];
for (const endpoint of testEndpoints) {
try {
const testUrl = `${baseUrl}${endpoint}`;
const shouldRejectUnauthorized = sslRejectUnauthorized;
const httpsAgent = createHttpsAgent(shouldRejectUnauthorized, sslCertificate);
const response = await axiosNtlmRequest({
url: testUrl,
method: "GET",
httpsAgent,
ntlm: {
domain: credentials.domain,
username: credentials.username,
password
}
});
// Check if we got a successful response
if (response.status === 200) {
const responseText = response.data;
// Verify this is actually an ADCS server by checking content
const adcsIndicators = [
"Microsoft Active Directory Certificate Services",
"Certificate Services",
"Request a certificate",
"certsrv",
"Certificate Template",
"Web Enrollment"
];
const isAdcsServer = adcsIndicators.some((indicator) =>
responseText.toLowerCase().includes(indicator.toLowerCase())
);
if (isAdcsServer) {
// Successfully authenticated and confirmed ADCS
return true;
}
}
if (response.status === 401) {
throw new BadRequestError({
message: "Authentication failed. Please verify your credentials are correct."
});
}
if (response.status === 403) {
throw new BadRequestError({
message: "Access denied. Your account may not have permission to access ADCS web enrollment."
});
}
} catch (error) {
if (error instanceof BadRequestError) {
throw error;
}
// Handle network and connection errors
if (error instanceof Error) {
if (error.message.includes("ENOTFOUND")) {
throw new BadRequestError({
message: "Cannot resolve ADCS server hostname. Please verify the URL is correct."
});
}
if (error.message.includes("ECONNREFUSED")) {
throw new BadRequestError({
message: "Connection refused by ADCS server. Please verify the server is running and accessible."
});
}
if (error.message.includes("ETIMEDOUT") || error.message.includes("timeout")) {
throw new BadRequestError({
message: "Connection timeout. Please verify the server is accessible and not blocked by firewall."
});
}
if (error.message.includes("certificate") || error.message.includes("SSL") || error.message.includes("TLS")) {
throw new BadRequestError({
message: `SSL/TLS certificate error: ${error.message}. This may indicate a certificate verification failure.`
});
}
if (error.message.includes("DEPTH_ZERO_SELF_SIGNED_CERT")) {
throw new BadRequestError({
message:
"Self-signed certificate detected. Either provide the server's certificate or set 'sslRejectUnauthorized' to false."
});
}
if (error.message.includes("UNABLE_TO_VERIFY_LEAF_SIGNATURE")) {
throw new BadRequestError({
message: "Unable to verify certificate signature. Please provide the correct CA certificate."
});
}
}
// Continue to next endpoint for other errors
continue;
}
}
// If we get here, no endpoint worked
throw new BadRequestError({
message: "Could not connect to ADCS server. Please verify the server URL and that Web Enrollment is enabled."
});
};
// Create authenticated NTLM client for ADCS operations
const createNtlmClient = (
username: string,
password: string,
baseUrl: string,
sslRejectUnauthorized: boolean = true,
sslCertificate?: string
) => {
const parsedCredentials = parseCredentials(username);
const normalizedUrl = normalizeAdcsUrl(baseUrl);
return {
get: async (endpoint: string, additionalHeaders: Record<string, string> = {}) => {
const shouldRejectUnauthorized = sslRejectUnauthorized;
const httpsAgent = createHttpsAgent(shouldRejectUnauthorized, sslCertificate);
return axiosNtlmRequest({
url: `${normalizedUrl}${endpoint}`,
method: "GET",
httpsAgent,
headers: additionalHeaders,
ntlm: {
domain: parsedCredentials.domain,
username: parsedCredentials.username,
password
}
});
},
post: async (endpoint: string, body: string, additionalHeaders: Record<string, string> = {}) => {
const shouldRejectUnauthorized = sslRejectUnauthorized;
const httpsAgent = createHttpsAgent(shouldRejectUnauthorized, sslCertificate);
return axiosNtlmRequest({
url: `${normalizedUrl}${endpoint}`,
method: "POST",
httpsAgent,
data: body,
headers: {
"Content-Type": "application/x-www-form-urlencoded",
...additionalHeaders
},
ntlm: {
domain: parsedCredentials.domain,
username: parsedCredentials.username,
password
}
});
},
baseUrl: normalizedUrl,
credentials: parsedCredentials
};
};
export const getAzureADCSConnectionCredentials = async (
connectionId: string,
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById">,
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
) => {
const appConnection = await appConnectionDAL.findById(connectionId);
if (!appConnection) {
throw new NotFoundError({ message: `Connection with ID '${connectionId}' not found` });
}
if (appConnection.app !== AppConnection.AzureADCS) {
throw new BadRequestError({ message: `Connection with ID '${connectionId}' is not an Azure ADCS connection` });
}
switch (appConnection.method) {
case AzureADCSConnectionMethod.UsernamePassword:
const credentials = (await decryptAppConnectionCredentials({
orgId: appConnection.orgId,
kmsService,
encryptedCredentials: appConnection.encryptedCredentials
})) as {
username: string;
password: string;
adcsUrl: string;
sslRejectUnauthorized?: boolean;
sslCertificate?: string;
};
return {
username: credentials.username,
password: credentials.password,
adcsUrl: credentials.adcsUrl,
sslRejectUnauthorized: credentials.sslRejectUnauthorized ?? true,
sslCertificate: credentials.sslCertificate
};
default:
throw new BadRequestError({
message: `Unsupported Azure ADCS connection method: ${appConnection.method}`
});
}
};
export const validateAzureADCSConnectionCredentials = async (appConnection: TAzureADCSConnectionConfig) => {
const { credentials } = appConnection;
try {
// Parse and validate credentials
const parsedCredentials = parseCredentials(credentials.username);
const normalizedUrl = normalizeAdcsUrl(credentials.adcsUrl);
// Validate URL to prevent DNS manipulation attacks and SSRF
await blockLocalAndPrivateIpAddresses(normalizedUrl);
// Test the connection using NTLM
await testAdcsConnection(
parsedCredentials,
credentials.password,
normalizedUrl,
credentials.sslRejectUnauthorized ?? true,
credentials.sslCertificate
);
// If we get here, authentication was successful
return {
username: credentials.username,
password: credentials.password,
adcsUrl: credentials.adcsUrl,
sslRejectUnauthorized: credentials.sslRejectUnauthorized ?? true,
sslCertificate: credentials.sslCertificate
};
} catch (error) {
if (error instanceof BadRequestError) {
throw error;
}
// Handle unexpected errors
let errorMessage = "Unable to validate ADCS connection.";
if (error instanceof Error) {
if (error.message.includes("401") || error.message.includes("Unauthorized")) {
errorMessage = "NTLM authentication failed. Please verify your username, password, and domain are correct.";
} else if (error.message.includes("ENOTFOUND") || error.message.includes("ECONNREFUSED")) {
errorMessage = "Cannot connect to the ADCS server. Please verify the server URL is correct and accessible.";
} else if (error.message.includes("timeout")) {
errorMessage = "Connection to ADCS server timed out. Please verify the server is accessible.";
} else if (
error.message.includes("certificate") ||
error.message.includes("SSL") ||
error.message.includes("TLS") ||
error.message.includes("DEPTH_ZERO_SELF_SIGNED_CERT") ||
error.message.includes("UNABLE_TO_VERIFY_LEAF_SIGNATURE")
) {
errorMessage = `SSL/TLS certificate error: ${error.message}. The server certificate may be self-signed or the CA certificate may be incorrect.`;
}
}
throw new BadRequestError({
message: `Failed to validate Azure ADCS connection: ${errorMessage} Details: ${
error instanceof Error ? error.message : "Unknown error"
}`
});
}
};
export const getAzureADCSConnectionListItem = () => ({
name: "Azure ADCS" as const,
app: AppConnection.AzureADCS as const,
methods: [AzureADCSConnectionMethod.UsernamePassword] as [AzureADCSConnectionMethod.UsernamePassword]
});
// Export helper functions for use in certificate ordering
export const createAdcsHttpClient = (
username: string,
password: string,
baseUrl: string,
sslRejectUnauthorized: boolean = true,
sslCertificate?: string
) => {
return createNtlmClient(username, password, baseUrl, sslRejectUnauthorized, sslCertificate);
};
@@ -0,0 +1,88 @@
import z from "zod";
import { AppConnections } from "@app/lib/api-docs";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import {
BaseAppConnectionSchema,
GenericCreateAppConnectionFieldsSchema,
GenericUpdateAppConnectionFieldsSchema
} from "@app/services/app-connection/app-connection-schemas";
import { AzureADCSConnectionMethod } from "./azure-adcs-connection-enums";
export const AzureADCSUsernamePasswordCredentialsSchema = z.object({
adcsUrl: z
.string()
.trim()
.min(1, "ADCS URL required")
.max(255)
.refine((value) => value.startsWith("https://"), "ADCS URL must use HTTPS")
.describe(AppConnections.CREDENTIALS.AZURE_ADCS.adcsUrl),
username: z
.string()
.trim()
.min(1, "Username required")
.max(255)
.describe(AppConnections.CREDENTIALS.AZURE_ADCS.username),
password: z
.string()
.trim()
.min(1, "Password required")
.max(255)
.describe(AppConnections.CREDENTIALS.AZURE_ADCS.password),
sslRejectUnauthorized: z.boolean().optional().describe(AppConnections.CREDENTIALS.AZURE_ADCS.sslRejectUnauthorized),
sslCertificate: z
.string()
.trim()
.transform((value) => value || undefined)
.optional()
.describe(AppConnections.CREDENTIALS.AZURE_ADCS.sslCertificate)
});
const BaseAzureADCSConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.AzureADCS) });
export const AzureADCSConnectionSchema = BaseAzureADCSConnectionSchema.extend({
method: z.literal(AzureADCSConnectionMethod.UsernamePassword),
credentials: AzureADCSUsernamePasswordCredentialsSchema
});
export const SanitizedAzureADCSConnectionSchema = z.discriminatedUnion("method", [
BaseAzureADCSConnectionSchema.extend({
method: z.literal(AzureADCSConnectionMethod.UsernamePassword),
credentials: AzureADCSUsernamePasswordCredentialsSchema.pick({
username: true,
adcsUrl: true,
sslRejectUnauthorized: true,
sslCertificate: true
})
})
]);
export const ValidateAzureADCSConnectionCredentialsSchema = z.discriminatedUnion("method", [
z.object({
method: z
.literal(AzureADCSConnectionMethod.UsernamePassword)
.describe(AppConnections.CREATE(AppConnection.AzureADCS).method),
credentials: AzureADCSUsernamePasswordCredentialsSchema.describe(
AppConnections.CREATE(AppConnection.AzureADCS).credentials
)
})
]);
export const CreateAzureADCSConnectionSchema = ValidateAzureADCSConnectionCredentialsSchema.and(
GenericCreateAppConnectionFieldsSchema(AppConnection.AzureADCS)
);
export const UpdateAzureADCSConnectionSchema = z
.object({
credentials: AzureADCSUsernamePasswordCredentialsSchema.optional().describe(
AppConnections.UPDATE(AppConnection.AzureADCS).credentials
)
})
.and(GenericUpdateAppConnectionFieldsSchema(AppConnection.AzureADCS));
export const AzureADCSConnectionListItemSchema = z.object({
name: z.literal("Azure ADCS"),
app: z.literal(AppConnection.AzureADCS),
methods: z.nativeEnum(AzureADCSConnectionMethod).array()
});
@@ -0,0 +1,23 @@
import z from "zod";
import { DiscriminativePick } from "@app/lib/types";
import { AppConnection } from "../app-connection-enums";
import {
AzureADCSConnectionSchema,
CreateAzureADCSConnectionSchema,
ValidateAzureADCSConnectionCredentialsSchema
} from "./azure-adcs-connection-schemas";
export type TAzureADCSConnection = z.infer<typeof AzureADCSConnectionSchema>;
export type TAzureADCSConnectionInput = z.infer<typeof CreateAzureADCSConnectionSchema> & {
app: AppConnection.AzureADCS;
};
export type TValidateAzureADCSConnectionCredentialsSchema = typeof ValidateAzureADCSConnectionCredentialsSchema;
export type TAzureADCSConnectionConfig = DiscriminativePick<
TAzureADCSConnectionInput,
"method" | "app" | "credentials"
>;
@@ -0,0 +1,4 @@
export * from "./azure-adcs-connection-enums";
export * from "./azure-adcs-connection-fns";
export * from "./azure-adcs-connection-schemas";
export * from "./azure-adcs-connection-types";
@@ -70,7 +70,7 @@ export const validateAzureAppConfigurationConnectionCredentials = async (
tokenError = e;
} else {
throw new BadRequestError({
message: `Unable to validate connection: verify credentials`
message: "Unable to validate connection: verify credentials"
});
}
}
@@ -186,7 +186,7 @@ export const validateAzureClientSecretsConnectionCredentials = async (config: TA
tokenError = e;
} else {
throw new BadRequestError({
message: `Unable to validate connection: verify credentials`
message: "Unable to validate connection: verify credentials"
});
}
}
@@ -204,7 +204,7 @@ export const validateAzureDevOpsConnectionCredentials = async (config: TAzureDev
tokenError = e;
} else {
throw new BadRequestError({
message: `Unable to validate connection: verify credentials`
message: "Unable to validate connection: verify credentials"
});
}
}
@@ -186,7 +186,7 @@ export const validateAzureKeyVaultConnectionCredentials = async (config: TAzureK
tokenError = e;
} else {
throw new BadRequestError({
message: `Unable to validate connection: verify credentials`
message: "Unable to validate connection: verify credentials"
});
}
}
@@ -82,7 +82,7 @@ export const validateCamundaConnectionCredentials = async (appConnection: TCamun
};
} catch (e: unknown) {
throw new BadRequestError({
message: `Unable to validate connection: verify credentials`
message: "Unable to validate connection: verify credentials"
});
}
};
@@ -4,6 +4,7 @@ import { AxiosInstance, AxiosRequestConfig, AxiosResponse, HttpStatusCode, isAxi
import { createRequestClient } from "@app/lib/config/request";
import { IntegrationUrls } from "@app/services/integration-auth/integration-list";
import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors";
import { ChecklyConnectionMethod } from "./checkly-connection-constants";
import { TChecklyAccount, TChecklyConnectionConfig, TChecklyVariable } from "./checkly-connection-types";
@@ -181,6 +182,122 @@ class ChecklyPublicClient {
return res;
}
async getCheckGroups(connection: TChecklyConnectionConfig, accountId: string, limit = 50, page = 1) {
const res = await this.send<{ id: number; name: string }[]>(connection, {
accountId,
method: "GET",
url: `/v1/check-groups`,
params: { limit, page }
});
return res?.map((group) => ({
id: group.id.toString(),
name: group.name
}));
}
async getCheckGroup(connection: TChecklyConnectionConfig, accountId: string, groupId: string) {
try {
type ChecklyGroupResponse = {
id: number;
name: string;
environmentVariables: Array<{
key: string;
value: string;
locked: boolean;
}>;
};
const res = await this.send<ChecklyGroupResponse>(connection, {
accountId,
method: "GET",
url: `/v1/check-groups/${groupId}`
});
if (!res) return null;
return {
id: res.id.toString(),
name: res.name,
environmentVariables: res.environmentVariables
};
} catch (error) {
if (isAxiosError(error) && error.response?.status === HttpStatusCode.NotFound) {
return null;
}
throw error;
}
}
async updateCheckGroupEnvironmentVariables(
connection: TChecklyConnectionConfig,
accountId: string,
groupId: string,
environmentVariables: Array<{ key: string; value: string; locked?: boolean }>
) {
if (environmentVariables.length > 50) {
throw new SecretSyncError({
message: "Checkly does not support syncing more than 50 variables to Check Group",
shouldRetry: false
});
}
const apiVariables = environmentVariables.map((v) => ({
key: v.key,
value: v.value,
locked: v.locked ?? false,
secret: true
}));
const group = await this.getCheckGroup(connection, accountId, groupId);
await this.send(connection, {
accountId,
method: "PUT",
url: `/v2/check-groups/${groupId}`,
data: { name: group?.name, environmentVariables: apiVariables }
});
return this.getCheckGroup(connection, accountId, groupId);
}
async getCheckGroupEnvironmentVariables(connection: TChecklyConnectionConfig, accountId: string, groupId: string) {
const group = await this.getCheckGroup(connection, accountId, groupId);
return group?.environmentVariables || [];
}
async upsertCheckGroupEnvironmentVariables(
connection: TChecklyConnectionConfig,
accountId: string,
groupId: string,
variables: Array<{ key: string; value: string; locked?: boolean }>
) {
const existingVars = await this.getCheckGroupEnvironmentVariables(connection, accountId, groupId);
const varMap = new Map(existingVars.map((v) => [v.key, v]));
for (const newVar of variables) {
varMap.set(newVar.key, {
key: newVar.key,
value: newVar.value,
locked: newVar.locked ?? false
});
}
return this.updateCheckGroupEnvironmentVariables(connection, accountId, groupId, Array.from(varMap.values()));
}
async deleteCheckGroupEnvironmentVariable(
connection: TChecklyConnectionConfig,
accountId: string,
groupId: string,
variableKey: string
) {
const existingVars = await this.getCheckGroupEnvironmentVariables(connection, accountId, groupId);
const filteredVars = existingVars.filter((v) => v.key !== variableKey);
return this.updateCheckGroupEnvironmentVariables(connection, accountId, groupId, filteredVars);
}
}
export const ChecklyPublicAPI = new ChecklyPublicClient();
@@ -24,7 +24,19 @@ export const checklyConnectionService = (getAppConnection: TGetAppConnectionFunc
}
};
const listGroups = async (connectionId: string, accountId: string, actor: OrgServiceActor) => {
const appConnection = await getAppConnection(AppConnection.Checkly, connectionId, actor);
try {
const groups = await ChecklyPublicAPI.getCheckGroups(appConnection, accountId);
return groups!;
} catch (error) {
logger.error(error, "Failed to list accounts on Checkly");
return [];
}
};
return {
listAccounts
listAccounts,
listGroups
};
};
@@ -33,3 +33,15 @@ export type TChecklyAccount = {
name: string;
runtimeId: string;
};
export type TChecklyGroupEnvironmentVariable = {
key: string;
value: string;
locked: boolean;
};
export type TChecklyGroup = {
id: string;
name: string;
environmentVariables?: TChecklyGroupEnvironmentVariable[];
};
@@ -89,7 +89,7 @@ export const validateDatabricksConnectionCredentials = async (appConnection: TDa
};
} catch (e: unknown) {
throw new BadRequestError({
message: `Unable to validate connection: verify credentials`
message: "Unable to validate connection: verify credentials"
});
}
};
@@ -114,7 +114,7 @@ export const validateGitHubRadarConnectionCredentials = async (config: TGitHubRa
}
throw new BadRequestError({
message: `Unable to validate connection: verify credentials`
message: "Unable to validate connection: verify credentials"
});
}
@@ -1,5 +1,3 @@
import { createAppAuth } from "@octokit/auth-app";
import { request } from "@octokit/request";
import { AxiosError, AxiosRequestConfig, AxiosResponse } from "axios";
import https from "https";
import RE2 from "re2";
@@ -9,6 +7,7 @@ import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service
import { TGatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2-service";
import { getConfig } from "@app/lib/config/env";
import { request as httpRequest } from "@app/lib/config/request";
import { crypto } from "@app/lib/crypto";
import { BadRequestError, ForbiddenRequestError, InternalServerError } from "@app/lib/errors";
import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway";
import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2";
@@ -163,10 +162,13 @@ export const requestWithGitHubGateway = async <T>(
);
};
export const getGitHubAppAuthToken = async (appConnection: TGitHubConnection) => {
export const getGitHubAppAuthToken = async (
appConnection: TGitHubConnection,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
) => {
const appCfg = getConfig();
const appId = appCfg.INF_APP_CONNECTION_GITHUB_APP_ID;
const appPrivateKey = appCfg.INF_APP_CONNECTION_GITHUB_APP_PRIVATE_KEY;
let appPrivateKey = appCfg.INF_APP_CONNECTION_GITHUB_APP_PRIVATE_KEY;
if (!appId || !appPrivateKey) {
throw new InternalServerError({
@@ -174,21 +176,42 @@ export const getGitHubAppAuthToken = async (appConnection: TGitHubConnection) =>
});
}
appPrivateKey = appPrivateKey
.split("\n")
.map((line) => line.trim())
.join("\n");
if (appConnection.method !== GitHubConnectionMethod.App) {
throw new InternalServerError({ message: "Cannot generate GitHub App token for non-app connection" });
}
const appAuth = createAppAuth({
appId,
privateKey: appPrivateKey,
installationId: appConnection.credentials.installationId,
request: request.defaults({
baseUrl: `https://${await getGitHubInstanceApiUrl(appConnection)}`
})
});
const now = Math.floor(Date.now() / 1000);
const payload = {
iat: now,
exp: now + 5 * 60,
iss: appId
};
const { token } = await appAuth({ type: "installation" });
return token;
const appJwt = crypto.jwt().sign(payload, appPrivateKey, { algorithm: "RS256" });
const apiBaseUrl = await getGitHubInstanceApiUrl(appConnection);
const { installationId } = appConnection.credentials;
const response = await requestWithGitHubGateway<{ token: string; expires_at: string }>(
appConnection,
gatewayService,
{
url: `https://${apiBaseUrl}/app/installations/${installationId}/access_tokens`,
method: "POST",
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${appJwt}`,
"X-GitHub-Api-Version": "2022-11-28"
}
}
);
return response.data.token;
};
const parseGitHubLinkHeader = (linkHeader: string | undefined): Record<string, string> => {
@@ -224,7 +247,9 @@ export const makePaginatedGitHubRequest = async <T, R = T[]>(
const { credentials, method } = appConnection;
const token =
method === GitHubConnectionMethod.OAuth ? credentials.accessToken : await getGitHubAppAuthToken(appConnection);
method === GitHubConnectionMethod.OAuth
? credentials.accessToken
: await getGitHubAppAuthToken(appConnection, gatewayService);
const baseUrl = `https://${await getGitHubInstanceApiUrl(appConnection)}${path}`;
const initialUrlObj = new URL(baseUrl);
@@ -494,7 +519,7 @@ export const validateGitHubConnectionCredentials = async (
}
throw new BadRequestError({
message: `Unable to validate connection: verify credentials`
message: "Unable to validate connection: verify credentials"
});
}
@@ -1,18 +1,18 @@
import { AxiosError } from "axios";
import { AxiosError, AxiosRequestConfig, AxiosResponse } from "axios";
import https from "https";
import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic-secret-fns";
import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
import { request } from "@app/lib/config/request";
import { BadRequestError } from "@app/lib/errors";
import { removeTrailingSlash } from "@app/lib/fn";
import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway";
import { logger } from "@app/lib/logger";
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { HCVaultConnectionMethod } from "./hc-vault-connection-enums";
import {
THCVaultConnection,
THCVaultConnectionConfig,
THCVaultMountResponse,
TValidateHCVaultConnectionCredentials
} from "./hc-vault-connection-types";
import { THCVaultConnection, THCVaultConnectionConfig, THCVaultMountResponse } from "./hc-vault-connection-types";
export const getHCVaultInstanceUrl = async (config: THCVaultConnectionConfig) => {
const instanceUrl = removeTrailingSlash(config.credentials.instanceUrl);
@@ -37,7 +37,78 @@ type TokenRespData = {
};
};
export const getHCVaultAccessToken = async (connection: TValidateHCVaultConnectionCredentials) => {
export const requestWithHCVaultGateway = async <T>(
appConnection: { gatewayId?: string | null },
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">,
requestConfig: AxiosRequestConfig
): Promise<AxiosResponse<T>> => {
const { gatewayId } = appConnection;
// If gateway isn't set up, don't proxy request
if (!gatewayId) {
return request.request(requestConfig);
}
const url = new URL(requestConfig.url as string);
await blockLocalAndPrivateIpAddresses(url.toString());
const [targetHost] = await verifyHostInputValidity(url.hostname, true);
const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(gatewayId);
const [relayHost, relayPort] = relayDetails.relayAddress.split(":");
return withGatewayProxy(
async (proxyPort) => {
const httpsAgent = new https.Agent({
servername: targetHost
});
url.protocol = "https:";
url.host = `localhost:${proxyPort}`;
const finalRequestConfig: AxiosRequestConfig = {
...requestConfig,
url: url.toString(),
httpsAgent,
headers: {
...requestConfig.headers,
Host: targetHost
}
};
try {
return await request.request(finalRequestConfig);
} catch (error) {
if (error instanceof AxiosError) {
logger.error(
{ message: error.message, data: (error.response as undefined | { data: unknown })?.data },
"Error during HashiCorp Vault gateway request:"
);
}
throw error;
}
},
{
protocol: GatewayProxyProtocol.Tcp,
targetHost,
targetPort: url.port ? Number(url.port) : 8200, // 8200 is the default port for Vault self-hosted/dedicated
relayHost,
relayPort: Number(relayPort),
identityId: relayDetails.identityId,
orgId: relayDetails.orgId,
tlsOptions: {
ca: relayDetails.certChain,
cert: relayDetails.certificate,
key: relayDetails.privateKey.toString()
}
}
);
};
export const getHCVaultAccessToken = async (
connection: THCVaultConnection,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
) => {
// Return access token directly if not using AppRole method
if (connection.method !== HCVaultConnectionMethod.AppRole) {
return connection.credentials.accessToken;
@@ -46,16 +117,16 @@ export const getHCVaultAccessToken = async (connection: TValidateHCVaultConnecti
// Generate temporary token for AppRole method
try {
const { instanceUrl, roleId, secretId } = connection.credentials;
const tokenResp = await request.post<TokenRespData>(
`${removeTrailingSlash(instanceUrl)}/v1/auth/approle/login`,
{ role_id: roleId, secret_id: secretId },
{
headers: {
"Content-Type": "application/json",
...(connection.credentials.namespace ? { "X-Vault-Namespace": connection.credentials.namespace } : {})
}
}
);
const tokenResp = await requestWithHCVaultGateway<TokenRespData>(connection, gatewayService, {
url: `${removeTrailingSlash(instanceUrl)}/v1/auth/approle/login`,
method: "POST",
headers: {
"Content-Type": "application/json",
...(connection.credentials.namespace ? { "X-Vault-Namespace": connection.credentials.namespace } : {})
},
data: { role_id: roleId, secret_id: secretId }
});
if (tokenResp.status !== 200) {
throw new BadRequestError({
@@ -71,38 +142,55 @@ export const getHCVaultAccessToken = async (connection: TValidateHCVaultConnecti
}
};
export const validateHCVaultConnectionCredentials = async (config: THCVaultConnectionConfig) => {
const instanceUrl = await getHCVaultInstanceUrl(config);
export const validateHCVaultConnectionCredentials = async (
connection: THCVaultConnection,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
) => {
const instanceUrl = await getHCVaultInstanceUrl(connection);
try {
const accessToken = await getHCVaultAccessToken(config);
const accessToken = await getHCVaultAccessToken(connection, gatewayService);
// Verify token
await request.get(`${instanceUrl}/v1/auth/token/lookup-self`, {
await requestWithHCVaultGateway(connection, gatewayService, {
url: `${instanceUrl}/v1/auth/token/lookup-self`,
method: "GET",
headers: { "X-Vault-Token": accessToken }
});
return config.credentials;
return connection.credentials;
} catch (error: unknown) {
logger.error(error, "Unable to verify HC Vault connection");
if (error instanceof AxiosError) {
throw new BadRequestError({
message: `Failed to validate credentials: ${error.message || "Unknown error"}`
});
}
if (error instanceof BadRequestError) {
throw error;
}
throw new BadRequestError({
message: "Unable to validate connection: verify credentials"
});
}
};
export const listHCVaultMounts = async (appConnection: THCVaultConnection) => {
const instanceUrl = await getHCVaultInstanceUrl(appConnection);
const accessToken = await getHCVaultAccessToken(appConnection);
export const listHCVaultMounts = async (
connection: THCVaultConnection,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
) => {
const instanceUrl = await getHCVaultInstanceUrl(connection);
const accessToken = await getHCVaultAccessToken(connection, gatewayService);
const { data } = await request.get<THCVaultMountResponse>(`${instanceUrl}/v1/sys/mounts`, {
const { data } = await requestWithHCVaultGateway<THCVaultMountResponse>(connection, gatewayService, {
url: `${instanceUrl}/v1/sys/mounts`,
method: "GET",
headers: {
"X-Vault-Token": accessToken,
...(appConnection.credentials.namespace ? { "X-Vault-Namespace": appConnection.credentials.namespace } : {})
...(connection.credentials.namespace ? { "X-Vault-Namespace": connection.credentials.namespace } : {})
}
});
@@ -55,11 +55,18 @@ export const HCVaultConnectionSchema = z.intersection(
export const SanitizedHCVaultConnectionSchema = z.discriminatedUnion("method", [
BaseHCVaultConnectionSchema.extend({
method: z.literal(HCVaultConnectionMethod.AccessToken),
credentials: HCVaultConnectionAccessTokenCredentialsSchema.pick({})
credentials: HCVaultConnectionAccessTokenCredentialsSchema.pick({
namespace: true,
instanceUrl: true
})
}),
BaseHCVaultConnectionSchema.extend({
method: z.literal(HCVaultConnectionMethod.AppRole),
credentials: HCVaultConnectionAppRoleCredentialsSchema.pick({})
credentials: HCVaultConnectionAppRoleCredentialsSchema.pick({
namespace: true,
instanceUrl: true,
roleId: true
})
})
]);
@@ -81,7 +88,7 @@ export const ValidateHCVaultConnectionCredentialsSchema = z.discriminatedUnion("
]);
export const CreateHCVaultConnectionSchema = ValidateHCVaultConnectionCredentialsSchema.and(
GenericCreateAppConnectionFieldsSchema(AppConnection.HCVault)
GenericCreateAppConnectionFieldsSchema(AppConnection.HCVault, { supportsGateways: true })
);
export const UpdateHCVaultConnectionSchema = z
@@ -91,7 +98,7 @@ export const UpdateHCVaultConnectionSchema = z
.optional()
.describe(AppConnections.UPDATE(AppConnection.HCVault).credentials)
})
.and(GenericUpdateAppConnectionFieldsSchema(AppConnection.HCVault));
.and(GenericUpdateAppConnectionFieldsSchema(AppConnection.HCVault, { supportsGateways: true }));
export const HCVaultConnectionListItemSchema = z.object({
name: z.literal("HCVault"),
@@ -1,3 +1,4 @@
import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
import { logger } from "@app/lib/logger";
import { OrgServiceActor } from "@app/lib/types";
@@ -11,12 +12,15 @@ type TGetAppConnectionFunc = (
actor: OrgServiceActor
) => Promise<THCVaultConnection>;
export const hcVaultConnectionService = (getAppConnection: TGetAppConnectionFunc) => {
export const hcVaultConnectionService = (
getAppConnection: TGetAppConnectionFunc,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
) => {
const listMounts = async (connectionId: string, actor: OrgServiceActor) => {
const appConnection = await getAppConnection(AppConnection.HCVault, connectionId, actor);
try {
const mounts = await listHCVaultMounts(appConnection);
const mounts = await listHCVaultMounts(appConnection, gatewayService);
return mounts;
} catch (error) {
logger.error(error, "Failed to establish connection with Hashicorp Vault");