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");
@@ -75,7 +75,7 @@ export const getTokenConfig = (tokenType: TokenType) => {
};
export const tokenServiceFactory = ({ tokenDAL, userDAL, orgMembershipDAL }: TAuthTokenServiceFactoryDep) => {
const createTokenForUser = async ({ type, userId, orgId }: TCreateTokenForUserDTO) => {
const createTokenForUser = async ({ type, userId, orgId, aliasId }: TCreateTokenForUserDTO) => {
const { token, ...tkCfg } = getTokenConfig(type);
const appCfg = getConfig();
const tokenHash = await crypto.hashing().createHash(token, appCfg.SALT_ROUNDS);
@@ -88,7 +88,8 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL, orgMembershipDAL }: TAu
type,
userId,
orgId,
triesLeft: tkCfg?.triesLeft
triesLeft: tkCfg?.triesLeft,
aliasId
},
tx
);
@@ -14,6 +14,7 @@ export type TCreateTokenForUserDTO = {
type: TokenType;
userId: string;
orgId?: string;
aliasId?: string;
};
export type TCreateOrgInviteTokenDTO = {
@@ -459,6 +459,18 @@ export const authLoginServiceFactory = ({
});
}
// Check if authEnforced is true and the current auth method is not an enforced method
if (
selectedOrg.authEnforced &&
!isAuthMethodSaml(decodedToken.authMethod) &&
decodedToken.authMethod !== AuthMethod.OIDC &&
!(selectedOrg.bypassOrgAuthEnabled && selectedOrgMembership.userRole === OrgMembershipRole.Admin)
) {
throw new BadRequestError({
message: "Login with the auth method required by your organization."
});
}
if (selectedOrg.googleSsoAuthEnforced && decodedToken.authMethod !== AuthMethod.GOOGLE) {
const canBypass = selectedOrg.bypassOrgAuthEnabled && selectedOrgMembership.userRole === OrgMembershipRole.Admin;
@@ -64,6 +64,8 @@ type DBConfigurationColumn = {
directoryUrl: string;
accountEmail: string;
hostedZoneId: string;
eabKid?: string;
eabHmacKey?: string;
};
export const castDbEntryToAcmeCertificateAuthority = (
@@ -89,7 +91,9 @@ export const castDbEntryToAcmeCertificateAuthority = (
hostedZoneId: dbConfigurationCol.hostedZoneId
},
directoryUrl: dbConfigurationCol.directoryUrl,
accountEmail: dbConfigurationCol.accountEmail
accountEmail: dbConfigurationCol.accountEmail,
eabKid: dbConfigurationCol.eabKid,
eabHmacKey: dbConfigurationCol.eabHmacKey
},
status: ca.status as CaStatus
};
@@ -128,7 +132,7 @@ export const AcmeCertificateAuthorityFns = ({
});
}
const { dnsAppConnectionId, directoryUrl, accountEmail, dnsProviderConfig } = configuration;
const { dnsAppConnectionId, directoryUrl, accountEmail, dnsProviderConfig, eabKid, eabHmacKey } = configuration;
const appConnection = await appConnectionDAL.findById(dnsAppConnectionId);
if (!appConnection) {
@@ -171,7 +175,9 @@ export const AcmeCertificateAuthorityFns = ({
directoryUrl,
accountEmail,
dnsProvider: dnsProviderConfig.provider,
hostedZoneId: dnsProviderConfig.hostedZoneId
hostedZoneId: dnsProviderConfig.hostedZoneId,
eabKid,
eabHmacKey
}
},
tx
@@ -214,7 +220,7 @@ export const AcmeCertificateAuthorityFns = ({
}) => {
const updatedCa = await certificateAuthorityDAL.transaction(async (tx) => {
if (configuration) {
const { dnsAppConnectionId, directoryUrl, accountEmail, dnsProviderConfig } = configuration;
const { dnsAppConnectionId, directoryUrl, accountEmail, dnsProviderConfig, eabKid, eabHmacKey } = configuration;
const appConnection = await appConnectionDAL.findById(dnsAppConnectionId);
if (!appConnection) {
@@ -254,7 +260,9 @@ export const AcmeCertificateAuthorityFns = ({
directoryUrl,
accountEmail,
dnsProvider: dnsProviderConfig.provider,
hostedZoneId: dnsProviderConfig.hostedZoneId
hostedZoneId: dnsProviderConfig.hostedZoneId,
eabKid,
eabHmacKey
}
},
tx
@@ -354,10 +362,19 @@ export const AcmeCertificateAuthorityFns = ({
await blockLocalAndPrivateIpAddresses(acmeCa.configuration.directoryUrl);
const acmeClient = new acme.Client({
const acmeClientOptions: acme.ClientOptions = {
directoryUrl: acmeCa.configuration.directoryUrl,
accountKey
});
};
if (acmeCa.configuration.eabKid && acmeCa.configuration.eabHmacKey) {
acmeClientOptions.externalAccountBinding = {
kid: acmeCa.configuration.eabKid,
hmacKey: acmeCa.configuration.eabHmacKey
};
}
const acmeClient = new acme.Client(acmeClientOptions);
const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048);
@@ -18,7 +18,9 @@ export const AcmeCertificateAuthorityConfigurationSchema = z.object({
hostedZoneId: z.string().trim().min(1).describe(CertificateAuthorities.CONFIGURATIONS.ACME.hostedZoneId)
}),
directoryUrl: z.string().url().trim().min(1).describe(CertificateAuthorities.CONFIGURATIONS.ACME.directoryUrl),
accountEmail: z.string().trim().min(1).describe(CertificateAuthorities.CONFIGURATIONS.ACME.accountEmail)
accountEmail: z.string().trim().min(1).describe(CertificateAuthorities.CONFIGURATIONS.ACME.accountEmail),
eabKid: z.string().trim().max(64).optional().describe(CertificateAuthorities.CONFIGURATIONS.ACME.eabKid),
eabHmacKey: z.string().trim().max(512).optional().describe(CertificateAuthorities.CONFIGURATIONS.ACME.eabHmacKey)
});
export const AcmeCertificateAuthorityCredentialsSchema = z.object({
@@ -0,0 +1,29 @@
import { z } from "zod";
import { CaType } from "../certificate-authority-enums";
import {
BaseCertificateAuthoritySchema,
GenericCreateCertificateAuthorityFieldsSchema,
GenericUpdateCertificateAuthorityFieldsSchema
} from "../certificate-authority-schemas";
export const AzureAdCsCertificateAuthorityConfigurationSchema = z.object({
azureAdcsConnectionId: z.string().uuid().trim().describe("Azure ADCS Connection ID")
});
export const AzureAdCsCertificateAuthoritySchema = BaseCertificateAuthoritySchema.extend({
type: z.literal(CaType.AZURE_AD_CS),
configuration: AzureAdCsCertificateAuthorityConfigurationSchema
});
export const CreateAzureAdCsCertificateAuthoritySchema = GenericCreateCertificateAuthorityFieldsSchema(
CaType.AZURE_AD_CS
).extend({
configuration: AzureAdCsCertificateAuthorityConfigurationSchema
});
export const UpdateAzureAdCsCertificateAuthoritySchema = GenericUpdateCertificateAuthorityFieldsSchema(
CaType.AZURE_AD_CS
).extend({
configuration: AzureAdCsCertificateAuthorityConfigurationSchema.optional()
});
@@ -0,0 +1,13 @@
import { z } from "zod";
import {
AzureAdCsCertificateAuthoritySchema,
CreateAzureAdCsCertificateAuthoritySchema,
UpdateAzureAdCsCertificateAuthoritySchema
} from "./azure-ad-cs-certificate-authority-schemas";
export type TAzureAdCsCertificateAuthority = z.infer<typeof AzureAdCsCertificateAuthoritySchema>;
export type TCreateAzureAdCsCertificateAuthorityDTO = z.infer<typeof CreateAzureAdCsCertificateAuthoritySchema>;
export type TUpdateAzureAdCsCertificateAuthorityDTO = z.infer<typeof UpdateAzureAdCsCertificateAuthoritySchema>;
@@ -1,6 +1,7 @@
export enum CaType {
INTERNAL = "internal",
ACME = "acme"
ACME = "acme",
AZURE_AD_CS = "azure-ad-cs"
}
export enum InternalCaType {
@@ -17,3 +18,9 @@ export enum CaStatus {
export enum CaRenewalType {
EXISTING = "existing"
}
export enum CaCapability {
ISSUE_CERTIFICATES = "issue-certificates",
REVOKE_CERTIFICATES = "revoke-certificates",
RENEW_CERTIFICATES = "renew-certificates"
}
@@ -1,6 +1,29 @@
import { CaType } from "./certificate-authority-enums";
import { CaCapability, CaType } from "./certificate-authority-enums";
export const CERTIFICATE_AUTHORITIES_TYPE_MAP: Record<CaType, string> = {
[CaType.INTERNAL]: "Internal",
[CaType.ACME]: "ACME"
[CaType.ACME]: "ACME",
[CaType.AZURE_AD_CS]: "Azure AD Certificate Service"
};
export const CERTIFICATE_AUTHORITIES_CAPABILITIES_MAP: Record<CaType, CaCapability[]> = {
[CaType.INTERNAL]: [
CaCapability.ISSUE_CERTIFICATES,
CaCapability.REVOKE_CERTIFICATES,
CaCapability.RENEW_CERTIFICATES
],
[CaType.ACME]: [CaCapability.ISSUE_CERTIFICATES, CaCapability.REVOKE_CERTIFICATES, CaCapability.RENEW_CERTIFICATES],
[CaType.AZURE_AD_CS]: [
CaCapability.ISSUE_CERTIFICATES,
CaCapability.RENEW_CERTIFICATES
// Note: REVOKE_CERTIFICATES intentionally omitted - not supported by ADCS connector
]
};
/**
* Check if a certificate authority type supports a specific capability
*/
export const caSupportsCapability = (caType: CaType, capability: CaCapability): boolean => {
const capabilities = CERTIFICATE_AUTHORITIES_CAPABILITIES_MAP[caType] || [];
return capabilities.includes(capability);
};
@@ -21,6 +21,7 @@ import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-
import { TPkiSubscriberDALFactory } from "../pki-subscriber/pki-subscriber-dal";
import { SubscriberOperationStatus } from "../pki-subscriber/pki-subscriber-types";
import { AcmeCertificateAuthorityFns } from "./acme/acme-certificate-authority-fns";
import { AzureAdCsCertificateAuthorityFns } from "./azure-ad-cs/azure-ad-cs-certificate-authority-fns";
import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal";
import { CaType } from "./certificate-authority-enums";
import { keyAlgorithmToAlgCfg } from "./certificate-authority-fns";
@@ -33,7 +34,7 @@ import {
type TCertificateAuthorityQueueFactoryDep = {
certificateAuthorityDAL: TCertificateAuthorityDALFactory;
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "update">;
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "update" | "updateById">;
appConnectionService: Pick<TAppConnectionServiceFactory, "connectAppConnectionById">;
externalCertificateAuthorityDAL: Pick<TExternalCertificateAuthorityDALFactory, "create" | "update">;
keyStore: Pick<TKeyStoreFactory, "acquireLock" | "setItemWithExpiry" | "getItem">;
@@ -82,6 +83,19 @@ export const certificateAuthorityQueueFactory = ({
projectDAL
});
const azureAdCsFns = AzureAdCsCertificateAuthorityFns({
appConnectionDAL,
appConnectionService,
certificateAuthorityDAL,
externalCertificateAuthorityDAL,
certificateDAL,
certificateBodyDAL,
certificateSecretDAL,
kmsService,
pkiSubscriberDAL,
projectDAL
});
// TODO 1: auto-periodic rotation
// TODO 2: manual rotation
@@ -158,6 +172,13 @@ export const certificateAuthorityQueueFactory = ({
lastOperationMessage: "Certificate ordered successfully",
lastOperationAt: new Date()
});
} else if (caType === CaType.AZURE_AD_CS) {
await azureAdCsFns.orderSubscriberCertificate(subscriberId);
await pkiSubscriberDAL.updateById(subscriberId, {
lastOperationStatus: SubscriberOperationStatus.SUCCESS,
lastOperationMessage: "Certificate ordered successfully",
lastOperationAt: new Date()
});
}
} catch (e: unknown) {
if (e instanceof Error) {
@@ -22,6 +22,14 @@ import {
TCreateAcmeCertificateAuthorityDTO,
TUpdateAcmeCertificateAuthorityDTO
} from "./acme/acme-certificate-authority-types";
import {
AzureAdCsCertificateAuthorityFns,
castDbEntryToAzureAdCsCertificateAuthority
} from "./azure-ad-cs/azure-ad-cs-certificate-authority-fns";
import {
TCreateAzureAdCsCertificateAuthorityDTO,
TUpdateAzureAdCsCertificateAuthorityDTO
} from "./azure-ad-cs/azure-ad-cs-certificate-authority-types";
import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal";
import { CaType } from "./certificate-authority-enums";
import {
@@ -34,7 +42,7 @@ import { TInternalCertificateAuthorityServiceFactory } from "./internal/internal
import { TCreateInternalCertificateAuthorityDTO } from "./internal/internal-certificate-authority-types";
type TCertificateAuthorityServiceFactoryDep = {
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "update">;
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "update" | "updateById">;
appConnectionService: Pick<TAppConnectionServiceFactory, "connectAppConnectionById">;
certificateAuthorityDAL: Pick<
TCertificateAuthorityDALFactory,
@@ -91,6 +99,19 @@ export const certificateAuthorityServiceFactory = ({
projectDAL
});
const azureAdCsFns = AzureAdCsCertificateAuthorityFns({
appConnectionDAL,
appConnectionService,
certificateAuthorityDAL,
externalCertificateAuthorityDAL,
certificateDAL,
certificateBodyDAL,
certificateSecretDAL,
kmsService,
pkiSubscriberDAL,
projectDAL
});
const createCertificateAuthority = async (
{ type, projectId, name, enableDirectIssuance, configuration, status }: TCreateCertificateAuthorityDTO,
actor: OrgServiceActor
@@ -146,6 +167,17 @@ export const certificateAuthorityServiceFactory = ({
});
}
if (type === CaType.AZURE_AD_CS) {
return azureAdCsFns.createCertificateAuthority({
name,
projectId,
configuration: configuration as TCreateAzureAdCsCertificateAuthorityDTO["configuration"],
enableDirectIssuance,
status,
actor
});
}
throw new BadRequestError({ message: "Invalid certificate authority type" });
};
@@ -205,6 +237,10 @@ export const certificateAuthorityServiceFactory = ({
return castDbEntryToAcmeCertificateAuthority(certificateAuthority);
}
if (type === CaType.AZURE_AD_CS) {
return castDbEntryToAzureAdCsCertificateAuthority(certificateAuthority);
}
throw new BadRequestError({ message: "Invalid certificate authority type" });
};
@@ -249,6 +285,10 @@ export const certificateAuthorityServiceFactory = ({
return acmeFns.listCertificateAuthorities({ projectId });
}
if (type === CaType.AZURE_AD_CS) {
return azureAdCsFns.listCertificateAuthorities({ projectId });
}
throw new BadRequestError({ message: "Invalid certificate authority type" });
};
@@ -323,6 +363,17 @@ export const certificateAuthorityServiceFactory = ({
});
}
if (type === CaType.AZURE_AD_CS) {
return azureAdCsFns.updateCertificateAuthority({
id: certificateAuthority.id,
configuration: configuration as TUpdateAzureAdCsCertificateAuthorityDTO["configuration"],
enableDirectIssuance,
actor,
status,
name
});
}
throw new BadRequestError({ message: "Invalid certificate authority type" });
};
@@ -384,14 +435,54 @@ export const certificateAuthorityServiceFactory = ({
return castDbEntryToAcmeCertificateAuthority(certificateAuthority);
}
if (type === CaType.AZURE_AD_CS) {
return castDbEntryToAzureAdCsCertificateAuthority(certificateAuthority);
}
throw new BadRequestError({ message: "Invalid certificate authority type" });
};
const getAzureAdcsTemplates = async ({
caId,
projectId,
actor,
actorId,
actorAuthMethod,
actorOrgId
}: {
caId: string;
projectId: string;
actor: OrgServiceActor["type"];
actorId: string;
actorAuthMethod: OrgServiceActor["authMethod"];
actorOrgId?: string;
}) => {
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionActions.Read,
ProjectPermissionSub.CertificateAuthorities
);
return azureAdCsFns.getTemplates({
caId,
projectId
});
};
return {
createCertificateAuthority,
findCertificateAuthorityByNameAndProjectId,
listCertificateAuthoritiesByProjectId,
updateCertificateAuthority,
deleteCertificateAuthority
deleteCertificateAuthority,
getAzureAdcsTemplates
};
};
@@ -1,13 +1,23 @@
import { TAcmeCertificateAuthority, TAcmeCertificateAuthorityInput } from "./acme/acme-certificate-authority-types";
import {
TAzureAdCsCertificateAuthority,
TCreateAzureAdCsCertificateAuthorityDTO
} from "./azure-ad-cs/azure-ad-cs-certificate-authority-types";
import { CaType } from "./certificate-authority-enums";
import {
TInternalCertificateAuthority,
TInternalCertificateAuthorityInput
} from "./internal/internal-certificate-authority-types";
export type TCertificateAuthority = TInternalCertificateAuthority | TAcmeCertificateAuthority;
export type TCertificateAuthority =
| TInternalCertificateAuthority
| TAcmeCertificateAuthority
| TAzureAdCsCertificateAuthority;
export type TCertificateAuthorityInput = TInternalCertificateAuthorityInput | TAcmeCertificateAuthorityInput;
export type TCertificateAuthorityInput =
| TInternalCertificateAuthorityInput
| TAcmeCertificateAuthorityInput
| TCreateAzureAdCsCertificateAuthorityDTO;
export type TCreateCertificateAuthorityDTO = Omit<TCertificateAuthority, "id">;
@@ -36,12 +36,18 @@ import { validateAndMapAltNameType } from "../certificate-authority-validators";
import { TIssueCertWithTemplateDTO } from "./internal-certificate-authority-types";
type TInternalCertificateAuthorityFnsDeps = {
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa" | "findById">;
certificateAuthorityDAL: Pick<
TCertificateAuthorityDALFactory,
"findByIdWithAssociatedCa" | "findById" | "create" | "transaction" | "updateById" | "findWithAssociatedCa"
>;
certificateAuthorityCertDAL: Pick<TCertificateAuthorityCertDALFactory, "findById">;
certificateAuthoritySecretDAL: Pick<TCertificateAuthoritySecretDALFactory, "findOne">;
certificateAuthorityCrlDAL: Pick<TCertificateAuthorityCrlDALFactory, "findOne">;
projectDAL: Pick<TProjectDALFactory, "findById" | "transaction" | "findOne" | "updateById">;
kmsService: Pick<TKmsServiceFactory, "decryptWithKmsKey" | "encryptWithKmsKey" | "generateKmsKey">;
kmsService: Pick<
TKmsServiceFactory,
"decryptWithKmsKey" | "encryptWithKmsKey" | "generateKmsKey" | "createCipherPairWithDataKey"
>;
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction">;
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "create">;
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "create">;
@@ -14,6 +14,8 @@ import { TCertificateBodyDALFactory } from "@app/services/certificate/certificat
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
import { TCertificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal";
import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal";
import { CaCapability, CaType } from "@app/services/certificate-authority/certificate-authority-enums";
import { caSupportsCapability } from "@app/services/certificate-authority/certificate-authority-maps";
import { TCertificateAuthoritySecretDALFactory } from "@app/services/certificate-authority/certificate-authority-secret-dal";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TPkiCollectionDALFactory } from "@app/services/pki-collection/pki-collection-dal";
@@ -184,9 +186,11 @@ export const certificateServiceFactory = ({
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(cert.caId);
if (ca.externalCa?.id) {
// Check if the CA type supports revocation
const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL;
if (!caSupportsCapability(caType, CaCapability.REVOKE_CERTIFICATES)) {
throw new BadRequestError({
message: "Cannot revoke external certificates"
message: "Certificate revocation is not supported by this certificate authority type"
});
}
@@ -218,18 +222,37 @@ export const certificateServiceFactory = ({
}
);
// rebuild CRL (TODO: move to interval-based cron job)
await rebuildCaCrl({
caId: ca.id,
certificateAuthorityDAL,
certificateAuthorityCrlDAL,
certificateAuthoritySecretDAL,
projectDAL,
certificateDAL,
kmsService
});
// Note: External CA revocation handling would go here for supported CA types
// Currently, only internal CAs and ACME CAs support revocation
return { revokedAt, cert, ca: expandInternalCa(ca) };
// rebuild CRL (TODO: move to interval-based cron job)
// Only rebuild CRL for internal CAs - external CAs manage their own CRLs
if (!ca.externalCa?.id) {
await rebuildCaCrl({
caId: ca.id,
certificateAuthorityDAL,
certificateAuthorityCrlDAL,
certificateAuthoritySecretDAL,
projectDAL,
certificateDAL,
kmsService
});
}
// Return appropriate CA format based on CA type
const caResult = ca.externalCa?.id
? {
id: ca.id,
name: ca.name,
projectId: ca.projectId,
status: ca.status,
enableDirectIssuance: ca.enableDirectIssuance,
type: ca.externalCa.type,
externalCa: ca.externalCa
}
: expandInternalCa(ca);
return { revokedAt, cert, ca: caResult };
};
/**
@@ -1,12 +1,21 @@
import https from "node:https";
import axios, { AxiosInstance } from "axios";
import { v4 as uuidv4 } from "uuid";
import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
import { BadRequestError } from "@app/lib/errors";
import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway";
import { logger } from "@app/lib/logger";
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator";
import { InfisicalImportData, VaultMappingType } from "../external-migration-types";
enum KvVersion {
V1 = "1",
V2 = "2"
}
type VaultData = {
namespace: string;
mount: string;
@@ -14,7 +23,42 @@ type VaultData = {
secretData: Record<string, string>;
};
const vaultFactory = () => {
const vaultFactory = (gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">) => {
const $gatewayProxyWrapper = async <T>(
inputs: {
gatewayId: string;
targetHost?: string;
targetPort?: number;
},
gatewayCallback: (host: string, port: number, httpsAgent?: https.Agent) => Promise<T>
): Promise<T> => {
const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(inputs.gatewayId);
const [relayHost, relayPort] = relayDetails.relayAddress.split(":");
const callbackResult = await withGatewayProxy(
async (port, httpsAgent) => {
const res = await gatewayCallback("http://localhost", port, httpsAgent);
return res;
},
{
protocol: GatewayProxyProtocol.Http,
targetHost: inputs.targetHost,
targetPort: inputs.targetPort,
relayHost,
relayPort: Number(relayPort),
identityId: relayDetails.identityId,
orgId: relayDetails.orgId,
tlsOptions: {
ca: relayDetails.certChain,
cert: relayDetails.certificate,
key: relayDetails.privateKey.toString()
}
}
);
return callbackResult;
};
const getMounts = async (request: AxiosInstance) => {
const response = await request
.get<{
@@ -31,11 +75,24 @@ const vaultFactory = () => {
const getPaths = async (
request: AxiosInstance,
{ mountPath, secretPath = "" }: { mountPath: string; secretPath?: string }
{ mountPath, secretPath = "" }: { mountPath: string; secretPath?: string },
kvVersion: KvVersion
) => {
try {
// For KV v2: /v1/{mount}/metadata/{path}?list=true
const path = secretPath ? `${mountPath}/metadata/${secretPath}` : `${mountPath}/metadata`;
if (kvVersion === KvVersion.V2) {
// For KV v2: /v1/{mount}/metadata/{path}?list=true
const path = secretPath ? `${mountPath}/metadata/${secretPath}` : `${mountPath}/metadata`;
const response = await request.get<{
data: {
keys: string[];
};
}>(`/v1/${path}?list=true`);
return response.data.data.keys;
}
// kv version v1: /v1/{mount}?list=true
const path = secretPath ? `${mountPath}/${secretPath}` : mountPath;
const response = await request.get<{
data: {
keys: string[];
@@ -56,21 +113,42 @@ const vaultFactory = () => {
const getSecrets = async (
request: AxiosInstance,
{ mountPath, secretPath }: { mountPath: string; secretPath: string }
{ mountPath, secretPath }: { mountPath: string; secretPath: string },
kvVersion: KvVersion
) => {
// For KV v2: /v1/{mount}/data/{path}
if (kvVersion === KvVersion.V2) {
// For KV v2: /v1/{mount}/data/{path}
const response = await request
.get<{
data: {
data: Record<string, string>; // KV v2 has nested data structure
metadata: {
created_time: string;
deletion_time: string;
destroyed: boolean;
version: number;
};
};
}>(`/v1/${mountPath}/data/${secretPath}`)
.catch((err) => {
if (axios.isAxiosError(err)) {
logger.error(err.response?.data, "External migration: Failed to get Vault secret");
}
throw err;
});
return response.data.data.data;
}
// kv version v1
const response = await request
.get<{
data: {
data: Record<string, string>; // KV v2 has nested data structure
metadata: {
created_time: string;
deletion_time: string;
destroyed: boolean;
version: number;
};
};
}>(`/v1/${mountPath}/data/${secretPath}`)
data: Record<string, string>; // KV v1 has flat data structure
lease_duration: number;
lease_id: string;
renewable: boolean;
}>(`/v1/${mountPath}/${secretPath}`)
.catch((err) => {
if (axios.isAxiosError(err)) {
logger.error(err.response?.data, "External migration: Failed to get Vault secret");
@@ -78,7 +156,7 @@ const vaultFactory = () => {
throw err;
});
return response.data.data.data;
return response.data.data;
};
// helper function to check if a mount is KV v2 (will be useful if we add support for Vault KV v1)
@@ -89,9 +167,10 @@ const vaultFactory = () => {
const recursivelyGetAllPaths = async (
request: AxiosInstance,
mountPath: string,
kvVersion: KvVersion,
currentPath: string = ""
): Promise<string[]> => {
const paths = await getPaths(request, { mountPath, secretPath: currentPath });
const paths = await getPaths(request, { mountPath, secretPath: currentPath }, kvVersion);
if (paths === null || paths.length === 0) {
return [];
@@ -105,7 +184,7 @@ const vaultFactory = () => {
if (path.endsWith("/")) {
// it's a folder so we recurse into it
const subSecrets = await recursivelyGetAllPaths(request, mountPath, fullItemPath);
const subSecrets = await recursivelyGetAllPaths(request, mountPath, kvVersion, fullItemPath);
allSecrets.push(...subSecrets);
} else {
// it's a secret so we add it to our results
@@ -119,60 +198,93 @@ const vaultFactory = () => {
async function collectVaultData({
baseUrl,
namespace,
accessToken
accessToken,
gatewayId
}: {
baseUrl: string;
namespace?: string;
accessToken: string;
gatewayId?: string;
}): Promise<VaultData[]> {
const request = axios.create({
baseURL: baseUrl,
headers: {
"X-Vault-Token": accessToken,
...(namespace ? { "X-Vault-Namespace": namespace } : {})
const getData = async (host: string, port?: number, httpsAgent?: https.Agent) => {
const allData: VaultData[] = [];
const request = axios.create({
baseURL: port ? `${host}:${port}` : host,
headers: {
"X-Vault-Token": accessToken,
...(namespace ? { "X-Vault-Namespace": namespace } : {})
},
httpsAgent
});
// Get all mounts in this namespace
const mounts = await getMounts(request);
for (const mount of Object.keys(mounts)) {
if (!mount.endsWith("/")) {
delete mounts[mount];
}
}
});
const allData: VaultData[] = [];
for await (const [mountPath, mountInfo] of Object.entries(mounts)) {
// skip non-KV mounts
if (!mountInfo.type.startsWith("kv")) {
// eslint-disable-next-line no-continue
continue;
}
// Get all mounts in this namespace
const mounts = await getMounts(request);
const kvVersion = mountInfo.options?.version === "2" ? KvVersion.V2 : KvVersion.V1;
for (const mount of Object.keys(mounts)) {
if (!mount.endsWith("/")) {
delete mounts[mount];
// get all paths in this mount
const paths = await recursivelyGetAllPaths(request, `${mountPath.replace(/\/$/, "")}`, kvVersion);
const cleanMountPath = mountPath.replace(/\/$/, "");
for await (const secretPath of paths) {
// get the actual secret data
const secretData = await getSecrets(
request,
{
mountPath: cleanMountPath,
secretPath: secretPath.replace(`${cleanMountPath}/`, "")
},
kvVersion
);
allData.push({
namespace: namespace || "",
mount: mountPath.replace(/\/$/, ""),
path: secretPath.replace(`${cleanMountPath}/`, ""),
secretData
});
}
}
return allData;
};
let data;
if (gatewayId) {
const url = new URL(baseUrl);
const { port, protocol, hostname } = url;
const cleanedProtocol = protocol.slice(0, -1);
data = await $gatewayProxyWrapper(
{
gatewayId,
targetHost: `${cleanedProtocol}://${hostname}`,
targetPort: port ? Number(port) : 8200 // 8200, default port for Vault self-hosted/dedicated
},
getData
);
} else {
data = await getData(baseUrl);
}
for await (const [mountPath, mountInfo] of Object.entries(mounts)) {
// skip non-KV mounts
if (!mountInfo.type.startsWith("kv")) {
// eslint-disable-next-line no-continue
continue;
}
// get all paths in this mount
const paths = await recursivelyGetAllPaths(request, `${mountPath.replace(/\/$/, "")}`);
const cleanMountPath = mountPath.replace(/\/$/, "");
for await (const secretPath of paths) {
// get the actual secret data
const secretData = await getSecrets(request, {
mountPath: cleanMountPath,
secretPath: secretPath.replace(`${cleanMountPath}/`, "")
});
allData.push({
namespace: namespace || "",
mount: mountPath.replace(/\/$/, ""),
path: secretPath.replace(`${cleanMountPath}/`, ""),
secretData
});
}
}
return allData;
return data;
}
return {
@@ -296,17 +408,22 @@ export const transformToInfisicalFormatNamespaceToProjects = (
};
};
export const importVaultDataFn = async ({
vaultAccessToken,
vaultNamespace,
vaultUrl,
mappingType
}: {
vaultAccessToken: string;
vaultNamespace?: string;
vaultUrl: string;
mappingType: VaultMappingType;
}) => {
export const importVaultDataFn = async (
{
vaultAccessToken,
vaultNamespace,
vaultUrl,
mappingType,
gatewayId
}: {
vaultAccessToken: string;
vaultNamespace?: string;
vaultUrl: string;
mappingType: VaultMappingType;
gatewayId?: string;
},
{ gatewayService }: { gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId"> }
) => {
await blockLocalAndPrivateIpAddresses(vaultUrl);
if (mappingType === VaultMappingType.Namespace && !vaultNamespace) {
@@ -315,12 +432,13 @@ export const importVaultDataFn = async ({
});
}
const vaultApi = vaultFactory();
const vaultApi = vaultFactory(gatewayService);
const vaultData = await vaultApi.collectVaultData({
accessToken: vaultAccessToken,
baseUrl: vaultUrl,
namespace: vaultNamespace
namespace: vaultNamespace,
gatewayId
});
const infisicalData = transformToInfisicalFormatNamespaceToProjects(vaultData, mappingType);
@@ -1,4 +1,5 @@
import { OrgMembershipRole } from "@app/db/schemas";
import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { crypto } from "@app/lib/crypto/cryptography";
import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors";
@@ -12,6 +13,7 @@ type TExternalMigrationServiceFactoryDep = {
permissionService: TPermissionServiceFactory;
externalMigrationQueue: TExternalMigrationQueueFactory;
userDAL: Pick<TUserDALFactory, "findById">;
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">;
};
export type TExternalMigrationServiceFactory = ReturnType<typeof externalMigrationServiceFactory>;
@@ -19,7 +21,8 @@ export type TExternalMigrationServiceFactory = ReturnType<typeof externalMigrati
export const externalMigrationServiceFactory = ({
permissionService,
externalMigrationQueue,
userDAL
userDAL,
gatewayService
}: TExternalMigrationServiceFactoryDep) => {
const importEnvKeyData = async ({
decryptionKey,
@@ -72,6 +75,7 @@ export const externalMigrationServiceFactory = ({
vaultNamespace,
mappingType,
vaultUrl,
gatewayId,
actor,
actorId,
actorOrgId,
@@ -91,12 +95,18 @@ export const externalMigrationServiceFactory = ({
const user = await userDAL.findById(actorId);
const vaultData = await importVaultDataFn({
vaultAccessToken,
vaultNamespace,
vaultUrl,
mappingType
});
const vaultData = await importVaultDataFn(
{
vaultAccessToken,
vaultNamespace,
vaultUrl,
mappingType,
gatewayId
},
{
gatewayService
}
);
const stringifiedJson = JSON.stringify({
data: vaultData,
@@ -31,6 +31,7 @@ export type TImportVaultDataDTO = {
vaultNamespace?: string;
mappingType: VaultMappingType;
vaultUrl: string;
gatewayId?: string;
} & Omit<TOrgPermission, "orgId">;
export type TImportInfisicalDataCreate = {
@@ -156,6 +156,7 @@ export const groupProjectDALFactory = (db: TDbClient) => {
`${TableName.GroupProjectMembershipRole}.customRoleId`,
`${TableName.ProjectRoles}.id`
)
.join(TableName.OrgMembership, `${TableName.Users}.id`, `${TableName.OrgMembership}.userId`)
.select(
db.ref("id").withSchema(TableName.UserGroupMembership),
db.ref("createdAt").withSchema(TableName.UserGroupMembership),
@@ -176,7 +177,8 @@ export const groupProjectDALFactory = (db: TDbClient) => {
db.ref("temporaryRange").withSchema(TableName.GroupProjectMembershipRole),
db.ref("temporaryAccessStartTime").withSchema(TableName.GroupProjectMembershipRole),
db.ref("temporaryAccessEndTime").withSchema(TableName.GroupProjectMembershipRole),
db.ref("name").as("projectName").withSchema(TableName.Project)
db.ref("name").as("projectName").withSchema(TableName.Project),
db.ref("isActive").withSchema(TableName.OrgMembership)
)
.where({ isGhost: false });
@@ -192,7 +194,8 @@ export const groupProjectDALFactory = (db: TDbClient) => {
id,
userId,
projectName,
createdAt
createdAt,
isActive
}) => ({
isGroupMember: true,
id,
@@ -202,7 +205,7 @@ export const groupProjectDALFactory = (db: TDbClient) => {
id: projectId,
name: projectName
},
user: { email, username, firstName, lastName, id: userId, publicKey, isGhost },
user: { email, username, firstName, lastName, id: userId, publicKey, isGhost, isOrgMembershipActive: isActive },
createdAt
}),
key: "id",
@@ -8,10 +8,18 @@ import {
validatePrivilegeChangeOperation
} from "@app/ee/services/permission/permission-fns";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore";
import { getConfig } from "@app/lib/config/env";
import { crypto } from "@app/lib/crypto/cryptography";
import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors";
import {
BadRequestError,
NotFoundError,
PermissionBoundaryError,
RateLimitError,
UnauthorizedError
} from "@app/lib/errors";
import { checkIPAgainstBlocklist, extractIPDetails, isValidIpOrCidr, TIp } from "@app/lib/ip";
import { logger } from "@app/lib/logger";
import { ActorType, AuthTokenType } from "../auth/auth-type";
import { TIdentityOrgDALFactory } from "../identity/identity-org-dal";
@@ -22,6 +30,7 @@ import { TIdentityUaClientSecretDALFactory } from "./identity-ua-client-secret-d
import { TIdentityUaDALFactory } from "./identity-ua-dal";
import {
TAttachUaDTO,
TClearUaLockoutsDTO,
TCreateUaClientSecretDTO,
TGetUaClientSecretsDTO,
TGetUaDTO,
@@ -38,30 +47,33 @@ type TIdentityUaServiceFactoryDep = {
identityOrgMembershipDAL: TIdentityOrgDALFactory;
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
keyStore: Pick<
TKeyStoreFactory,
"setItemWithExpiry" | "getItem" | "deleteItem" | "getKeysByPattern" | "deleteItems" | "acquireLock"
>;
};
export type TIdentityUaServiceFactory = ReturnType<typeof identityUaServiceFactory>;
type LockoutObject = {
lockedOut: boolean;
failedAttempts: number;
};
export const identityUaServiceFactory = ({
identityUaDAL,
identityUaClientSecretDAL,
identityAccessTokenDAL,
identityOrgMembershipDAL,
permissionService,
licenseService
licenseService,
keyStore
}: TIdentityUaServiceFactoryDep) => {
const login = async (clientId: string, clientSecret: string, ip: string) => {
const identityUa = await identityUaDAL.findOne({ clientId });
if (!identityUa) {
throw new NotFoundError({
message: "No identity with specified client ID was found"
});
}
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityUa.identityId });
if (!identityMembershipOrg) {
throw new NotFoundError({
message: "No identity with the org membership was found"
throw new UnauthorizedError({
message: "Invalid credentials"
});
}
@@ -69,119 +81,184 @@ export const identityUaServiceFactory = ({
ipAddress: ip,
trustedIps: identityUa.clientSecretTrustedIps as TIp[]
});
const clientSecretPrefix = clientSecret.slice(0, 4);
const clientSecrtInfo = await identityUaClientSecretDAL.find({
identityUAId: identityUa.id,
isClientSecretRevoked: false,
clientSecretPrefix
});
let validClientSecretInfo: (typeof clientSecrtInfo)[0] | null = null;
for await (const info of clientSecrtInfo) {
const isMatch = await crypto.hashing().compareHash(clientSecret, info.clientSecretHash);
const LOCKOUT_KEY = `lockout:identity:${identityUa.identityId}:${IdentityAuthMethod.UNIVERSAL_AUTH}:${clientId}`;
if (isMatch) {
validClientSecretInfo = info;
break;
}
let lock: Awaited<ReturnType<typeof keyStore.acquireLock>>;
try {
lock = await keyStore.acquireLock([KeyStorePrefixes.IdentityLockoutLock(LOCKOUT_KEY)], 500, {
retryCount: 3,
retryDelay: 300,
retryJitter: 100
});
} catch (e) {
logger.info(
`identity login failed to acquire lock [identityId=${identityUa.identityId}] [authMethod=${IdentityAuthMethod.UNIVERSAL_AUTH}]`
);
throw new RateLimitError({ message: "Rate limit exceeded" });
}
if (!validClientSecretInfo) throw new UnauthorizedError({ message: "Invalid credentials" });
try {
const lockoutRaw = await keyStore.getItem(LOCKOUT_KEY);
const { clientSecretTTL, clientSecretNumUses, clientSecretNumUsesLimit } = validClientSecretInfo;
if (Number(clientSecretTTL) > 0) {
const clientSecretCreated = new Date(validClientSecretInfo.createdAt);
const ttlInMilliseconds = Number(clientSecretTTL) * 1000;
const currentDate = new Date();
const expirationTime = new Date(clientSecretCreated.getTime() + ttlInMilliseconds);
let lockout: LockoutObject | undefined;
if (lockoutRaw) {
lockout = JSON.parse(lockoutRaw) as LockoutObject;
}
if (currentDate > expirationTime) {
if (lockout && lockout.lockedOut) {
throw new UnauthorizedError({
message: "This identity auth method is temporarily locked, please try again later"
});
}
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityUa.identityId });
if (!identityMembershipOrg) {
throw new UnauthorizedError({
message: "Invalid credentials"
});
}
const clientSecretPrefix = clientSecret.slice(0, 4);
const clientSecretInfo = await identityUaClientSecretDAL.find({
identityUAId: identityUa.id,
isClientSecretRevoked: false,
clientSecretPrefix
});
let validClientSecretInfo: (typeof clientSecretInfo)[0] | null = null;
for await (const info of clientSecretInfo) {
const isMatch = await crypto.hashing().compareHash(clientSecret, info.clientSecretHash);
if (isMatch) {
validClientSecretInfo = info;
break;
}
}
if (!validClientSecretInfo) {
if (identityUa.lockoutEnabled) {
if (!lockout) {
lockout = {
lockedOut: false,
failedAttempts: 0
};
}
lockout.failedAttempts += 1;
if (lockout.failedAttempts >= identityUa.lockoutThreshold) {
lockout.lockedOut = true;
}
await keyStore.setItemWithExpiry(
LOCKOUT_KEY,
lockout.lockedOut ? identityUa.lockoutDurationSeconds : identityUa.lockoutCounterResetSeconds,
JSON.stringify(lockout)
);
}
throw new UnauthorizedError({ message: "Invalid credentials" });
} else if (lockout) {
await keyStore.deleteItem(LOCKOUT_KEY);
}
const { clientSecretTTL, clientSecretNumUses, clientSecretNumUsesLimit } = validClientSecretInfo;
if (Number(clientSecretTTL) > 0) {
const clientSecretCreated = new Date(validClientSecretInfo.createdAt);
const ttlInMilliseconds = Number(clientSecretTTL) * 1000;
const currentDate = new Date();
const expirationTime = new Date(clientSecretCreated.getTime() + ttlInMilliseconds);
if (currentDate > expirationTime) {
await identityUaClientSecretDAL.updateById(validClientSecretInfo.id, {
isClientSecretRevoked: true
});
throw new UnauthorizedError({
message: "Access denied due to expired client secret"
});
}
}
if (clientSecretNumUsesLimit > 0 && clientSecretNumUses >= clientSecretNumUsesLimit) {
// number of times client secret can be used for
// a login operation reached
await identityUaClientSecretDAL.updateById(validClientSecretInfo.id, {
isClientSecretRevoked: true
});
throw new UnauthorizedError({
message: "Access denied due to expired client secret"
message: "Access denied due to client secret usage limit reached"
});
}
}
if (clientSecretNumUsesLimit > 0 && clientSecretNumUses === clientSecretNumUsesLimit) {
// number of times client secret can be used for
// a login operation reached
await identityUaClientSecretDAL.updateById(validClientSecretInfo.id, {
isClientSecretRevoked: true
const accessTokenTTLParams =
Number(identityUa.accessTokenPeriod) === 0
? {
accessTokenTTL: identityUa.accessTokenTTL,
accessTokenMaxTTL: identityUa.accessTokenMaxTTL
}
: {
accessTokenTTL: identityUa.accessTokenPeriod,
// We set a very large Max TTL for periodic tokens to ensure that clients (even outdated ones) can always renew their token
// without them having to update their SDKs, CLIs, etc. This workaround sets it to 30 years to emulate "forever"
accessTokenMaxTTL: 1000000000
};
const identityAccessToken = await identityUaDAL.transaction(async (tx) => {
const uaClientSecretDoc = await identityUaClientSecretDAL.incrementUsage(validClientSecretInfo!.id, tx);
await identityOrgMembershipDAL.updateById(
identityMembershipOrg.id,
{
lastLoginAuthMethod: IdentityAuthMethod.UNIVERSAL_AUTH,
lastLoginTime: new Date()
},
tx
);
const newToken = await identityAccessTokenDAL.create(
{
identityId: identityUa.identityId,
isAccessTokenRevoked: false,
identityUAClientSecretId: uaClientSecretDoc.id,
accessTokenNumUses: 0,
accessTokenNumUsesLimit: identityUa.accessTokenNumUsesLimit,
accessTokenPeriod: identityUa.accessTokenPeriod,
authMethod: IdentityAuthMethod.UNIVERSAL_AUTH,
...accessTokenTTLParams
},
tx
);
return newToken;
});
throw new UnauthorizedError({
message: "Access denied due to client secret usage limit reached"
});
}
const accessTokenTTLParams =
Number(identityUa.accessTokenPeriod) === 0
? {
accessTokenTTL: identityUa.accessTokenTTL,
accessTokenMaxTTL: identityUa.accessTokenMaxTTL
}
: {
accessTokenTTL: identityUa.accessTokenPeriod,
// We set a very large Max TTL for periodic tokens to ensure that clients (even outdated ones) can always renew their token
// without them having to update their SDKs, CLIs, etc. This workaround sets it to 30 years to emulate "forever"
accessTokenMaxTTL: 1000000000
};
const identityAccessToken = await identityUaDAL.transaction(async (tx) => {
const uaClientSecretDoc = await identityUaClientSecretDAL.incrementUsage(validClientSecretInfo!.id, tx);
await identityOrgMembershipDAL.updateById(
identityMembershipOrg.id,
{
lastLoginAuthMethod: IdentityAuthMethod.UNIVERSAL_AUTH,
lastLoginTime: new Date()
},
tx
);
const newToken = await identityAccessTokenDAL.create(
const appCfg = getConfig();
const accessToken = crypto.jwt().sign(
{
identityId: identityUa.identityId,
isAccessTokenRevoked: false,
identityUAClientSecretId: uaClientSecretDoc.id,
accessTokenNumUses: 0,
accessTokenNumUsesLimit: identityUa.accessTokenNumUsesLimit,
accessTokenPeriod: identityUa.accessTokenPeriod,
authMethod: IdentityAuthMethod.UNIVERSAL_AUTH,
...accessTokenTTLParams
},
tx
clientSecretId: validClientSecretInfo.id,
identityAccessTokenId: identityAccessToken.id,
authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN
} as TIdentityAccessTokenJwtPayload,
appCfg.AUTH_SECRET,
// akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error
Number(identityAccessToken.accessTokenTTL) === 0
? undefined
: {
expiresIn: Number(identityAccessToken.accessTokenTTL)
}
);
return newToken;
});
const appCfg = getConfig();
const accessToken = crypto.jwt().sign(
{
identityId: identityUa.identityId,
clientSecretId: validClientSecretInfo.id,
identityAccessTokenId: identityAccessToken.id,
authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN
} as TIdentityAccessTokenJwtPayload,
appCfg.AUTH_SECRET,
// akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error
Number(identityAccessToken.accessTokenTTL) === 0
? undefined
: {
expiresIn: Number(identityAccessToken.accessTokenTTL)
}
);
return {
accessToken,
identityUa,
validClientSecretInfo,
identityAccessToken,
identityMembershipOrg,
...accessTokenTTLParams
};
return {
accessToken,
identityUa,
validClientSecretInfo,
identityAccessToken,
identityMembershipOrg,
...accessTokenTTLParams
};
} finally {
await lock.release();
}
};
const attachUniversalAuth = async ({
@@ -196,7 +273,11 @@ export const identityUaServiceFactory = ({
actor,
actorOrgId,
isActorSuperAdmin,
accessTokenPeriod
accessTokenPeriod,
lockoutEnabled,
lockoutThreshold,
lockoutDurationSeconds,
lockoutCounterResetSeconds
}: TAttachUaDTO) => {
await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin);
@@ -266,7 +347,11 @@ export const identityUaServiceFactory = ({
accessTokenTTL,
accessTokenNumUsesLimit,
accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps),
accessTokenPeriod
accessTokenPeriod,
lockoutEnabled,
lockoutThreshold,
lockoutDurationSeconds,
lockoutCounterResetSeconds
},
tx
);
@@ -286,7 +371,11 @@ export const identityUaServiceFactory = ({
actorId,
actorAuthMethod,
actor,
actorOrgId
actorOrgId,
lockoutEnabled,
lockoutThreshold,
lockoutDurationSeconds,
lockoutCounterResetSeconds
}: TUpdateUaDTO) => {
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId });
if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` });
@@ -362,7 +451,11 @@ export const identityUaServiceFactory = ({
accessTokenPeriod,
accessTokenTrustedIps: reformattedAccessTokenTrustedIps
? JSON.stringify(reformattedAccessTokenTrustedIps)
: undefined
: undefined,
lockoutEnabled,
lockoutThreshold,
lockoutDurationSeconds,
lockoutCounterResetSeconds
});
return { ...updatedUaAuth, orgId: identityMembershipOrg.orgId };
};
@@ -713,6 +806,38 @@ export const identityUaServiceFactory = ({
return { ...updatedClientSecret, identityId, orgId: identityMembershipOrg.orgId };
};
const clearUniversalAuthLockouts = async ({
identityId,
actorId,
actor,
actorOrgId,
actorAuthMethod
}: TClearUaLockoutsDTO) => {
const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId });
if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` });
if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.UNIVERSAL_AUTH)) {
throw new BadRequestError({
message: "The identity does not have universal auth"
});
}
const { permission } = await permissionService.getOrgPermission(
actor,
actorId,
identityMembershipOrg.orgId,
actorAuthMethod,
actorOrgId
);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity);
const deleted = await keyStore.deleteItems({
pattern: `lockout:identity:${identityId}:${IdentityAuthMethod.UNIVERSAL_AUTH}:*`
});
return { deleted, identityId, orgId: identityMembershipOrg.orgId };
};
return {
login,
attachUniversalAuth,
@@ -722,6 +847,7 @@ export const identityUaServiceFactory = ({
createUniversalAuthClientSecret,
getUniversalAuthClientSecrets,
revokeUniversalAuthClientSecret,
getUniversalAuthClientSecretById
getUniversalAuthClientSecretById,
clearUniversalAuthLockouts
};
};
@@ -9,6 +9,10 @@ export type TAttachUaDTO = {
clientSecretTrustedIps: { ipAddress: string }[];
accessTokenTrustedIps: { ipAddress: string }[];
isActorSuperAdmin?: boolean;
lockoutEnabled: boolean;
lockoutThreshold: number;
lockoutDurationSeconds: number;
lockoutCounterResetSeconds: number;
} & Omit<TProjectPermission, "projectId">;
export type TUpdateUaDTO = {
@@ -19,6 +23,10 @@ export type TUpdateUaDTO = {
accessTokenPeriod?: number;
clientSecretTrustedIps?: { ipAddress: string }[];
accessTokenTrustedIps?: { ipAddress: string }[];
lockoutEnabled?: boolean;
lockoutThreshold?: number;
lockoutDurationSeconds?: number;
lockoutCounterResetSeconds?: number;
} & Omit<TProjectPermission, "projectId">;
export type TGetUaDTO = {
@@ -45,6 +53,10 @@ export type TRevokeUaClientSecretDTO = {
clientSecretId: string;
} & Omit<TProjectPermission, "projectId">;
export type TClearUaLockoutsDTO = {
identityId: string;
} & Omit<TProjectPermission, "projectId">;
export type TGetUniversalAuthClientSecretByIdDTO = {
identityId: string;
clientSecretId: string;
@@ -8,6 +8,7 @@ import {
validatePrivilegeChangeOperation
} from "@app/ee/services/permission/permission-fns";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { TKeyStoreFactory } from "@app/keystore/keystore";
import { BadRequestError, NotFoundError, PermissionBoundaryError } from "@app/lib/errors";
import { TIdentityProjectDALFactory } from "@app/services/identity-project/identity-project-dal";
@@ -32,6 +33,7 @@ type TIdentityServiceFactoryDep = {
identityProjectDAL: Pick<TIdentityProjectDALFactory, "findByIdentityId">;
permissionService: Pick<TPermissionServiceFactory, "getOrgPermission" | "getOrgPermissionByRole">;
licenseService: Pick<TLicenseServiceFactory, "getPlan" | "updateSubscriptionOrgMemberCount">;
keyStore: Pick<TKeyStoreFactory, "getKeysByPattern">;
};
export type TIdentityServiceFactory = ReturnType<typeof identityServiceFactory>;
@@ -42,7 +44,8 @@ export const identityServiceFactory = ({
identityOrgMembershipDAL,
identityProjectDAL,
permissionService,
licenseService
licenseService,
keyStore
}: TIdentityServiceFactoryDep) => {
const createIdentity = async ({
name,
@@ -255,7 +258,20 @@ export const identityServiceFactory = ({
);
ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity);
return identity;
const activeLockouts = await keyStore.getKeysByPattern(`lockout:identity:${id}:*`);
const activeLockoutAuthMethods = new Set<string>();
activeLockouts.forEach((key) => {
const parts = key.split(":");
if (parts.length > 3) {
activeLockoutAuthMethods.add(parts[3]);
}
});
return {
...identity,
identity: { ...identity.identity, activeLockoutAuthMethods: Array.from(activeLockoutAuthMethods) }
};
};
const deleteIdentity = async ({
@@ -124,12 +124,12 @@ export const orgMembershipDALFactory = (db: TDbClient) => {
void qb
.whereNull(`${TableName.OrgMembership}.lastInvitedAt`)
.whereBetween(`${TableName.OrgMembership}.createdAt`, [twelveMonthsAgo, oneWeekAgo]);
})
.orWhere((qb) => {
// lastInvitedAt is older than 1 week ago AND createdAt is younger than 1 month ago
void qb
.where(`${TableName.OrgMembership}.lastInvitedAt`, "<", oneWeekAgo)
.where(`${TableName.OrgMembership}.createdAt`, ">", oneMonthAgo);
void qb.orWhere((qbInner) => {
void qbInner
.where(`${TableName.OrgMembership}.lastInvitedAt`, "<", oneWeekAgo)
.where(`${TableName.OrgMembership}.createdAt`, ">", oneMonthAgo);
});
});
return memberships;
@@ -153,10 +153,64 @@ export const orgMembershipDALFactory = (db: TDbClient) => {
}
};
const findOrgMembershipsWithUsersByOrgId = async (orgId: string) => {
try {
const members = await db
.replicaNode()(TableName.OrgMembership)
.where(`${TableName.OrgMembership}.orgId`, orgId)
.join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`)
.leftJoin<TUserEncryptionKeys>(
TableName.UserEncryptionKey,
`${TableName.UserEncryptionKey}.userId`,
`${TableName.Users}.id`
)
.leftJoin(TableName.IdentityMetadata, (queryBuilder) => {
void queryBuilder
.on(`${TableName.OrgMembership}.userId`, `${TableName.IdentityMetadata}.userId`)
.andOn(`${TableName.OrgMembership}.orgId`, `${TableName.IdentityMetadata}.orgId`);
})
.select(
db.ref("id").withSchema(TableName.OrgMembership),
db.ref("inviteEmail").withSchema(TableName.OrgMembership),
db.ref("orgId").withSchema(TableName.OrgMembership),
db.ref("role").withSchema(TableName.OrgMembership),
db.ref("roleId").withSchema(TableName.OrgMembership),
db.ref("status").withSchema(TableName.OrgMembership),
db.ref("isActive").withSchema(TableName.OrgMembership),
db.ref("email").withSchema(TableName.Users),
db.ref("username").withSchema(TableName.Users),
db.ref("firstName").withSchema(TableName.Users),
db.ref("lastName").withSchema(TableName.Users),
db.ref("isEmailVerified").withSchema(TableName.Users),
db.ref("id").withSchema(TableName.Users).as("userId")
)
.where({ isGhost: false });
return members.map((member) => ({
id: member.id,
orgId: member.orgId,
role: member.role,
status: member.status,
isActive: member.isActive,
inviteEmail: member.inviteEmail,
user: {
id: member.userId,
email: member.email,
username: member.username,
firstName: member.firstName,
lastName: member.lastName
}
}));
} catch (error) {
throw new DatabaseError({ error, name: "Find org memberships with users by org id" });
}
};
return {
...orgMembershipOrm,
findOrgMembershipById,
findRecentInvitedMemberships,
updateLastInvitedAtByIds
updateLastInvitedAtByIds,
findOrgMembershipsWithUsersByOrgId
};
};
@@ -18,7 +18,8 @@ export const sanitizedPkiSubscriber = PkiSubscribersSchema.pick({
lastOperationAt: true,
enableAutoRenewal: true,
autoRenewalPeriodInDays: true,
lastAutoRenewAt: true
lastAutoRenewAt: true,
properties: true
}).extend({
supportsImmediateCertIssuance: z.boolean().optional()
});
@@ -109,6 +109,7 @@ export const pkiSubscriberServiceFactory = ({
extendedKeyUsages,
enableAutoRenewal,
autoRenewalPeriodInDays,
properties,
projectId,
actorId,
actorAuthMethod,
@@ -157,7 +158,8 @@ export const pkiSubscriberServiceFactory = ({
keyUsages,
extendedKeyUsages,
enableAutoRenewal,
autoRenewalPeriodInDays
autoRenewalPeriodInDays,
properties
});
return newSubscriber;
@@ -221,6 +223,7 @@ export const pkiSubscriberServiceFactory = ({
extendedKeyUsages,
enableAutoRenewal,
autoRenewalPeriodInDays,
properties,
actorId,
actorAuthMethod,
actor,
@@ -275,7 +278,8 @@ export const pkiSubscriberServiceFactory = ({
keyUsages,
extendedKeyUsages,
enableAutoRenewal,
autoRenewalPeriodInDays
autoRenewalPeriodInDays,
properties
});
return updatedSubscriber;
@@ -360,7 +364,7 @@ export const pkiSubscriberServiceFactory = ({
throw new BadRequestError({ message: "CA is disabled" });
}
if (ca.externalCa?.id && ca.externalCa.type === CaType.ACME) {
if (ca.externalCa?.id && (ca.externalCa.type === CaType.ACME || ca.externalCa.type === CaType.AZURE_AD_CS)) {
await certificateAuthorityQueue.orderCertificateForSubscriber({
subscriberId: subscriber.id,
caType: ca.externalCa.type
@@ -18,6 +18,7 @@ export type TCreatePkiSubscriberDTO = {
extendedKeyUsages: CertExtendedKeyUsage[];
enableAutoRenewal?: boolean;
autoRenewalPeriodInDays?: number;
properties?: TPkiSubscriberProperties;
} & TProjectPermission;
export type TGetPkiSubscriberDTO = {
@@ -36,6 +37,7 @@ export type TUpdatePkiSubscriberDTO = {
extendedKeyUsages?: CertExtendedKeyUsage[];
enableAutoRenewal?: boolean;
autoRenewalPeriodInDays?: number;
properties?: TPkiSubscriberProperties;
} & TProjectPermission;
export type TDeletePkiSubscriberDTO = {
@@ -69,3 +71,13 @@ export enum SubscriberOperationStatus {
SUCCESS = "success",
FAILED = "failed"
}
export type TPkiSubscriberProperties = {
azureTemplateType?: string;
organization?: string;
organizationalUnit?: string;
country?: string;
state?: string;
locality?: string;
emailAddress?: string;
};
@@ -21,6 +21,14 @@ export const projectMembershipDALFactory = (db: TDbClient) => {
.where({ [`${TableName.ProjectMembership}.projectId` as "projectId"]: projectId })
.join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`)
.join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`)
.join(TableName.OrgMembership, (qb) => {
qb.on(`${TableName.Users}.id`, "=", `${TableName.OrgMembership}.userId`).andOn(
`${TableName.OrgMembership}.orgId`,
"=",
`${TableName.Project}.orgId`
);
})
.where((qb) => {
if (filter.usernames) {
void qb.whereIn("username", filter.usernames);
@@ -90,7 +98,8 @@ export const projectMembershipDALFactory = (db: TDbClient) => {
db.ref("temporaryRange").withSchema(TableName.ProjectUserMembershipRole),
db.ref("temporaryAccessStartTime").withSchema(TableName.ProjectUserMembershipRole),
db.ref("temporaryAccessEndTime").withSchema(TableName.ProjectUserMembershipRole),
db.ref("name").as("projectName").withSchema(TableName.Project)
db.ref("name").as("projectName").withSchema(TableName.Project),
db.ref("isActive").withSchema(TableName.OrgMembership)
)
.where({ isGhost: false })
.orderBy(`${TableName.Users}.username` as "username");
@@ -107,12 +116,22 @@ export const projectMembershipDALFactory = (db: TDbClient) => {
id,
userId,
projectName,
createdAt
createdAt,
isActive
}) => ({
id,
userId,
projectId,
user: { email, username, firstName, lastName, id: userId, publicKey, isGhost },
user: {
email,
username,
firstName,
lastName,
id: userId,
publicKey,
isGhost,
isOrgMembershipActive: isActive
},
project: {
id: projectId,
name: projectName
@@ -97,7 +97,6 @@ export const projectMembershipServiceFactory = ({
const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId, { roles });
// projectMembers[0].project
if (includeGroupMembers) {
const groupMembers = await groupProjectDAL.findAllProjectGroupMembers(projectId);
const allMembers = [
@@ -30,6 +30,7 @@ import {
TDeleteFolderDTO,
TDeleteManyFoldersDTO,
TGetFolderByIdDTO,
TGetFolderByPathDTO,
TGetFolderDTO,
TGetFoldersDeepByEnvsDTO,
TUpdateFolderDTO,
@@ -1398,6 +1399,31 @@ export const secretFolderServiceFactory = ({
};
};
const getFolderByPath = async (
{ projectId, environment, secretPath }: TGetFolderByPathDTO,
actor: OrgServiceActor
) => {
// folder check is allowed to be read by anyone
// permission is to check if user has access
await permissionService.getProjectPermission({
actor: actor.type,
actorId: actor.id,
projectId,
actorAuthMethod: actor.authMethod,
actorOrgId: actor.orgId,
actionProjectType: ActionProjectType.SecretManager
});
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
if (!folder)
throw new NotFoundError({
message: `Could not find folder with path "${secretPath}" in environment "${environment}" for project with ID "${projectId}"`
});
return folder;
};
return {
createFolder,
updateFolder,
@@ -1405,6 +1431,7 @@ export const secretFolderServiceFactory = ({
deleteFolder,
getFolders,
getFolderById,
getFolderByPath,
getProjectFolderCount,
getFoldersMultiEnv,
getFoldersDeepByEnvs,
@@ -91,3 +91,9 @@ export type TDeleteManyFoldersDTO = {
idOrName: string;
}>;
};
export type TGetFolderByPathDTO = {
projectId: string;
environment: string;
secretPath: string;
};
@@ -23,56 +23,120 @@ export const ChecklySyncFns = {
const config = secretSync.destinationConfig;
const variables = await ChecklyPublicAPI.getVariables(secretSync.connection, config.accountId);
if (config.groupId) {
// Handle group environment variables
const groupVars = await ChecklyPublicAPI.getCheckGroupEnvironmentVariables(
secretSync.connection,
config.accountId,
config.groupId
);
const checklySecrets = Object.fromEntries(variables!.map((variable) => [variable.key, variable]));
const checklyGroupSecrets = Object.fromEntries(groupVars.map((variable) => [variable.key, variable]));
for await (const key of Object.keys(secretMap)) {
try {
// Prepare all variables to update at once
const updatedVariables = { ...checklyGroupSecrets };
for (const key of Object.keys(secretMap)) {
const entry = secretMap[key];
// If value is empty, we skip the upsert - checkly does not allow empty values
// If value is empty, we skip adding it - checkly does not allow empty values
if (entry.value.trim() === "") {
// Delete the secret from Checkly if its empty
// Delete the secret from the group if it's empty
if (!disableSecretDeletion) {
await ChecklyPublicAPI.deleteVariable(secretSync.connection, config.accountId, {
key
});
delete updatedVariables[key];
}
continue; // Skip empty values
}
await ChecklyPublicAPI.upsertVariable(secretSync.connection, config.accountId, {
// Add or update the variable
updatedVariables[key] = {
key,
value: entry.value,
secret: true,
locked: true
});
};
}
// Remove secrets that are not in the secretMap if deletion is enabled
if (!disableSecretDeletion) {
for (const key of Object.keys(checklyGroupSecrets)) {
// eslint-disable-next-line no-continue
if (!matchesSchema(key, environment?.slug || "", keySchema)) continue;
if (!secretMap[key]) {
delete updatedVariables[key];
}
}
}
// Update all group environment variables at once
try {
await ChecklyPublicAPI.updateCheckGroupEnvironmentVariables(
secretSync.connection,
config.accountId,
config.groupId,
Object.values(updatedVariables)
);
} catch (error) {
if (error instanceof SecretSyncError) throw error;
throw new SecretSyncError({
error,
secretKey: key
secretKey: "group_update"
});
}
}
} else {
// Handle global variables (existing logic)
const variables = await ChecklyPublicAPI.getVariables(secretSync.connection, config.accountId);
if (disableSecretDeletion) return;
const checklySecrets = Object.fromEntries(variables!.map((variable) => [variable.key, variable]));
for await (const key of Object.keys(checklySecrets)) {
try {
// eslint-disable-next-line no-continue
if (!matchesSchema(key, environment?.slug || "", keySchema)) continue;
for await (const key of Object.keys(secretMap)) {
try {
const entry = secretMap[key];
if (!secretMap[key]) {
await ChecklyPublicAPI.deleteVariable(secretSync.connection, config.accountId, {
key
// If value is empty, we skip the upsert - checkly does not allow empty values
if (entry.value.trim() === "") {
// Delete the secret from Checkly if its empty
if (!disableSecretDeletion) {
await ChecklyPublicAPI.deleteVariable(secretSync.connection, config.accountId, {
key
});
}
continue; // Skip empty values
}
await ChecklyPublicAPI.upsertVariable(secretSync.connection, config.accountId, {
key,
value: entry.value,
secret: true,
locked: true
});
} catch (error) {
throw new SecretSyncError({
error,
secretKey: key
});
}
}
if (disableSecretDeletion) return;
for await (const key of Object.keys(checklySecrets)) {
try {
// eslint-disable-next-line no-continue
if (!matchesSchema(key, environment?.slug || "", keySchema)) continue;
if (!secretMap[key]) {
await ChecklyPublicAPI.deleteVariable(secretSync.connection, config.accountId, {
key
});
}
} catch (error) {
throw new SecretSyncError({
error,
secretKey: key
});
}
} catch (error) {
throw new SecretSyncError({
error,
secretKey: key
});
}
}
},
@@ -80,23 +144,54 @@ export const ChecklySyncFns = {
async removeSecrets(secretSync: TChecklySyncWithCredentials, secretMap: TSecretMap) {
const config = secretSync.destinationConfig;
const variables = await ChecklyPublicAPI.getVariables(secretSync.connection, config.accountId);
if (config.groupId) {
// Handle group environment variables
const groupVars = await ChecklyPublicAPI.getCheckGroupEnvironmentVariables(
secretSync.connection,
config.accountId,
config.groupId
);
const checklySecrets = Object.fromEntries(variables!.map((variable) => [variable.key, variable]));
const checklyGroupSecrets = Object.fromEntries(groupVars.map((variable) => [variable.key, variable]));
// Filter out the secrets to remove
const remainingVariables = Object.keys(checklyGroupSecrets)
.filter((key) => !(key in secretMap))
.map((key) => checklyGroupSecrets[key]);
for await (const secret of Object.keys(checklySecrets)) {
try {
if (secret in secretMap) {
await ChecklyPublicAPI.deleteVariable(secretSync.connection, config.accountId, {
key: secret
});
}
await ChecklyPublicAPI.updateCheckGroupEnvironmentVariables(
secretSync.connection,
config.accountId,
config.groupId,
remainingVariables
);
} catch (error) {
throw new SecretSyncError({
error,
secretKey: secret
secretKey: "group_remove"
});
}
} else {
// Handle global variables (existing logic)
const variables = await ChecklyPublicAPI.getVariables(secretSync.connection, config.accountId);
const checklySecrets = Object.fromEntries(variables!.map((variable) => [variable.key, variable]));
for await (const secret of Object.keys(checklySecrets)) {
try {
if (secret in secretMap) {
await ChecklyPublicAPI.deleteVariable(secretSync.connection, config.accountId, {
key: secret
});
}
} catch (error) {
throw new SecretSyncError({
error,
secretKey: secret
});
}
}
}
}
};
@@ -11,7 +11,17 @@ import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"
const ChecklySyncDestinationConfigSchema = z.object({
accountId: z.string().min(1, "Account ID is required").max(255, "Account ID must be less than 255 characters"),
accountName: z.string().min(1, "Account Name is required").max(255, "Account ID must be less than 255 characters")
accountName: z
.string()
.min(1, "Account Name is required")
.max(255, "Account ID must be less than 255 characters")
.optional(),
groupId: z.string().min(1, "Group ID is required").max(255, "Group ID must be less than 255 characters").optional(),
groupName: z
.string()
.min(1, "Group Name is required")
.max(255, "Group Name must be less than 255 characters")
.optional()
});
const ChecklySyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: false };
@@ -214,7 +214,7 @@ export const GithubSyncFns = {
const token =
connection.method === GitHubConnectionMethod.OAuth
? connection.credentials.accessToken
: await getGitHubAppAuthToken(connection);
: await getGitHubAppAuthToken(connection, gatewayService);
const encryptedSecrets = await getEncryptedSecrets(secretSync, gatewayService, gatewayV2Service);
const publicKey = await getPublicKey(secretSync, gatewayService, gatewayV2Service, token);
@@ -272,7 +272,7 @@ export const GithubSyncFns = {
const token =
connection.method === GitHubConnectionMethod.OAuth
? connection.credentials.accessToken
: await getGitHubAppAuthToken(connection);
: await getGitHubAppAuthToken(connection, gatewayService);
const encryptedSecrets = await getEncryptedSecrets(secretSync, gatewayService, gatewayV2Service);
@@ -1,9 +1,13 @@
import { isAxiosError } from "axios";
import { request } from "@app/lib/config/request";
import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
import { removeTrailingSlash } from "@app/lib/fn";
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator";
import { getHCVaultAccessToken, getHCVaultInstanceUrl } from "@app/services/app-connection/hc-vault";
import {
getHCVaultAccessToken,
getHCVaultInstanceUrl,
requestWithHCVaultGateway,
THCVaultConnection
} from "@app/services/app-connection/hc-vault";
import {
THCVaultListVariables,
THCVaultListVariablesResponse,
@@ -14,19 +18,20 @@ import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors";
import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns";
import { TSecretMap } from "@app/services/secret-sync/secret-sync-types";
const listHCVaultVariables = async ({ instanceUrl, namespace, mount, accessToken, path }: THCVaultListVariables) => {
await blockLocalAndPrivateIpAddresses(instanceUrl);
const listHCVaultVariables = async (
{ instanceUrl, namespace, mount, accessToken, path }: THCVaultListVariables,
connection: THCVaultConnection,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
) => {
try {
const { data } = await request.get<THCVaultListVariablesResponse>(
`${instanceUrl}/v1/${removeTrailingSlash(mount)}/data/${path}`,
{
headers: {
"X-Vault-Token": accessToken,
...(namespace ? { "X-Vault-Namespace": namespace } : {})
}
const { data } = await requestWithHCVaultGateway<THCVaultListVariablesResponse>(connection, gatewayService, {
url: `${instanceUrl}/v1/${removeTrailingSlash(mount)}/data/${path}`,
method: "GET",
headers: {
"X-Vault-Token": accessToken,
...(namespace ? { "X-Vault-Namespace": namespace } : {})
}
);
});
return data.data.data;
} catch (error: unknown) {
@@ -39,33 +44,29 @@ const listHCVaultVariables = async ({ instanceUrl, namespace, mount, accessToken
};
// Hashicorp Vault updates all variables in one batch. This is to respect their versioning
const updateHCVaultVariables = async ({
path,
instanceUrl,
namespace,
accessToken,
mount,
data
}: TPostHCVaultVariable) => {
await blockLocalAndPrivateIpAddresses(instanceUrl);
return request.post(
`${instanceUrl}/v1/${removeTrailingSlash(mount)}/data/${path}`,
{
data
const updateHCVaultVariables = async (
{ path, instanceUrl, namespace, accessToken, mount, data }: TPostHCVaultVariable,
connection: THCVaultConnection,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
) => {
return requestWithHCVaultGateway(connection, gatewayService, {
url: `${instanceUrl}/v1/${removeTrailingSlash(mount)}/data/${path}`,
method: "POST",
headers: {
"X-Vault-Token": accessToken,
...(namespace ? { "X-Vault-Namespace": namespace } : {}),
"Content-Type": "application/json"
},
{
headers: {
"X-Vault-Token": accessToken,
...(namespace ? { "X-Vault-Namespace": namespace } : {}),
"Content-Type": "application/json"
}
}
);
data: { data }
});
};
export const HCVaultSyncFns = {
syncSecrets: async (secretSync: THCVaultSyncWithCredentials, secretMap: TSecretMap) => {
syncSecrets: async (
secretSync: THCVaultSyncWithCredentials,
secretMap: TSecretMap,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
) => {
const {
connection,
environment,
@@ -74,16 +75,20 @@ export const HCVaultSyncFns = {
} = secretSync;
const { namespace } = connection.credentials;
const accessToken = await getHCVaultAccessToken(connection);
const accessToken = await getHCVaultAccessToken(connection, gatewayService);
const instanceUrl = await getHCVaultInstanceUrl(connection);
const variables = await listHCVaultVariables({
instanceUrl,
accessToken,
namespace,
mount,
path
});
const variables = await listHCVaultVariables(
{
instanceUrl,
accessToken,
namespace,
mount,
path
},
connection,
gatewayService
);
let tainted = false;
for (const entry of Object.entries(secretMap)) {
@@ -110,24 +115,36 @@ export const HCVaultSyncFns = {
if (!tainted) return;
try {
await updateHCVaultVariables({ accessToken, instanceUrl, namespace, mount, path, data: variables });
await updateHCVaultVariables(
{ accessToken, instanceUrl, namespace, mount, path, data: variables },
connection,
gatewayService
);
} catch (error) {
throw new SecretSyncError({
error
});
}
},
removeSecrets: async (secretSync: THCVaultSyncWithCredentials, secretMap: TSecretMap) => {
removeSecrets: async (
secretSync: THCVaultSyncWithCredentials,
secretMap: TSecretMap,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
) => {
const {
connection,
destinationConfig: { mount, path }
} = secretSync;
const { namespace } = connection.credentials;
const accessToken = await getHCVaultAccessToken(connection);
const accessToken = await getHCVaultAccessToken(connection, gatewayService);
const instanceUrl = await getHCVaultInstanceUrl(connection);
const variables = await listHCVaultVariables({ instanceUrl, namespace, accessToken, mount, path });
const variables = await listHCVaultVariables(
{ instanceUrl, namespace, accessToken, mount, path },
connection,
gatewayService
);
for await (const [key] of Object.entries(variables)) {
if (key in secretMap) {
@@ -136,30 +153,41 @@ export const HCVaultSyncFns = {
}
try {
await updateHCVaultVariables({ accessToken, instanceUrl, namespace, mount, path, data: variables });
await updateHCVaultVariables(
{ accessToken, instanceUrl, namespace, mount, path, data: variables },
connection,
gatewayService
);
} catch (error) {
throw new SecretSyncError({
error
});
}
},
getSecrets: async (secretSync: THCVaultSyncWithCredentials) => {
getSecrets: async (
secretSync: THCVaultSyncWithCredentials,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">
) => {
const {
connection,
destinationConfig: { mount, path }
} = secretSync;
const { namespace } = connection.credentials;
const accessToken = await getHCVaultAccessToken(connection);
const accessToken = await getHCVaultAccessToken(connection, gatewayService);
const instanceUrl = await getHCVaultInstanceUrl(connection);
const variables = await listHCVaultVariables({
instanceUrl,
namespace,
accessToken,
mount,
path
});
const variables = await listHCVaultVariables(
{
instanceUrl,
namespace,
accessToken,
mount,
path
},
connection,
gatewayService
);
return Object.fromEntries(Object.entries(variables).map(([key, value]) => [key, { value }]));
}
@@ -246,7 +246,7 @@ export const SecretSyncFns = {
case SecretSync.Windmill:
return WindmillSyncFns.syncSecrets(secretSync, schemaSecretMap);
case SecretSync.HCVault:
return HCVaultSyncFns.syncSecrets(secretSync, schemaSecretMap);
return HCVaultSyncFns.syncSecrets(secretSync, schemaSecretMap, gatewayService);
case SecretSync.TeamCity:
return TeamCitySyncFns.syncSecrets(secretSync, schemaSecretMap);
case SecretSync.OCIVault:
@@ -285,7 +285,7 @@ export const SecretSyncFns = {
},
getSecrets: async (
secretSync: TSecretSyncWithCredentials,
{ kmsService, appConnectionDAL }: TSyncSecretDeps
{ kmsService, appConnectionDAL, gatewayService }: TSyncSecretDeps
): Promise<TSecretMap> => {
let secretMap: TSecretMap;
switch (secretSync.destination) {
@@ -343,7 +343,7 @@ export const SecretSyncFns = {
secretMap = await WindmillSyncFns.getSecrets(secretSync);
break;
case SecretSync.HCVault:
secretMap = await HCVaultSyncFns.getSecrets(secretSync);
secretMap = await HCVaultSyncFns.getSecrets(secretSync, gatewayService);
break;
case SecretSync.TeamCity:
secretMap = await TeamCitySyncFns.getSecrets(secretSync);
@@ -453,7 +453,7 @@ export const SecretSyncFns = {
case SecretSync.Windmill:
return WindmillSyncFns.removeSecrets(secretSync, schemaSecretMap);
case SecretSync.HCVault:
return HCVaultSyncFns.removeSecrets(secretSync, schemaSecretMap);
return HCVaultSyncFns.removeSecrets(secretSync, schemaSecretMap, gatewayService);
case SecretSync.TeamCity:
return TeamCitySyncFns.removeSecrets(secretSync, schemaSecretMap);
case SecretSync.OCIVault:
@@ -684,9 +684,9 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => {
throw new BadRequestError({ message: "Missing personal user id" });
}
void bd.orWhere({
key: el.key,
type: el.type,
userId: el.type === SecretType.Personal ? el.userId : null
[`${TableName.SecretV2}.key` as "key"]: el.key,
[`${TableName.SecretV2}.type` as "type"]: el.type,
[`${TableName.SecretV2}.userId` as "userId"]: el.type === SecretType.Personal ? el.userId : null
});
});
})
@@ -695,12 +695,60 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => {
`${TableName.SecretV2}.id`,
`${TableName.SecretRotationV2SecretMapping}.secretId`
)
.leftJoin(
TableName.SecretV2JnTag,
`${TableName.SecretV2}.id`,
`${TableName.SecretV2JnTag}.${TableName.SecretV2}Id`
)
.leftJoin(
TableName.SecretTag,
`${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`,
`${TableName.SecretTag}.id`
)
.leftJoin(TableName.ResourceMetadata, `${TableName.SecretV2}.id`, `${TableName.ResourceMetadata}.secretId`)
.select(db.ref("id").withSchema(TableName.SecretTag).as("tagId"))
.select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor"))
.select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"))
.select(
db.ref("id").withSchema(TableName.ResourceMetadata).as("metadataId"),
db.ref("key").withSchema(TableName.ResourceMetadata).as("metadataKey"),
db.ref("value").withSchema(TableName.ResourceMetadata).as("metadataValue")
)
.select(selectAllTableCols(TableName.SecretV2))
.select(db.ref("rotationId").withSchema(TableName.SecretRotationV2SecretMapping));
return secrets.map((secret) => ({
...secret,
isRotatedSecret: Boolean(secret.rotationId)
}));
const docs = sqlNestRelationships({
data: secrets,
key: "id",
parentMapper: (secret) => ({
...secret,
isRotatedSecret: Boolean(secret.rotationId)
}),
childrenMapper: [
{
key: "tagId",
label: "tags" as const,
mapper: ({ tagId: id, tagColor: color, tagSlug: slug }) => ({
id,
color,
slug,
name: slug
})
},
{
key: "metadataId",
label: "secretMetadata" as const,
mapper: ({ metadataKey, metadataValue, metadataId }) => ({
id: metadataId,
key: metadataKey,
value: metadataValue
})
}
]
});
return docs;
} catch (error) {
throw new DatabaseError({ error, name: "find by secret keys" });
}
@@ -1074,12 +1074,22 @@ export const secretV2BridgeServiceFactory = ({
currentPath: path
});
if (!deepPaths) return { secrets: [], imports: [] };
if (!deepPaths?.length) {
throw new NotFoundError({
message: `Folder with path '${path}' in environment '${environment}' was not found. Please ensure the environment slug and secret path is correct.`,
name: "SecretPathNotFound"
});
}
paths = deepPaths.map(({ folderId, path: p }) => ({ folderId, path: p }));
} else {
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
if (!folder) return { secrets: [], imports: [] };
if (!folder) {
throw new NotFoundError({
message: `Folder with path '${path}' in environment '${environment}' was not found. Please ensure the environment slug and secret path is correct.`,
name: "SecretPathNotFound"
});
}
paths = [{ folderId: folder.id, path }];
}
+12 -2
View File
@@ -637,7 +637,12 @@ export const secretServiceFactory = ({
}
});
if (!deepPaths) return { secrets: [], imports: [] };
if (!deepPaths?.length) {
throw new NotFoundError({
message: `Folder with path '${path}' in environment '${environment}' was not found. Please ensure the environment slug and secret path is correct.`,
name: "SecretPathNotFound"
});
}
paths = deepPaths.map(({ folderId, path: p }) => ({ folderId, path: p }));
} else {
@@ -647,7 +652,12 @@ export const secretServiceFactory = ({
});
const folder = await folderDAL.findBySecretPath(projectId, environment, path);
if (!folder) return { secrets: [], imports: [] };
if (!folder) {
throw new NotFoundError({
message: `Folder with path '${path}' in environment '${environment}' was not found. Please ensure the environment slug and secret path is correct.`,
name: "SecretPathNotFound"
});
}
paths = [{ folderId: folder.id, path }];
}
+35 -10
View File
@@ -2,6 +2,7 @@ import { ForbiddenError } from "@casl/ability";
import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { crypto } from "@app/lib/crypto";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { logger } from "@app/lib/logger";
import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service";
@@ -9,9 +10,10 @@ import { TokenType } from "@app/services/auth-token/auth-token-types";
import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal";
import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service";
import { AuthMethod } from "../auth/auth-type";
import { AuthMethod, AuthTokenType } from "../auth/auth-type";
import { TGroupProjectDALFactory } from "../group-project/group-project-dal";
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
import { TUserAliasDALFactory } from "../user-alias/user-alias-dal";
import { TUserDALFactory } from "./user-dal";
import { TListUserGroupsDTO, TUpdateUserMfaDTO } from "./user-types";
@@ -37,6 +39,7 @@ type TUserServiceFactoryDep = {
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "find">;
smtpService: Pick<TSmtpService, "sendMail">;
permissionService: TPermissionServiceFactory;
userAliasDAL: Pick<TUserAliasDALFactory, "findOne" | "find" | "updateById">;
};
export type TUserServiceFactory = ReturnType<typeof userServiceFactory>;
@@ -48,22 +51,38 @@ export const userServiceFactory = ({
groupProjectDAL,
tokenService,
smtpService,
permissionService
permissionService,
userAliasDAL
}: TUserServiceFactoryDep) => {
const sendEmailVerificationCode = async (username: string) => {
const sendEmailVerificationCode = async (token: string) => {
const { authType, aliasId, username, authTokenType } = crypto.jwt().decode(token) as {
authType: string;
aliasId?: string;
username: string;
authTokenType: AuthTokenType;
};
if (authTokenType !== AuthTokenType.PROVIDER_TOKEN) throw new BadRequestError({ name: "Invalid auth token type" });
// akhilmhdh: case sensitive email resolution
const users = await userDAL.findUserByUsername(username);
const user = users?.length > 1 ? users.find((el) => el.username === username) : users?.[0];
if (!user) throw new NotFoundError({ name: `User with username '${username}' not found` });
let { isEmailVerified } = user;
if (aliasId) {
const userAlias = await userAliasDAL.findOne({ userId: user.id, aliasType: authType, id: aliasId });
if (!userAlias) throw new NotFoundError({ name: `User alias with ID '${aliasId}' not found` });
isEmailVerified = userAlias.isEmailVerified;
}
if (!user.email)
throw new BadRequestError({ name: "Failed to send email verification code due to no email on user" });
if (user.isEmailVerified)
if (isEmailVerified)
throw new BadRequestError({ name: "Failed to send email verification code due to email already verified" });
const token = await tokenService.createTokenForUser({
const userToken = await tokenService.createTokenForUser({
type: TokenType.TOKEN_EMAIL_VERIFICATION,
userId: user.id
userId: user.id,
aliasId
});
await smtpService.sendMail({
@@ -71,7 +90,7 @@ export const userServiceFactory = ({
subjectLine: "Infisical confirmation code",
recipients: [user.email],
substitutions: {
code: token
code: userToken
}
});
};
@@ -95,15 +114,21 @@ export const userServiceFactory = ({
if (!user) throw new NotFoundError({ name: `User with username '${username}' not found` });
if (!user.email)
throw new BadRequestError({ name: "Failed to verify email verification code due to no email on user" });
if (user.isEmailVerified)
throw new BadRequestError({ name: "Failed to verify email verification code due to email already verified" });
await tokenService.validateTokenForUser({
const token = await tokenService.validateTokenForUser({
type: TokenType.TOKEN_EMAIL_VERIFICATION,
userId: user.id,
code
});
if (token?.aliasId) {
const userAlias = await userAliasDAL.findOne({ userId: user.id, id: token.aliasId });
if (!userAlias) throw new NotFoundError({ name: `User alias with ID '${token.aliasId}' not found` });
if (userAlias?.isEmailVerified)
throw new BadRequestError({ name: "Failed to verify email verification code due to email already verified" });
await userAliasDAL.updateById(token.aliasId, { isEmailVerified: true });
}
const userEmails = user?.email ? await userDAL.find({ email: user.email }) : [];
await userDAL.updateById(user.id, {