From 9a17cd6af097ac26bc3a4bf1f7a45d300b8da23b Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Tue, 9 Sep 2025 10:34:00 -0300 Subject: [PATCH 01/66] Stop blocking secret references with no matching reference and imporve UI edit secret behavior to better highlight this --- .../secret-v2-bridge-service.ts | 22 +----- .../InfisicalSecretInput.tsx | 76 ++++++++++++++++--- .../components/v2/SecretInput/SecretInput.tsx | 25 +++++- 3 files changed, 89 insertions(+), 34 deletions(-) diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts index 48ab07816..7ad8ab82c 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts @@ -148,7 +148,7 @@ export const secretV2BridgeServiceFactory = ({ keyStore, reminderService }: TSecretV2BridgeServiceFactoryDep) => { - const $validateSecretReferences = async ( + const validateSecretReferences = async ( projectId: string, permission: MongoAbility, references: ReturnType["nestedReferences"], @@ -312,12 +312,7 @@ export const secretV2BridgeServiceFactory = ({ project.secretDetectionIgnoreValues || [] ); - const { nestedReferences, localReferences } = getAllSecretReferences(inputSecret.secretValue); - const allSecretReferences = nestedReferences.concat( - localReferences.map((el) => ({ secretKey: el, secretPath, environment })) - ); - - await $validateSecretReferences(projectId, permission, allSecretReferences); + const { nestedReferences } = getAllSecretReferences(inputSecret.secretValue); const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, @@ -558,14 +553,6 @@ export const secretV2BridgeServiceFactory = ({ } : {}; - if (secretValue) { - const { nestedReferences, localReferences } = getAllSecretReferences(secretValue); - const allSecretReferences = nestedReferences.concat( - localReferences.map((el) => ({ secretKey: el, secretPath, environment })) - ); - await $validateSecretReferences(projectId, permission, allSecretReferences); - } - const updatedSecret = await secretDAL.transaction(async (tx) => fnSecretBulkUpdate({ folderId, @@ -1686,7 +1673,6 @@ export const secretV2BridgeServiceFactory = ({ }); } }); - await $validateSecretReferences(projectId, permission, secretReferences); const { encryptor: secretManagerEncryptor, decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, projectId }); @@ -2000,7 +1986,6 @@ export const secretV2BridgeServiceFactory = ({ }); } }); - await $validateSecretReferences(projectId, permission, secretReferences, tx); const project = await projectDAL.findById(projectId); await scanSecretPolicyViolations( @@ -3150,6 +3135,7 @@ export const secretV2BridgeServiceFactory = ({ getSecretById, getAccessibleSecrets, getSecretVersionsByIds, - findSecretIdsByFolderIdAndKeys + findSecretIdsByFolderIdAndKeys, + validateSecretReferences }; }; diff --git a/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx b/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx index ee2f196b1..c4b438e9a 100644 --- a/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx +++ b/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx @@ -9,6 +9,15 @@ import { useGetProjectFolders, useGetProjectSecrets } from "@app/hooks/api"; import { SecretInput } from "../SecretInput"; +// Regex to find all secret references in the format ${reference} +const REFERENCE_REGEX = /\${([^}]+)}/g; + +// Extract unique references from a value +const extractReferences = (value: string): string[] => { + const matches = Array.from(value.matchAll(REFERENCE_REGEX)); + return [...new Set(matches.map((match) => match[1]))]; +}; + const getIndexOfUnclosedRefToTheLeft = (value: string, pos: number) => { // take substring up to pos in order to consider edits for closed references for (let i = pos; i >= 1; i -= 1) { @@ -83,7 +92,7 @@ export const InfisicalSecretInput = forwardRef( const [highlightedIndex, setHighlightedIndex] = useState(-1); - const inputRef = useRef(null); + const inputRef = useRef(null); const popoverContentRef = useRef(null); const [isFocused, setIsFocused] = useToggle(false); const currentCursorPosition = inputRef.current?.selectionStart || 0; @@ -139,6 +148,8 @@ export const InfisicalSecretInput = forwardRef( } }); + const allReferences = useMemo(() => extractReferences(value), [value]); + const suggestions = useMemo(() => { if (!isPopupOpen) return []; // reset highlight whenever recomputation happens @@ -178,6 +189,44 @@ export const InfisicalSecretInput = forwardRef( return suggestionsArr; }, [secrets, folders, currentWorkspace?.environments, isPopupOpen, suggestionSource.value]); + // Mark as invalid when editing and reference doesn't match any suggestion + const invalidReferences = useMemo(() => { + const invalid = new Set(); + + if (!isPopupOpen) { + return invalid; // No validation when not editing + } + + const suggestionsHaveLoaded = Boolean(secrets || folders || currentWorkspace?.environments); + + if (!suggestionsHaveLoaded) { + return invalid; + } + + // If we have an active suggestion context but no suggestions, it means the query returned empty + const suggestionsAreEmpty = suggestions.length === 0; + + allReferences.forEach((reference) => { + const matchesAnySuggestion = suggestions.some((suggestion) => { + if (!reference.includes(".")) { + return suggestion.slug === reference; + } + const parts = reference.split("."); + const finalPart = parts[parts.length - 1]; + return suggestion.slug === finalPart; + }); + + // Mark as invalid only if: + // 1. We have suggestions loaded AND none match, OR + // 2. The query returned empty results + if (!matchesAnySuggestion && (suggestions.length > 0 || suggestionsAreEmpty)) { + invalid.add(reference); + } + }); + + return invalid; + }, [isPopupOpen, allReferences, suggestions, secrets, folders, currentWorkspace?.environments]); + const handleSuggestionSelect = (selectIndex?: number) => { const selectedSuggestion = suggestions[typeof selectIndex !== "undefined" ? selectIndex : highlightedIndex]; @@ -257,18 +306,20 @@ export const InfisicalSecretInput = forwardRef( }; // to handle multiple ref for single component - const handleRef = useCallback((el: HTMLTextAreaElement) => { - // @ts-expect-error this is for multiple ref single component - inputRef.current = el; - if (ref) { - if (typeof ref === "function") { - ref(el); - } else { - // eslint-disable-next-line - ref.current = el; + const handleRef = useCallback( + (el: HTMLTextAreaElement) => { + inputRef.current = el; + if (ref) { + if (typeof ref === "function") { + ref(el); + } else { + // eslint-disable-next-line no-param-reassign + ref.current = el; + } } - } - }, []); + }, + [ref] + ); return ( @@ -287,6 +338,7 @@ export const InfisicalSecretInput = forwardRef( }} onChange={(e) => onChange?.(e.target.value)} containerClassName={containerClassName} + invalidReferences={invalidReferences} /> { +const syntaxHighlight = ( + content?: string | null, + isVisible?: boolean, + isImport?: boolean, + invalidReferences?: Set +) => { if (isImport && !content) return "IMPORTED"; if (content === "") return "EMPTY"; if (!content) return "EMPTY"; @@ -18,9 +23,18 @@ const syntaxHighlight = (content?: string | null, isVisible?: boolean, isImport? const isInterpolationSyntax = el.startsWith("${") && el.endsWith("}"); if (isInterpolationSyntax) { skipNext = true; + const referenceContent = el.slice(2, -1); + const isInvalid = invalidReferences?.has(referenceContent) ?? false; + return ( - - ${{el.slice(2, -1)} + + ${ + + {referenceContent} + } ); @@ -46,6 +60,7 @@ type Props = TextareaHTMLAttributes & { isDisabled?: boolean; containerClassName?: string; canEditButNotView?: boolean; + invalidReferences?: Set; }; const commonClassName = "font-mono text-sm caret-white border-none outline-none w-full break-all"; @@ -63,6 +78,7 @@ export const SecretInput = forwardRef( isReadOnly, onFocus, canEditButNotView, + invalidReferences, ...props }, ref @@ -81,7 +97,8 @@ export const SecretInput = forwardRef( {syntaxHighlight( value, isVisible || (isSecretFocused && !valueAlwaysHidden), - isImport + isImport, + invalidReferences )} From 7f03f2cf1d9e3f1cb6b8ad4d2c85197adcb27659 Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Wed, 10 Sep 2025 00:20:00 -0300 Subject: [PATCH 02/66] Address PR comments --- .../secret-v2-bridge-service.ts | 118 +++++++++++------- .../components/v2/SecretInput/SecretInput.tsx | 4 +- 2 files changed, 75 insertions(+), 47 deletions(-) diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts index 7ad8ab82c..f5ae60e57 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts @@ -148,7 +148,7 @@ export const secretV2BridgeServiceFactory = ({ keyStore, reminderService }: TSecretV2BridgeServiceFactoryDep) => { - const validateSecretReferences = async ( + const $validateSecretReferences = async ( projectId: string, permission: MongoAbility, references: ReturnType["nestedReferences"], @@ -177,58 +177,71 @@ export const secretV2BridgeServiceFactory = ({ ); const referencesFolderGroupByPath = groupBy(referredFolders.filter(Boolean), (i) => `${i?.envId}-${i?.path}`); + + // Find only references that have valid folders (don't throw for missing paths) + const validReferences = references.filter((el) => { + const folderId = + referencesFolderGroupByPath[`${referencesEnvironmentGroupBySlug[el.environment][0].id}-${el.secretPath}`]?.[0] + ?.id; + return folderId; + }); + + if (validReferences.length === 0) return; + const referredSecrets = await secretDAL.find( { $complex: { operator: "or", - value: references.map((el) => { - const folderId = - referencesFolderGroupByPath[ - `${referencesEnvironmentGroupBySlug[el.environment][0].id}-${el.secretPath}` - ][0]?.id; - if (!folderId) throw new BadRequestError({ message: `Referenced path ${el.secretPath} doesn't exist` }); + value: validReferences + .map((el) => { + const folderGroup = + referencesFolderGroupByPath[ + `${referencesEnvironmentGroupBySlug[el.environment][0].id}-${el.secretPath}` + ]; + if (!folderGroup || !folderGroup[0]) return null; - return { - operator: "and", - value: [ - { - operator: "eq", - field: "folderId", - value: folderId - }, - { - operator: "eq", - field: `${TableName.SecretV2}.key` as "key", - value: el.secretKey - } - ] - }; - }) + const folderId = folderGroup[0].id; + + return { + operator: "and", + value: [ + { + operator: "eq", + field: "folderId", + value: folderId + }, + { + operator: "eq", + field: `${TableName.SecretV2}.key` as "key", + value: el.secretKey + } + ] + }; + }) + .filter((query) => query !== null) as Array<{ + operator: "and"; + value: Array<{ + operator: "eq"; + field: "folderId" | "key"; + value: string; + }>; + }> } }, { tx } ); - if ( - referredSecrets.length !== - new Set(references.map(({ secretKey, secretPath, environment }) => `${secretKey}.${secretPath}.${environment}`)) - .size // only count unique references - ) - throw new BadRequestError({ - message: `Referenced secret(s) not found: ${diff( - references.map((el) => el.secretKey), - referredSecrets.map((el) => el.key) - ).join(",")}` - }); - - const referredSecretsGroupBySecretKey = groupBy(referredSecrets, (i) => i.key); - references.forEach((el) => { - throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret, { - environment: el.environment, - secretPath: el.secretPath, - secretName: el.secretKey, - secretTags: referredSecretsGroupBySecretKey[el.secretKey][0]?.tags?.map((i) => i.slug) - }); + // Only check permissions for secrets that actually exist + referredSecrets.forEach((secret) => { + const reference = validReferences.find((ref) => ref.secretKey === secret.key); + if (reference) { + throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret, { + environment: reference.environment, + secretPath: reference.secretPath, + secretName: reference.secretKey, + secretTags: secret.tags?.map((i) => i.slug) + }); + } }); return referredSecrets; @@ -312,7 +325,12 @@ export const secretV2BridgeServiceFactory = ({ project.secretDetectionIgnoreValues || [] ); - const { nestedReferences } = getAllSecretReferences(inputSecret.secretValue); + const { nestedReferences, localReferences } = getAllSecretReferences(inputSecret.secretValue); + const allSecretReferences = nestedReferences.concat( + localReferences.map((el) => ({ secretKey: el, secretPath, environment })) + ); + + await $validateSecretReferences(projectId, permission, allSecretReferences); const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, @@ -541,6 +559,14 @@ export const secretV2BridgeServiceFactory = ({ ); } + if (secretValue) { + const { nestedReferences, localReferences } = getAllSecretReferences(secretValue); + const allSecretReferences = nestedReferences.concat( + localReferences.map((el) => ({ secretKey: el, secretPath, environment })) + ); + await $validateSecretReferences(projectId, permission, allSecretReferences); + } + const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, projectId @@ -1673,6 +1699,7 @@ export const secretV2BridgeServiceFactory = ({ }); } }); + await $validateSecretReferences(projectId, permission, secretReferences); const { encryptor: secretManagerEncryptor, decryptor: secretManagerDecryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, projectId }); @@ -1986,6 +2013,7 @@ export const secretV2BridgeServiceFactory = ({ }); } }); + await $validateSecretReferences(projectId, permission, secretReferences, tx); const project = await projectDAL.findById(projectId); await scanSecretPolicyViolations( @@ -3136,6 +3164,6 @@ export const secretV2BridgeServiceFactory = ({ getAccessibleSecrets, getSecretVersionsByIds, findSecretIdsByFolderIdAndKeys, - validateSecretReferences + $validateSecretReferences }; }; diff --git a/frontend/src/components/v2/SecretInput/SecretInput.tsx b/frontend/src/components/v2/SecretInput/SecretInput.tsx index 9934c5c4c..c2f557745 100644 --- a/frontend/src/components/v2/SecretInput/SecretInput.tsx +++ b/frontend/src/components/v2/SecretInput/SecretInput.tsx @@ -28,11 +28,11 @@ const syntaxHighlight = ( return ( ${ - + {referenceContent} } From 12a943019f357f0810d57a18f7122eaccdf60964 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Fri, 12 Sep 2025 03:34:12 +0800 Subject: [PATCH 03/66] misc: revamped gateway v2 docs --- docs/cli/commands/relay.mdx | 160 ++++++ docs/docs.json | 3 +- .../platform/gateways/deployment.mdx | 375 ++++++++++++++ .../platform/gateways/networking.mdx | 170 +++++- .../platform/gateways/overview.mdx | 488 +++--------------- .../{gateway-security.mdx => security.mdx} | 90 +++- 6 files changed, 849 insertions(+), 437 deletions(-) create mode 100644 docs/documentation/platform/gateways/deployment.mdx rename docs/documentation/platform/gateways/{gateway-security.mdx => security.mdx} (62%) diff --git a/docs/cli/commands/relay.mdx b/docs/cli/commands/relay.mdx index 46a061da3..66bed96da 100644 --- a/docs/cli/commands/relay.mdx +++ b/docs/cli/commands/relay.mdx @@ -9,6 +9,15 @@ description: "Relay-related commands for Infisical" infisical relay start --type= --host= --name= --auth-method= ``` + + ```bash + # Install systemd service + sudo infisical relay systemd install --type= --host= --name= --token= + + # Uninstall systemd service + sudo infisical relay systemd uninstall + ``` + ## Description @@ -304,3 +313,154 @@ The Infisical CLI supports multiple authentication methods for organization rela - You want full control over your relay infrastructure and its configuration + + + Manage systemd service for Infisical relay. This allows you to install and run the relay as a systemd service on Linux systems. + ### Requirements + - **Operating System**: Linux only (systemd is not supported on other operating systems) + - **Privileges**: Root/sudo privileges required for both install and uninstall operations + - **Systemd**: The system must be running systemd as the init system + +```bash +infisical relay systemd +``` + +### Subcommands + + + Install and enable systemd service for the relay. Must be run with sudo on Linux systems. + +```bash +sudo infisical relay systemd install --type= --host= --name= [flags] +``` + +#### Flags + + + The type of relay to run. Must be either 'instance' or 'org'. + + - **`instance`**: Shared relay server that can be used by all organizations on your Infisical instance. Requires `--relay-auth-secret` flag or `INFISICAL_RELAY_AUTH_SECRET` environment variable. + - **`org`**: Dedicated relay server that individual organizations deploy and manage in their own infrastructure. Requires `--token` flag. + +```bash +# Organization relay +sudo infisical relay systemd install --type=org --host=192.168.1.100 --name=my-org-relay --token= + +# Instance relay +sudo infisical relay systemd install --type=instance --host=10.0.1.50 --name=shared-relay --relay-auth-secret= +``` + + + + + The host (IP address or hostname) of the instance where the relay is deployed. This must be a static public IP or resolvable hostname that gateways can reach. + +```bash +# Example with IP address +sudo infisical relay systemd install --host=203.0.113.100 --type=org --name=my-relay --token= + +# Example with hostname +sudo infisical relay systemd install --host=relay.example.com --type=org --name=my-relay --token= +``` + + + + + The name of the relay. + +```bash +# Example +sudo infisical relay systemd install --name=my-relay --type=org --host=192.168.1.100 --token= +``` + + + + + Connect with Infisical using machine identity access token. Required for organization relays (`--type=org`). + +```bash +# Example +sudo infisical relay systemd install --token= --type=org --host= --name= +``` + + + + + Domain of your self-hosted Infisical instance. Optional flag for specifying a custom domain. + +```bash +# Example +sudo infisical relay systemd install --domain=http://localhost:8080 --token= --type=org --host= --name= +``` + + + + + Relay auth secret. Required for instance relays (`--type=instance`) if the `INFISICAL_RELAY_AUTH_SECRET` environment variable is not set. + +```bash +# Example +sudo infisical relay systemd install --relay-auth-secret= --type=instance --host= --name= +``` + + + +#### Examples + +```bash +# Install organization relay with token authentication +sudo infisical relay systemd install --type=org --host=192.168.1.100 --name=my-org-relay --token= + +# Install instance relay with auth secret +sudo infisical relay systemd install --type=instance --host=10.0.1.50 --name=shared-relay --relay-auth-secret= + +# Install with custom domain +sudo infisical relay systemd install --domain=http://localhost:8080 --token= --type=org --host= --name= +``` + +#### Post-installation + +After successful installation, the service will be enabled but not started. To start the service: + +```bash +sudo systemctl start infisical-relay +``` + +To check the service status: + +```bash +sudo systemctl status infisical-relay +``` + +To view service logs: + +```bash +sudo journalctl -u infisical-relay -f +``` + + + + + Uninstall and remove systemd service for the relay. Must be run with sudo on Linux systems. + +```bash +sudo infisical relay systemd uninstall +``` + +#### Examples + +```bash +# Uninstall the relay systemd service +sudo infisical relay systemd uninstall +``` + +#### What it does + +- Stops the `infisical-relay` systemd service if it's running +- Disables the service from starting on boot +- Removes the systemd service file +- Cleans up the service configuration + + + + diff --git a/docs/docs.json b/docs/docs.json index 285e11a60..c9c2527f4 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -173,8 +173,9 @@ "group": "Gateway", "pages": [ "documentation/platform/gateways/overview", - "documentation/platform/gateways/gateway-security", + "documentation/platform/gateways/deployment", "documentation/platform/gateways/networking", + "documentation/platform/gateways/security", { "group": "Gateway (Deprecated)", "pages": [ diff --git a/docs/documentation/platform/gateways/deployment.mdx b/docs/documentation/platform/gateways/deployment.mdx new file mode 100644 index 000000000..3f392dd22 --- /dev/null +++ b/docs/documentation/platform/gateways/deployment.mdx @@ -0,0 +1,375 @@ +--- +title: "Deployment Guide" +description: "Complete guide to deploying Infisical Gateways and Relays" +--- + +This guide covers everything you need to deploy and configure both Infisical Gateways and Relay Servers, including prerequisites, step-by-step instructions, and troubleshooting. + +## Prerequisites + +Before deploying gateways or relays, ensure you have: + +1. **Machine Identity** - Configured with appropriate permissions to create and manage gateways +2. **Network Access** - Proper connectivity between components +3. **Relay Server** - A running relay server before deploying gateways + +## Deploying Relays + +Choose the relay deployment type that best fits your needs: + +- **Instance Relays** - Use Infisical's managed infrastructure (Infisical Cloud) or shared relays set up by your instance administrator (self-hosted). Best for getting started quickly with minimal operational overhead. + +- **Organization Relays** - Deploy and manage your own relay servers. Choose this for lower latency, custom network policies, compliance requirements, or when you need full control over the relay infrastructure. + + + Most users should start with **Instance Relays** for simplicity. Consider + Organization Relays only if you have specific latency, compliance, or control + requirements. + + + + + **Infisical Cloud:** + - Pre-configured and ready to use + - No setup required, managed by Infisical + + **Self-hosted:** + + Instance relays require **two-step configuration** with matching secrets: + + **Step 1: Instance Admin Configuration** + The Infisical instance administrator must first set the relay auth secret in the instance environment: + + ```bash + # In the Infisical instance environment variables + RELAY_AUTH_SECRET= + ``` + + **Step 2: Relay Deployment** + Deploy the relay using the **same secret value**: + + ```bash + # Set the matching secret for relay deployment + export INFISICAL_RELAY_AUTH_SECRET= + + # Start the instance relay + infisical relay start --type=instance --host= --name= + ``` + + + The `RELAY_AUTH_SECRET` (instance environment) and + `INFISICAL_RELAY_AUTH_SECRET` (relay environment) **must match exactly** for + authentication to work. + + + + + + Deploy your own relay server for full control. Organization relays support all authentication methods: + + ```bash + # Token auth + infisical relay start --type=org --host= --name= --token= + + # Universal auth + infisical relay start --type=org --host= --name= --auth-method=universal-auth --client-id= --client-secret= + + # Other auth methods (kubernetes, aws-iam, gcp-id-token, azure, etc.) + infisical relay start --type=org --host= --name= --auth-method= --machine-identity-id= + ``` + + + + +### Relay Systemd Service (Linux) + +For production deployments, install as a systemd service: + +```bash +# Organization relay +sudo infisical relay systemd install --token= --name= --host= --type=org + +# Instance relay +sudo infisical relay systemd install --type=instance --name= --host= --relay-auth-secret= + +# Start the service +sudo systemctl start infisical-relay + +# Uninstall +sudo infisical relay systemd uninstall +``` + +**Required Network Configuration:** + +- **Inbound SSH** on port 2222 from gateways +- **Inbound TCP with TLS** on port 8443 from Infisical platform +- **Outbound HTTPS** to Infisical API endpoints on port 443 + +## Deploying Gateways + +### Linux Server Deployment + +For production deployments on Linux servers, install the Gateway as a systemd service: + + + **Gateway v2:** The `infisical gateway systemd install` command deploys the new Gateway v2 component. + +If you are migrating from Gateway v1 (legacy `infisical gateway install` command), this is not in-place. Gateway v2 provisions new gateway instances with new gateway IDs. Update any resources that reference a gateway ID (for example: dynamic secret configs, app connections, or other gateway-bound resources) to use the new Gateway v2 gateway ID. + + + +```bash +sudo infisical gateway systemd install --token --domain --name --relay +sudo systemctl start infisical-gateway +``` + +This will install and start the Gateway as a secure systemd service that: + +- Runs with restricted privileges +- Automatically restarts on failure +- Starts on system boot +- Manages token and domain configuration securely in `/etc/infisical/gateway.conf` + + + The systemd install command requires a Linux operating system with root/sudo + privileges. + + +### Kubernetes Cluster Deployment + +For production deployments on Kubernetes clusters, install the Gateway using the Infisical Helm chart: + +#### Install the latest Helm Chart repository + +```bash +helm repo add infisical-helm-charts 'https://dl.cloudsmith.io/public/infisical/helm-charts/helm/charts/' +helm repo update +``` + +#### Create a Kubernetes Secret + +The gateway supports all identity authentication methods through environment variables: + +```bash +kubectl create secret generic infisical-gateway-environment \ + --from-literal=INFISICAL_AUTH_METHOD=universal-auth \ + --from-literal=INFISICAL_UNIVERSAL_AUTH_CLIENT_ID= \ + --from-literal=INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET= \ + --from-literal=INFISICAL_RELAY_NAME= \ + --from-literal=INFISICAL_GATEWAY_NAME= +``` + +#### Install the Gateway + +```bash +helm install infisical-gateway infisical-helm-charts/infisical-gateway +``` + + + **Version mapping:** Helm chart versions `>= 1.0.0` contain the new Gateway v2 component. Helm chart versions `<= 0.0.5` contain the legacy Gateway v1 component. + +If you are moving from Gateway v1 (chart `<= 0.0.5`) to Gateway v2 (chart `>= 1.0.0`), this is not in-place. Gateway v2 provisions new gateway instances with new gateway IDs. Update any resources that reference a gateway ID (for example: dynamic secret configs, app connections, or other gateway-bound resources) to use the new Gateway v2 gateway ID. + + + +### Development & Testing + +For development or testing environments: + +```bash +# Direct execution with token +infisical gateway start --token --relay= --name= + +# Using environment variable +export INFISICAL_TOKEN= +infisical gateway start --relay= --name= +``` + +### Authentication Methods + +The gateway supports multiple authentication methods. Choose the one that best fits your environment: + + + + Simple and secure authentication using client ID and client secret. + + **Environment Variables:** + - `INFISICAL_AUTH_METHOD=universal-auth` + - `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID=` + - `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET=` + + + + Direct authentication using a machine identity access token. + + **Environment Variables:** + - `INFISICAL_TOKEN=` + + + + Authentication using Kubernetes service account tokens. + + **Environment Variables:** + - `INFISICAL_AUTH_METHOD=kubernetes` + - `INFISICAL_MACHINE_IDENTITY_ID=` + + + + Authentication using AWS IAM roles. + + **Environment Variables:** + - `INFISICAL_AUTH_METHOD=aws-iam` + - `INFISICAL_MACHINE_IDENTITY_ID=` + + + + Authentication using GCP identity tokens. + + **Environment Variables:** + - `INFISICAL_AUTH_METHOD=gcp-id-token` + - `INFISICAL_MACHINE_IDENTITY_ID=` + + + + Authentication using GCP service account keys. + + **Environment Variables:** + - `INFISICAL_AUTH_METHOD=gcp-iam` + - `INFISICAL_MACHINE_IDENTITY_ID=` + - `INFISICAL_GCP_SERVICE_ACCOUNT_KEY_FILE_PATH=` + + + + Authentication using Azure managed identity. + + **Environment Variables:** + - `INFISICAL_AUTH_METHOD=azure` + - `INFISICAL_MACHINE_IDENTITY_ID=` + + + + Authentication using OIDC identity tokens. + + **Environment Variables:** + - `INFISICAL_AUTH_METHOD=oidc-auth` + - `INFISICAL_MACHINE_IDENTITY_ID=` + - `INFISICAL_JWT=` + + + + Authentication using JWT tokens. + + **Environment Variables:** + - `INFISICAL_AUTH_METHOD=jwt-auth` + - `INFISICAL_MACHINE_IDENTITY_ID=` + - `INFISICAL_JWT=` + + + +### Environment Variables + +In addition to authentication, you must set these required variables: + +- `INFISICAL_RELAY_NAME` - The name of the relay server to connect to +- `INFISICAL_GATEWAY_NAME` - A unique name for this gateway instance + +Optional variables: + +- `INFISICAL_API_URL` - The Infisical API URL (defaults to `https://app.infisical.com`) + +## Verification + +After deployment, verify your gateway is working: + +1. **Check logs** for "Gateway started successfully" message +2. **Verify registration** in the Infisical dashboard under Organization Access Control > Gateways +3. **Test connectivity** by creating a dynamic secret or app connection + +## Best Practices + +### Gateway Security + +- **One identity per gateway** - Create a unique machine identity for each gateway +- **Minimal permissions** - Assign only the permissions each gateway actually needs +- **Rotate credentials** - Set up regular credential rotation +- **Monitor authentication** - Watch for unusual authentication activity + +### Relay Security + +- **Secure secret management** - For instance relays, ensure `RELAY_AUTH_SECRET` and `INFISICAL_RELAY_AUTH_SECRET` are stored securely +- **Network isolation** - Use security groups and network ACLs to restrict relay access +- **Regular updates** - Keep relay servers updated and patched +- **Monitor traffic** - Watch for unusual connection patterns or traffic volume + +### Performance & Reliability + +- **Deploy close to resources** - Place gateways as close as possible to your private resources +- **Deploy relays strategically** - For organization relays, deploy close to gateways to minimize latency +- **Use multiple instances** - Deploy multiple gateways and relays for redundancy +- **Monitor resource usage** - Track CPU, memory, and network usage for both gateways and relays +- **Health monitoring** - Set up monitoring for both gateway and relay connection status +- **Automatic restart** - Use systemd or Kubernetes to automatically restart failed services + +## Troubleshooting + +### Common Issues + +**Gateway fails to start:** + +- Verify machine identity has correct permissions +- Check network connectivity to relay server +- Ensure all required environment variables are set + +**Cannot connect to relay:** + +- Verify relay server is running and accessible +- Check firewall rules allow outbound connections on port 2222 +- Confirm relay name matches exactly + +**Authentication failures:** + +- Verify machine identity credentials are correct +- Check token expiration and renewal +- Ensure authentication method is properly configured + +**Relay not accessible from gateways:** + +- Check firewall rules allow inbound SSH on port 2222 +- Verify public IP is correctly configured +- Test connectivity: `telnet 2222` + +**Platform cannot connect to relay:** + +- Check firewall rules allow inbound TCP with TLS on port 8443 +- Verify SSL certificate is properly configured +- Test connectivity: `openssl s_client -connect :8443` + +### Logs + +Check gateway logs for detailed error information: + +```bash +# systemd service +sudo journalctl -u infisical-gateway -f + +# Kubernetes +kubectl logs deployment/infisical-gateway + +# Local installation +# Logs appear in the terminal where you started the gateway +``` + +### Network Testing + +Test relay connectivity: + +```bash +# Test SSH port from gateway +nc -zv 2222 + +# Test TCP with TLS port from platform +openssl s_client -connect :8443 + +# Test outbound API access +curl -I https://app.infisical.com +``` diff --git a/docs/documentation/platform/gateways/networking.mdx b/docs/documentation/platform/gateways/networking.mdx index 2b068a535..5d6e235a7 100644 --- a/docs/documentation/platform/gateways/networking.mdx +++ b/docs/documentation/platform/gateways/networking.mdx @@ -1,21 +1,20 @@ --- -title: "Networking" -description: "Network configuration and firewall requirements for Infisical Gateway" +title: "Networking Requirements" +description: "Network configuration and firewall requirements for Infisical Gateways and Relays" --- -The Infisical Gateway requires outbound network connectivity to establish secure SSH reverse tunnels with relay servers. -This page outlines the required ports, protocols, and firewall configurations needed for optimal gateway usage. +This page outlines the required ports, protocols, and firewall configurations needed for optimal gateway and relay usage. ## Network Architecture -The gateway uses SSH reverse tunnels to establish secure connections with end-to-end encryption: +The gateway system uses SSH reverse tunnels to establish secure connections with end-to-end encryption: 1. **Gateway** connects outbound to **Relay Servers** using SSH over TCP 2. **Infisical platform** establishes mTLS connections with gateways for application traffic 3. **Relay Servers** route the doubly-encrypted traffic (mTLS payload within SSH tunnels) between the platform and gateways 4. **Double encryption** ensures relay servers cannot access application data - only the platform and gateway can decrypt traffic -## Required Network Connectivity +## Gateway Network Requirements ### Outbound Connections (Required) @@ -55,6 +54,141 @@ The gateway requires the following outbound connectivity: +## Relay Server Network Requirements + +### Inbound Connections (Required) + +The relay server must accept the following inbound connections: + +| Protocol | Source | Port | Purpose | +| -------- | ------------------ | ---- | -------------------------------- | +| TCP | Gateways | 2222 | SSH reverse tunnel establishment | +| TCP | Infisical Platform | 8443 | Platform-to-relay communication | + +### Outbound Connections (Required) + +The relay server requires outbound connectivity to: + +| Protocol | Destination | Port | Purpose | +| -------- | ------------------------------------ | ---- | ------------------------------------------ | +| TCP | app.infisical.com / eu.infisical.com | 443 | API communication and certificate requests | + +## Firewall Configuration + +### Gateway Firewall Rules + +Since gateways only make outbound connections, you only need simple outbound rules: + +1. **Allow outbound TCP** to relay servers (IP addresses or hostnames) on port 2222 +2. **Allow outbound HTTPS** to Infisical API endpoints on port 443 +3. **No inbound rules required** - all connections are outbound only + +### Relay Firewall Rules + +Configure your firewall to allow: + +1. **SSH on port 2222** - For gateway connections + + ```bash + # Example iptables rule + iptables -A INPUT -p tcp --dport 2222 -j ACCEPT + ``` + +2. **TCP with TLS on port 8443** - For platform connections + + ```bash + # Example iptables rule + iptables -A INPUT -p tcp --dport 8443 -j ACCEPT + ``` + +3. **HTTPS to Infisical API** - For certificate requests and API communication + ```bash + # Example iptables rule + iptables -A OUTPUT -p tcp --dport 443 -d app.infisical.com -j ACCEPT + ``` + +## Cloud Provider Configuration + +### AWS EC2 + +Configure security groups: + +```json +{ + "SecurityGroupRules": [ + { + "IpProtocol": "tcp", + "FromPort": 2222, + "ToPort": 2222, + "CidrIpv4": "0.0.0.0/0", + "Description": "SSH for gateway connections" + }, + { + "IpProtocol": "tcp", + "FromPort": 8443, + "ToPort": 8443, + "CidrIpv4": "0.0.0.0/0", + "Description": "TCP with TLS for platform connections" + } + ] +} +``` + +### Google Cloud Platform + +Configure firewall rules: + +```bash +# Allow SSH from gateways +gcloud compute firewall-rules create allow-gateway-ssh \ + --allow tcp:2222 \ + --source-ranges 0.0.0.0/0 \ + --description "Allow SSH from gateways" + +# Allow TCP with TLS from platform +gcloud compute firewall-rules create allow-platform-tls \ + --allow tcp:8443 \ + --source-ranges 0.0.0.0/0 \ + --description "Allow TCP with TLS from platform" +``` + +### Azure + +Configure Network Security Groups: + +```json +{ + "securityRules": [ + { + "name": "AllowGatewaySSH", + "properties": { + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "2222", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 100, + "direction": "Inbound" + } + }, + { + "name": "AllowPlatformTLS", + "properties": { + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "8443", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 110, + "direction": "Inbound" + } + } + ] +} +``` + ## Protocol Details ### SSH over TCP @@ -65,10 +199,6 @@ The gateway uses SSH reverse tunnels for primary communication: - **Built-in features**: Automatic reconnection, certificate-based authentication, encrypted tunneling - **Encryption**: SSH with certificate-based authentication and key exchange -## Firewall Configuration for SSH - -The gateway uses standard SSH over TCP, making firewall configuration straightforward. - ### TCP Connection Handling SSH connections over TCP are stateful and handled seamlessly by all modern firewalls: @@ -78,14 +208,6 @@ SSH connections over TCP are stateful and handled seamlessly by all modern firew - **No special configuration** needed for connection tracking - **Standard SSH protocol** that enterprise firewalls handle well -### Simplified Firewall Rules - -Since SSH uses TCP, you only need simple outbound rules: - -1. **Allow outbound TCP** to relay servers (IP addresses or hostnames) on port 2222 -2. **Allow outbound HTTPS** to Infisical API endpoints on port 443 -3. **No inbound rules required** - all connections are outbound only - ## Common Network Scenarios ### Corporate Firewalls @@ -105,6 +227,15 @@ Configure security groups to allow: - **Outbound HTTPS** to app.infisical.com/eu.infisical.com on port 443 - **No inbound rules required** - SSH reverse tunnels are outbound only +## Performance Considerations + +### Network Optimization + +- **Deploy close to gateways** - Reduce latency by placing relay servers geographically close to your gateways +- **Use high-bandwidth connections** - Ensure adequate bandwidth for encrypted traffic +- **Monitor network performance** - Track latency and throughput metrics +- **Consider multiple relays** - Deploy multiple relay servers for redundancy and load distribution + ## Frequently Asked Questions @@ -134,7 +265,7 @@ TCP's reliability and firewall compatibility make it ideal for enterprise enviro -No inbound ports need to be opened. The gateway only makes outbound connections: +No inbound ports need to be opened for gateways. The gateway only makes outbound connections: - **Outbound SSH** to relay servers on port 2222 - **Outbound HTTPS** to Infisical API endpoints on port 443 @@ -163,6 +294,7 @@ The gateway connects to **one relay server**: - **Certificate-based authentication**: Each connection uses SSH certificates issued by Infisical for secure authentication + No, relay servers cannot decrypt any traffic passing through them due to end-to-end encryption: diff --git a/docs/documentation/platform/gateways/overview.mdx b/docs/documentation/platform/gateways/overview.mdx index b8ea0102a..84ca46d5f 100644 --- a/docs/documentation/platform/gateways/overview.mdx +++ b/docs/documentation/platform/gateways/overview.mdx @@ -1,5 +1,5 @@ --- -title: "Gateway" +title: "Gateway Overview" sidebarTitle: "Overview" description: "How to access private network resources from Infisical" --- @@ -8,13 +8,6 @@ description: "How to access private network resources from Infisical" The Infisical Gateway provides secure access to private resources within your network without needing direct inbound connections to your environment. This method keeps your resources fully protected from external access while enabling Infisical to securely interact with resources like databases. -**Architecture Components:** - -- **Gateway**: Lightweight agent deployed within your VPCs that provides access to private resources -- **Relay**: Infrastructure that routes encrypted traffic (instance-wide or organization-specific) - -Common use cases include generating dynamic credentials or rotating credentials for private databases. - Gateway is a paid feature available under the Enterprise Tier for Infisical Cloud users. Self-hosted Infisical users can contact @@ -22,6 +15,26 @@ Common use cases include generating dynamic credentials or rotating credentials license. +## What are Gateways and Relays? + +### Gateway + +A lightweight service deployed within your VPCs that provides secure access to private resources: + +- **Runs inside your network** - Deployed within your VPCs, data centers, or on-premises infrastructure +- **Makes outbound connections only** - No inbound firewall rules required +- **Provides secure access** - Uses SSH reverse tunnels and certificate-based authentication +- **Handles resource connections** - Connects to your private databases, APIs, and services + +### Relay Server + +Infrastructure that routes encrypted traffic between the Infisical platform and gateways: + +- **Routes encrypted traffic** - Routes traffic between the platform and gateways +- **Provides network isolation** - Enables communication without direct connections +- **Handles authentication** - Manages SSH certificate validation and routing +- **Ensures security** - Uses double encryption to protect data in transit + ## How It Works The Gateway system uses SSH reverse tunnels for secure, firewall-friendly connectivity: @@ -37,7 +50,62 @@ The Gateway system uses SSH reverse tunnels for secure, firewall-friendly connec - **Certificate-based authentication** provides enhanced security - **Automatic reconnection** if connections are lost -## Deployment +## Relay Types + +### Instance Relays (Shared Infrastructure) + +**Infisical Cloud:** + +- Pre-configured and ready to use +- No setup required +- Shared across all organizations +- Managed by Infisical + +**Self-hosted:** + +- Set up by instance administrators +- Shared across all organizations on the instance +- Uses `INFISICAL_RELAY_AUTH_SECRET` for authentication + +### Organization Relays (Customer-Deployed) + +**Benefits:** + +- Full control over infrastructure +- Lower latency (deploy closer to resources) +- Enhanced security and compliance +- Custom network policies + +**Authentication:** + +- Uses standard Infisical authentication methods +- Organization-specific credentials +- Full control over access and permissions + +## When to Use Each + +**Use Instance Relays when:** + +- You want minimal operational overhead +- You don't need custom network policies +- You're okay with shared infrastructure +- You want to get started quickly + +**Use Organization Relays when:** + +- You need lower latency +- You have security or compliance requirements +- You need custom network policies +- You want full control over infrastructure + +## Common Use Cases + +- **Database credential rotation** - Automatically rotate database passwords +- **Dynamic secret generation** - Generate temporary credentials for services +- **Private API access** - Connect to internal APIs and services +- **Compliance requirements** - Meet data sovereignty and air-gapped environment needs + +## Quick Start The Infisical Gateway is integrated into the Infisical CLI under the `gateway` command, making it simple to deploy and manage. You can install the Gateway in all the same ways you install the Infisical CLI—whether via npm, Docker, or a binary. @@ -45,405 +113,15 @@ For detailed installation instructions, refer to the Infisical [CLI Installation **Prerequisites:** -1. **Relay Server**: Before deploying gateways, you need a running relay server: - - **Infisical Cloud**: Instance relays are already available - no setup needed - - **Self-hosted**: Instance admin must set up shared instance relays, or organizations can deploy their own +1. **Relay Server**: Before deploying gateways, you need a running relay server 2. **Machine Identity**: Configure a machine identity with appropriate permissions to create and manage gateways Once authenticated, the Gateway establishes an SSH reverse tunnel to the specified relay server, allowing secure access to your private resources. -### Get started +## Next Steps - - - 1. Navigate to **Organization Access Control** in your Infisical dashboard. - 2. Create a dedicated machine identity for your Gateway. - 3. **Best Practice:** Assign a unique identity to each Gateway for better security and management. - ![Create Gateway Identity](../../../images/platform/gateways/create-identity-for-gateway.png) - - - - You'll need to choose an authentication method to initiate communication with Infisical. View the available machine identity authentication methods [here](/documentation/platform/identities/machine-identities). - +Ready to get started? Follow these guides: - - You have two options for relay infrastructure: - - - - **Infisical Cloud:** Instance relays are already running and available - **no setup required**. You can immediately proceed to deploy gateways using these shared relays. - - **Self-hosted:** If your instance admin has set up shared instance relays, you can use them directly. If not, the instance admin can set them up: - ```bash - # Instance admin sets up shared relay (one-time setup) - export INFISICAL_RELAY_AUTH_SECRET= - infisical relay start --type=instance --ip= --name= - ``` - - - **Available for all users:** Deploy your own dedicated relay infrastructure for enhanced control: - ```bash - # Deploy organization-specific relay - infisical relay start --type=org --ip= --name= --auth-method=universal-auth --client-id= --client-secret= - ``` - - **When to choose this:** - - You need lower latency (deploy closer to your resources) - - Enhanced security requirements - - Compliance needs (data sovereignty, air-gapped environments) - - Custom network policies - - - - - - Use the Infisical CLI to deploy the Gateway. You can run it directly or install it as a systemd service for production: - - - - For production deployments on Linux, install the Gateway as a systemd service: - - - **Gateway v2:** The `infisical gateway systemd install` command deploys the new Gateway v2 component. - - If you are migrating from Gateway v1 (legacy `infisical gateway install` command), this is not in-place. Gateway v2 provisions new gateway instances with new gateway IDs. Update any resources that reference a gateway ID (for example: dynamic secret configs, app connections, or other gateway-bound resources) to use the new Gateway v2 gateway ID. - - - ```bash - sudo infisical gateway systemd install --token --domain --name --relay - sudo systemctl start infisical-gateway - ``` - This will install and start the Gateway as a secure systemd service that: - - Runs with restricted privileges: - - Runs as root user (required for secure token management) - - Restricted access to home directories - - Private temporary directory - - Automatically restarts on failure - - Starts on system boot - - Manages token and domain configuration securely in `/etc/infisical/gateway.conf` - - - The install command requires: - - Linux operating system - - Root/sudo privileges - - Systemd - - - - - - The Gateway can be installed via [Helm](https://helm.sh/). Helm is a package manager for Kubernetes that allows you to define, install, and upgrade Kubernetes applications. - - For production deployments on Kubernetes, install the Gateway using the Infisical Helm chart: - - ### Install the latest Helm Chart repository - ```bash - helm repo add infisical-helm-charts 'https://dl.cloudsmith.io/public/infisical/helm-charts/helm/charts/' - ``` - - ### Update the Helm Chart repository - ```bash - helm repo update - ``` - - ### Create a Kubernetes Secret containing gateway environment variables - - The gateway supports all identity authentication methods through the use of environment variables. - The environment variables must be set in the `infisical-gateway-environment` Kubernetes secret. - - - #### Supported authentication methods - - - - The Universal Auth method is a simple and secure way to authenticate with Infisical. It requires a client ID and a client secret to authenticate with Infisical. - - - - - Your machine identity client ID. - - - Your machine identity client secret. - - - The authentication method to use. Must be `universal-auth` when using Universal Auth. - - - - - ```bash - kubectl create secret generic infisical-gateway-environment \ - --from-literal=INFISICAL_AUTH_METHOD=universal-auth \ - --from-literal=INFISICAL_UNIVERSAL_AUTH_CLIENT_ID= \ - --from-literal=INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET= \ - --from-literal=INFISICAL_RELAY_NAME= \ - --from-literal=INFISICAL_GATEWAY_NAME= - ``` - - - - The Native Kubernetes method is used to authenticate with Infisical when running in a Kubernetes environment. It requires a service account token to authenticate with Infisical. - - - - - Your machine identity ID. - - - Path to the Kubernetes service account token to use. Default: `/var/run/secrets/kubernetes.io/serviceaccount/token`. - - - The authentication method to use. Must be `kubernetes` when using Native Kubernetes. - - - - - - ```bash - kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=kubernetes --from-literal=INFISICAL_MACHINE_IDENTITY_ID= - ``` - - - - The Native Azure method is used to authenticate with Infisical when running in an Azure environment. - - - - - Your machine identity ID. - - - The authentication method to use. Must be `azure` when using Native Azure. - - - - - ```bash - kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=azure --from-literal=INFISICAL_MACHINE_IDENTITY_ID= - ``` - - - The Native GCP ID Token method is used to authenticate with Infisical when running in a GCP environment. - - - - - Your machine identity ID. - - - The authentication method to use. Must be `gcp-id-token` when using Native GCP ID Token. - - - - - ```bash - kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=gcp-id-token --from-literal=INFISICAL_MACHINE_IDENTITY_ID= - ``` - - - - The GCP IAM method is used to authenticate with Infisical with a GCP service account key. - - - - - Your machine identity ID. - - - Path to your GCP service account key file _(Must be in JSON format!)_ - - - The authentication method to use. Must be `gcp-iam` when using GCP IAM. - - - - - ```bash - kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=gcp-iam --from-literal=INFISICAL_MACHINE_IDENTITY_ID= --from-literal=INFISICAL_GCP_SERVICE_ACCOUNT_KEY_FILE_PATH= - ``` - - - - - The AWS IAM method is used to authenticate with Infisical with an AWS IAM role while running in an AWS environment like EC2, Lambda, etc. - - - - - Your machine identity ID. - - - The authentication method to use. Must be `aws-iam` when using Native AWS IAM. - - - - - ```bash - kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=aws-iam --from-literal=INFISICAL_MACHINE_IDENTITY_ID= - ``` - - - - The OIDC Auth method is used to authenticate with Infisical via identity tokens with OIDC. - - - - - Your machine identity ID. - - - The OIDC JWT from the identity provider. - - - The authentication method to use. Must be `oidc-auth` when using OIDC Auth. - - - - - ```bash - kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=oidc-auth --from-literal=INFISICAL_MACHINE_IDENTITY_ID= --from-literal=INFISICAL_JWT= - ``` - - - - The JWT Auth method is used to authenticate with Infisical via a JWT token. - - - - - The JWT token to use for authentication. - - - Your machine identity ID. - - - The authentication method to use. Must be `jwt-auth` when using JWT Auth. - - - - - ```bash - kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_AUTH_METHOD=jwt-auth --from-literal=INFISICAL_JWT= --from-literal=INFISICAL_MACHINE_IDENTITY_ID= - ``` - - - You can use the `INFISICAL_TOKEN` environment variable to authenticate with Infisical with a raw machine identity access token. - - - - - The machine identity access token to use for authentication. - - - - - ```bash - kubectl create secret generic infisical-gateway-environment --from-literal=INFISICAL_TOKEN= - ``` - - - - - #### Required environment variables - - In addition to the authentication method above, you **must** include these required variables: - - - - The name of the relay server that this gateway should connect to. - - - The name of this gateway instance. - - - - **Complete example with required variables:** - ```bash - kubectl create secret generic infisical-gateway-environment \ - --from-literal=INFISICAL_AUTH_METHOD=universal-auth \ - --from-literal=INFISICAL_UNIVERSAL_AUTH_CLIENT_ID= \ - --from-literal=INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET= \ - --from-literal=INFISICAL_RELAY_NAME= \ - --from-literal=INFISICAL_GATEWAY_NAME= - ``` - - #### Other environment variables - - - - The API URL to use for the gateway. By default, `INFISICAL_API_URL` is set to `https://app.infisical.com`. - - - - ### Install the Infisical Gateway Helm Chart - - **Version mapping:** Helm chart versions `>= 1.0.0` contain the new Gateway v2 component. Helm chart versions `<= 0.0.5` contain the legacy Gateway v1 component. - - If you are moving from Gateway v1 (chart `<= 0.0.5`) to Gateway v2 (chart `>= 1.0.0`), this is not in-place. Gateway v2 provisions new gateway instances with new gateway IDs. Update any resources that reference a gateway ID (for example: dynamic secret configs, app connections, or other gateway-bound resources) to use the new Gateway v2 gateway ID. - - - ```bash - helm install infisical-gateway infisical-helm-charts/infisical-gateway - ``` - - ### Check the gateway logs - After installing the gateway, you can check the logs to ensure it's running as expected. - - ```bash - kubectl logs deployment/infisical-gateway - ``` - - You should see the following output which indicates the gateway is running as expected. - ```bash - $ kubectl logs deployment/infisical-gateway - 12:43AM INF Starting gateway - 12:43AM INF Starting gateway certificate renewal goroutine - 12:43AM INF Successfully registered gateway and received certificates - 12:43AM INF Connecting to relay server infisical-start on 152.42.218.156:2222... - 12:43AM INF Relay connection established for gateway - 12:43AM INF Received incoming connection, starting TLS handshake - 12:43AM INF TLS handshake completed successfully - 12:43AM INF Negotiated ALPN protocol: infisical-ping - 12:43AM INF Starting ping handler - 12:43AM INF Ping handler completed - 12:43AM INF Gateway is reachable by Infisical - ``` - - - - - For development or testing, you can run the Gateway directly. Log in with your machine identity and start the Gateway in one command: - ```bash - infisical gateway start --token $(infisical login --method=universal-auth --client-id=<> --client-secret=<> --plain) --relay= --name= - ``` - - Alternatively, if you already have the token, use it directly with the `--token` flag: - ```bash - infisical gateway start --token --relay= --name= - ``` - - Or set it as an environment variable: - ```bash - export INFISICAL_TOKEN= - infisical gateway start --relay= --name= - ``` - - - - For detailed information about the gateway commands and their options, see the [gateway command documentation](/cli/commands/gateway). - - - **Requirements:** - - Ensure the deployed Gateway has network access to the private resources you intend to connect with Infisical - - The gateway must be able to reach the relay server (outbound connection only) - - Replace `` with the name of your relay server and `` with a unique name for this gateway - - - - - - To confirm your Gateway is working, check the deployment status by looking for the message **"Gateway started successfully"** in the Gateway logs. This indicates the Gateway is running properly. Next, verify its registration by opening your Infisical dashboard, navigating to **Organization Access Control**, and selecting the **Gateways** tab. Your newly deployed Gateway should appear in the list. - ![Gateway List](../../../images/platform/gateways/gateway-list.png) - - +1. **[Deployment Guide](/documentation/platform/gateways/deployment)** - Complete deployment instructions +2. **[Networking Requirements](/documentation/platform/gateways/networking)** - Network configuration and firewall setup +3. **[Security Architecture](/documentation/platform/gateways/security)** - Security model and best practices diff --git a/docs/documentation/platform/gateways/gateway-security.mdx b/docs/documentation/platform/gateways/security.mdx similarity index 62% rename from docs/documentation/platform/gateways/gateway-security.mdx rename to docs/documentation/platform/gateways/security.mdx index 6962c627e..db2c25f40 100644 --- a/docs/documentation/platform/gateways/gateway-security.mdx +++ b/docs/documentation/platform/gateways/security.mdx @@ -1,13 +1,9 @@ --- -title: "Gateway Security Architecture" -sidebarTitle: "Architecture" -description: "Understand the security model and tenant isolation of Infisical's Gateway" +title: "Security Architecture" +description: "Security model, tenant isolation, and best practices for Infisical Gateways and Relays" --- -# Gateway Security Architecture - The Infisical Gateway enables secure access to private resources using SSH reverse tunnels, certificate-based authentication, and a comprehensive PKI (Public Key Infrastructure) system. The architecture provides end-to-end encryption and complete tenant isolation through multiple certificate authorities. -This document explains the internal security architecture and how tenant isolation is maintained. ## Security Model Overview @@ -82,16 +78,16 @@ The platform establishes secure direct connections with gateways through a **TLS 2. **Connection Flow**: ``` - Platform ←→ [SSH Reverse Tunnel] ←→ Gateway + Platform ←→ [TCP with TLS] ←→ Relay ←→ [SSH Reverse Tunnel] ←→ Gateway ``` - Gateway maintains persistent outbound SSH tunnel to relay server - - Platform connects directly to gateway through this tunnel - - TLS handshake occurs over the SSH tunnel, establishing mTLS connection - - Application traffic flows through the TLS-pinned tunnel + - Platform connects to relay server using TCP with TLS + - Relay routes encrypted traffic between platform and gateway + - TLS handshake occurs between platform and gateway through the relay + - Application traffic flows through the TLS-pinned tunnel via relay routing 3. **Security Benefits**: - - **No inbound connections**: Gateway never needs to accept incoming connections - **Certificate-based authentication**: Uses Organization Gateway certificates for mutual TLS - **Double encryption**: TLS traffic within SSH tunnel provides layered security @@ -132,7 +128,77 @@ The architecture provides tenant isolation through multiple certificate authorit - Ephemeral certificate validation ensures time-bound access 2. **Network Isolation**: - - Each organization's traffic flows through isolated certificate-authenticated channels - Relay servers route traffic based on certificate validation without content access - Gateway validates all incoming connections against Organization Gateway Client CA + +## Security Best Practices + +### Gateway Security + +- **One identity per gateway** - Create a unique machine identity for each gateway +- **Minimal permissions** - Assign only the permissions each gateway actually needs +- **Rotate credentials** - Set up regular credential rotation (quarterly recommended) +- **Monitor authentication** - Watch for unusual authentication activity +- **Deploy close to resources** - Place gateways as close as possible to your private resources +- **Use multiple gateways** - Deploy multiple gateways for redundancy and load distribution + +### Relay Security + +- **Deploy close to gateways** - Minimize latency between gateways and relays +- **Use multiple regions** - Deploy relays in multiple regions for redundancy +- **Monitor performance** - Track connection counts and throughput +- **Regular updates** - Keep relay servers updated and patched +- **Network isolation** - Use security groups and network ACLs to restrict access +- **Monitor network traffic** - Watch for unusual patterns + +### Network Security + +- **Gateway outbound only** - Gateways only need outbound connections (port 2222 to relays, port 443 to Infisical API) +- **Relay inbound** - Relays need inbound SSH (2222) and TCP with TLS (8443) +- **No inbound for gateways** - Never open inbound ports for gateways +- **Use security groups** in cloud environments to restrict access +- **Implement network ACLs** to limit traffic to specific IP ranges +- **Monitor network traffic** for unusual patterns +- **Regular security updates** for the relay server + +## Compliance Considerations + +### Data Sovereignty + +- **Customer-deployed relays** - Deploy relays in your preferred geographic region +- **Air-gapped environments** - Use self-hosted relays for completely isolated deployments +- **Custom network policies** - Implement organization-specific security requirements + +### Security Controls + +- **Certificate-based authentication** - No shared secrets, uses SSH certificates +- **End-to-end encryption** - All traffic is encrypted in transit +- **Tenant isolation** - Each organization's traffic is completely isolated +- **No data storage** - Relay servers only route traffic, never store data +- **Automatic reconnection** - Handles network interruptions gracefully + +## Troubleshooting Security Issues + +### Common Security Issues + +**Authentication failures:** + +- Verify machine identity credentials are correct +- Check token expiration and renewal +- Ensure authentication method is properly configured + +**Network security issues:** + +- Verify firewall rules allow required connections +- Check for network ACLs blocking traffic +- Monitor for unusual network patterns + +### Security Monitoring + +**Key metrics to monitor:** + +- Gateway connection status and health +- Authentication success/failure rates +- Network connectivity and latency +- Unusual traffic patterns or access attempts From 499946c042777e9f4507a8b6fe8da3e8fc05bedb Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Fri, 12 Sep 2025 21:09:13 +0800 Subject: [PATCH 04/66] misc: addressed comments --- docs/cli/commands/gateway.mdx | 2 +- docs/cli/commands/relay.mdx | 2 +- docs/docs.json | 4 +- ...{deployment.mdx => gateway-deployment.mdx} | 262 ++++++----- .../platform/gateways/networking.mdx | 310 ------------- .../platform/gateways/overview.mdx | 87 ++-- .../platform/gateways/relay-deployment.mdx | 424 ++++++++++++++++++ 7 files changed, 586 insertions(+), 505 deletions(-) rename docs/documentation/platform/gateways/{deployment.mdx => gateway-deployment.mdx} (50%) delete mode 100644 docs/documentation/platform/gateways/networking.mdx create mode 100644 docs/documentation/platform/gateways/relay-deployment.mdx diff --git a/docs/cli/commands/gateway.mdx b/docs/cli/commands/gateway.mdx index 99d0e1086..4ae660cf5 100644 --- a/docs/cli/commands/gateway.mdx +++ b/docs/cli/commands/gateway.mdx @@ -9,7 +9,7 @@ description: "Run the Infisical gateway or manage its systemd service" infisical gateway start --name= --relay= --auth-method= ``` - + ```bash sudo infisical gateway systemd install --token= --domain= --name= --relay= ``` diff --git a/docs/cli/commands/relay.mdx b/docs/cli/commands/relay.mdx index 66bed96da..7530ee99d 100644 --- a/docs/cli/commands/relay.mdx +++ b/docs/cli/commands/relay.mdx @@ -9,7 +9,7 @@ description: "Relay-related commands for Infisical" infisical relay start --type= --host= --name= --auth-method= ``` - + ```bash # Install systemd service sudo infisical relay systemd install --type= --host= --name= --token= diff --git a/docs/docs.json b/docs/docs.json index c9c2527f4..9eeeea500 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -173,8 +173,8 @@ "group": "Gateway", "pages": [ "documentation/platform/gateways/overview", - "documentation/platform/gateways/deployment", - "documentation/platform/gateways/networking", + "documentation/platform/gateways/gateway-deployment", + "documentation/platform/gateways/relay-deployment", "documentation/platform/gateways/security", { "group": "Gateway (Deprecated)", diff --git a/docs/documentation/platform/gateways/deployment.mdx b/docs/documentation/platform/gateways/gateway-deployment.mdx similarity index 50% rename from docs/documentation/platform/gateways/deployment.mdx rename to docs/documentation/platform/gateways/gateway-deployment.mdx index 3f392dd22..f939744c3 100644 --- a/docs/documentation/platform/gateways/deployment.mdx +++ b/docs/documentation/platform/gateways/gateway-deployment.mdx @@ -1,120 +1,27 @@ --- -title: "Deployment Guide" -description: "Complete guide to deploying Infisical Gateways and Relays" +title: "Gateway Deployment" +description: "Complete guide to deploying Infisical Gateways including network configuration and firewall requirements" --- -This guide covers everything you need to deploy and configure both Infisical Gateways and Relay Servers, including prerequisites, step-by-step instructions, and troubleshooting. +This guide covers everything you need to deploy and configure Infisical Gateways, including prerequisites, step-by-step deployment instructions, network configuration, and troubleshooting. ## Prerequisites -Before deploying gateways or relays, ensure you have: +Before deploying gateways, ensure you have: 1. **Machine Identity** - Configured with appropriate permissions to create and manage gateways -2. **Network Access** - Proper connectivity between components -3. **Relay Server** - A running relay server before deploying gateways +2. **Relay Server** - A running relay server before deploying gateways. This can be either a managed relay (like Infisical's US/EU relays) or your own self-deployed relay server (see [Relay Deployment Guide](/documentation/platform/gateways/relay-deployment)) -## Deploying Relays +## Gateway Deployment Methods -Choose the relay deployment type that best fits your needs: - -- **Instance Relays** - Use Infisical's managed infrastructure (Infisical Cloud) or shared relays set up by your instance administrator (self-hosted). Best for getting started quickly with minimal operational overhead. - -- **Organization Relays** - Deploy and manage your own relay servers. Choose this for lower latency, custom network policies, compliance requirements, or when you need full control over the relay infrastructure. - - - Most users should start with **Instance Relays** for simplicity. Consider - Organization Relays only if you have specific latency, compliance, or control - requirements. - - - - - **Infisical Cloud:** - - Pre-configured and ready to use - - No setup required, managed by Infisical - - **Self-hosted:** - - Instance relays require **two-step configuration** with matching secrets: - - **Step 1: Instance Admin Configuration** - The Infisical instance administrator must first set the relay auth secret in the instance environment: - - ```bash - # In the Infisical instance environment variables - RELAY_AUTH_SECRET= - ``` - - **Step 2: Relay Deployment** - Deploy the relay using the **same secret value**: - - ```bash - # Set the matching secret for relay deployment - export INFISICAL_RELAY_AUTH_SECRET= - - # Start the instance relay - infisical relay start --type=instance --host= --name= - ``` - - - The `RELAY_AUTH_SECRET` (instance environment) and - `INFISICAL_RELAY_AUTH_SECRET` (relay environment) **must match exactly** for - authentication to work. - - - - - - Deploy your own relay server for full control. Organization relays support all authentication methods: - - ```bash - # Token auth - infisical relay start --type=org --host= --name= --token= - - # Universal auth - infisical relay start --type=org --host= --name= --auth-method=universal-auth --client-id= --client-secret= - - # Other auth methods (kubernetes, aws-iam, gcp-id-token, azure, etc.) - infisical relay start --type=org --host= --name= --auth-method= --machine-identity-id= - ``` - - - - -### Relay Systemd Service (Linux) - -For production deployments, install as a systemd service: - -```bash -# Organization relay -sudo infisical relay systemd install --token= --name= --host= --type=org - -# Instance relay -sudo infisical relay systemd install --type=instance --name= --host= --relay-auth-secret= - -# Start the service -sudo systemctl start infisical-relay - -# Uninstall -sudo infisical relay systemd uninstall -``` - -**Required Network Configuration:** - -- **Inbound SSH** on port 2222 from gateways -- **Inbound TCP with TLS** on port 8443 from Infisical platform -- **Outbound HTTPS** to Infisical API endpoints on port 443 - -## Deploying Gateways - -### Linux Server Deployment +### Linux Server Deployment (Production) For production deployments on Linux servers, install the Gateway as a systemd service: **Gateway v2:** The `infisical gateway systemd install` command deploys the new Gateway v2 component. -If you are migrating from Gateway v1 (legacy `infisical gateway install` command), this is not in-place. Gateway v2 provisions new gateway instances with new gateway IDs. Update any resources that reference a gateway ID (for example: dynamic secret configs, app connections, or other gateway-bound resources) to use the new Gateway v2 gateway ID. +If you are migrating from Gateway v1 (legacy `infisical gateway install` command), this will create a completely new gateway rather than upgrading your existing one. Gateway v2 provisions new gateway instances with new gateway IDs. You'll need to update any resources that reference the old gateway ID (for example: dynamic secret configs, app connections, or other gateway-bound resources) to use the new Gateway v2 gateway ID. @@ -135,7 +42,7 @@ This will install and start the Gateway as a secure systemd service that: privileges. -### Kubernetes Cluster Deployment +### Kubernetes Cluster Deployment (Production) For production deployments on Kubernetes clusters, install the Gateway using the Infisical Helm chart: @@ -168,7 +75,7 @@ helm install infisical-gateway infisical-helm-charts/infisical-gateway **Version mapping:** Helm chart versions `>= 1.0.0` contain the new Gateway v2 component. Helm chart versions `<= 0.0.5` contain the legacy Gateway v1 component. -If you are moving from Gateway v1 (chart `<= 0.0.5`) to Gateway v2 (chart `>= 1.0.0`), this is not in-place. Gateway v2 provisions new gateway instances with new gateway IDs. Update any resources that reference a gateway ID (for example: dynamic secret configs, app connections, or other gateway-bound resources) to use the new Gateway v2 gateway ID. +If you are moving from Gateway v1 (chart `<= 0.0.5`) to Gateway v2 (chart `>= 1.0.0`), this will create a completely new gateway rather than upgrading your existing one. Gateway v2 provisions new gateway instances with new gateway IDs. You'll need to update any resources that reference the old gateway ID (for example: dynamic secret configs, app connections, or other gateway-bound resources) to use the new Gateway v2 gateway ID. @@ -185,7 +92,7 @@ export INFISICAL_TOKEN= infisical gateway start --relay= --name= ``` -### Authentication Methods +## Authentication Methods The gateway supports multiple authentication methods. Choose the one that best fits your environment: @@ -266,7 +173,7 @@ The gateway supports multiple authentication methods. Choose the one that best f -### Environment Variables +## Environment Variables In addition to authentication, you must set these required variables: @@ -277,6 +184,72 @@ Optional variables: - `INFISICAL_API_URL` - The Infisical API URL (defaults to `https://app.infisical.com`) +## Network Configuration + +### Gateway Network Requirements + +The gateway uses SSH reverse tunnels to establish secure connections with end-to-end encryption: + +1. **Gateway** connects outbound to **Relay Servers** using SSH over TCP +2. **Infisical platform** establishes mTLS connections with gateways via the relay servers for application traffic +3. **Relay Servers** route the doubly-encrypted traffic (mTLS payload within SSH tunnels) between the platform and gateways +4. **Double encryption** ensures relay servers cannot access application data - only the platform and gateway can decrypt traffic + +### Outbound Connections (Required) + +The gateway requires the following outbound connectivity: + +| Protocol | Destination | Ports | Purpose | +| -------- | ------------------------------------ | ----- | ------------------------------------------ | +| TCP | Relay Servers | 2222 | SSH reverse tunnel establishment | +| TCP | app.infisical.com / eu.infisical.com | 443 | API communication and certificate requests | + +### Relay Server Connectivity + +Your gateway needs to connect to a relay server on port 2222. The specific relay server depends on your setup: + +**Managed Relays (Infisical Cloud):** + +- Use Infisical's pre-configured relay servers in US/EU regions +- Static IP addresses available for firewall whitelisting +- Allow outbound TCP connections to the relay server IP on port 2222 + +**Self-Deployed or Instance Relays:** + +- Connect to your own relay server or relay servers deployed by your instance administrator +- Allow outbound TCP connections to your relay server IP/hostname on port 2222 +- Example: If your relay is at `203.0.113.100`, allow TCP to `203.0.113.100:2222` +- For instance relays, contact your admin for the specific relay server addresses + +## Firewall Configuration + +### Gateway Firewall Rules + +Since gateways only make outbound connections, you only need simple outbound rules: + +1. **Allow outbound TCP** to relay servers (IP addresses or hostnames) on port 2222 +2. **Allow outbound HTTPS** to Infisical API endpoints on port 443 +3. **No inbound rules required** - all connections are outbound only + +### Common Network Scenarios + +#### Corporate Firewalls + +For corporate environments with strict egress filtering: + +1. **Allow outbound TCP** to relay servers (IP addresses or hostnames) on port 2222 +2. **Allow outbound HTTPS** to the Infisical API server on port 443 +3. **No inbound rules required** - all connections are outbound only +4. **Standard TCP rules** - simple and straightforward configuration + +#### Cloud Environments (AWS/GCP/Azure) + +Configure security groups to allow: + +- **Outbound TCP** to relay servers (IP addresses or hostnames) on port 2222 +- **Outbound HTTPS** to app.infisical.com/eu.infisical.com on port 443 +- **No inbound rules required** - SSH reverse tunnels are outbound only + ## Verification After deployment, verify your gateway is working: @@ -294,20 +267,12 @@ After deployment, verify your gateway is working: - **Rotate credentials** - Set up regular credential rotation - **Monitor authentication** - Watch for unusual authentication activity -### Relay Security - -- **Secure secret management** - For instance relays, ensure `RELAY_AUTH_SECRET` and `INFISICAL_RELAY_AUTH_SECRET` are stored securely -- **Network isolation** - Use security groups and network ACLs to restrict relay access -- **Regular updates** - Keep relay servers updated and patched -- **Monitor traffic** - Watch for unusual connection patterns or traffic volume - ### Performance & Reliability - **Deploy close to resources** - Place gateways as close as possible to your private resources -- **Deploy relays strategically** - For organization relays, deploy close to gateways to minimize latency -- **Use multiple instances** - Deploy multiple gateways and relays for redundancy -- **Monitor resource usage** - Track CPU, memory, and network usage for both gateways and relays -- **Health monitoring** - Set up monitoring for both gateway and relay connection status +- **Use multiple instances** - Deploy multiple gateways for redundancy +- **Monitor resource usage** - Track CPU, memory, and network usage for gateways +- **Health monitoring** - Set up monitoring for gateway connection status - **Automatic restart** - Use systemd or Kubernetes to automatically restart failed services ## Troubleshooting @@ -332,18 +297,6 @@ After deployment, verify your gateway is working: - Check token expiration and renewal - Ensure authentication method is properly configured -**Relay not accessible from gateways:** - -- Check firewall rules allow inbound SSH on port 2222 -- Verify public IP is correctly configured -- Test connectivity: `telnet 2222` - -**Platform cannot connect to relay:** - -- Check firewall rules allow inbound TCP with TLS on port 8443 -- Verify SSL certificate is properly configured -- Test connectivity: `openssl s_client -connect :8443` - ### Logs Check gateway logs for detailed error information: @@ -361,15 +314,58 @@ kubectl logs deployment/infisical-gateway ### Network Testing -Test relay connectivity: +Test relay connectivity from gateway: ```bash -# Test SSH port from gateway +# Test SSH port to relay nc -zv 2222 -# Test TCP with TLS port from platform -openssl s_client -connect :8443 - # Test outbound API access curl -I https://app.infisical.com ``` + +## Frequently Asked Questions + + +The gateway is designed to handle network interruptions gracefully: + +- **Automatic reconnection**: The gateway will automatically attempt to reconnect to relay servers if the SSH connection is lost +- **Connection retry logic**: Built-in retry mechanisms handle temporary network outages without manual intervention +- **Persistent SSH tunnels**: SSH connections are automatically re-established when connectivity is restored +- **Certificate rotation**: The gateway handles certificate renewal automatically during reconnection +- **Graceful degradation**: The gateway logs connection issues and continues attempting to restore connectivity + +No manual intervention is typically required during network interruptions. + + + + +No inbound ports need to be opened for gateways. The gateway only makes outbound connections: + +- **Outbound SSH** to relay servers on port 2222 +- **Outbound HTTPS** to Infisical API endpoints on port 443 +- **SSH reverse tunnels** handle all communication - no return traffic configuration needed + +This design maintains security by avoiding the need for inbound firewall rules that could expose your network to external threats. + + + + +If your firewall has strict outbound restrictions: + +1. **Work with your network team** to allow outbound TCP connections on port 2222 to relay servers (IP addresses or hostnames) +2. **Allow standard SSH traffic** - most enterprises already have SSH policies in place +3. **Consider network policy exceptions** for the gateway host if needed +4. **Monitor firewall logs** to identify which specific rules are blocking traffic + + + + +The gateway connects to **one relay server**: + +- **Single SSH connection**: Each gateway establishes one SSH reverse tunnel to its assigned relay server +- **Named relay assignment**: Gateways connect to the specific relay server specified by `--relay` +- **Automatic reconnection**: If the relay connection is lost, the gateway automatically reconnects to the same relay +- **Certificate-based authentication**: Each connection uses SSH certificates issued by Infisical for secure authentication + + diff --git a/docs/documentation/platform/gateways/networking.mdx b/docs/documentation/platform/gateways/networking.mdx deleted file mode 100644 index 5d6e235a7..000000000 --- a/docs/documentation/platform/gateways/networking.mdx +++ /dev/null @@ -1,310 +0,0 @@ ---- -title: "Networking Requirements" -description: "Network configuration and firewall requirements for Infisical Gateways and Relays" ---- - -This page outlines the required ports, protocols, and firewall configurations needed for optimal gateway and relay usage. - -## Network Architecture - -The gateway system uses SSH reverse tunnels to establish secure connections with end-to-end encryption: - -1. **Gateway** connects outbound to **Relay Servers** using SSH over TCP -2. **Infisical platform** establishes mTLS connections with gateways for application traffic -3. **Relay Servers** route the doubly-encrypted traffic (mTLS payload within SSH tunnels) between the platform and gateways -4. **Double encryption** ensures relay servers cannot access application data - only the platform and gateway can decrypt traffic - -## Gateway Network Requirements - -### Outbound Connections (Required) - -The gateway requires the following outbound connectivity: - -| Protocol | Destination | Ports | Purpose | -| -------- | ------------------------------------ | ----- | ------------------------------------------ | -| TCP | Relay Servers | 2222 | SSH reverse tunnel establishment | -| TCP | app.infisical.com / eu.infisical.com | 443 | API communication and certificate requests | - -### Relay Server Connectivity - -**For Instance Relays (Infisical Cloud):** Your firewall must allow outbound connectivity to Infisical-managed relay servers. - -**For Organization Relays:** Your firewall must allow outbound connectivity to your own relay server IP addresses or hostnames. - -**For Self-hosted Instance Relays:** Your firewall must allow outbound connectivity to relay servers configured by your instance administrator. - - - - Infisical provides multiple managed relay servers with static IP addresses. - You can whitelist these IPs ahead of time based on which relay server you - choose to connect to. **Firewall requirements:** Allow outbound TCP - connections to the desired relay server IP on port 2222. - - - You control the relay server IP addresses or hostnames when deploying your - own organization relays. **Firewall requirements:** Allow outbound TCP - connections to your relay server IP or hostname on port 2222. For example, - if your relay is at `203.0.113.100` or `relay.example.com`, allow TCP to - `203.0.113.100:2222` or `relay.example.com:2222`. - - - Contact your instance administrator for the relay server IP addresses or - hostnames configured for your deployment. **Firewall requirements:** Allow - outbound TCP connections to instance relay servers on port 2222. - - - -## Relay Server Network Requirements - -### Inbound Connections (Required) - -The relay server must accept the following inbound connections: - -| Protocol | Source | Port | Purpose | -| -------- | ------------------ | ---- | -------------------------------- | -| TCP | Gateways | 2222 | SSH reverse tunnel establishment | -| TCP | Infisical Platform | 8443 | Platform-to-relay communication | - -### Outbound Connections (Required) - -The relay server requires outbound connectivity to: - -| Protocol | Destination | Port | Purpose | -| -------- | ------------------------------------ | ---- | ------------------------------------------ | -| TCP | app.infisical.com / eu.infisical.com | 443 | API communication and certificate requests | - -## Firewall Configuration - -### Gateway Firewall Rules - -Since gateways only make outbound connections, you only need simple outbound rules: - -1. **Allow outbound TCP** to relay servers (IP addresses or hostnames) on port 2222 -2. **Allow outbound HTTPS** to Infisical API endpoints on port 443 -3. **No inbound rules required** - all connections are outbound only - -### Relay Firewall Rules - -Configure your firewall to allow: - -1. **SSH on port 2222** - For gateway connections - - ```bash - # Example iptables rule - iptables -A INPUT -p tcp --dport 2222 -j ACCEPT - ``` - -2. **TCP with TLS on port 8443** - For platform connections - - ```bash - # Example iptables rule - iptables -A INPUT -p tcp --dport 8443 -j ACCEPT - ``` - -3. **HTTPS to Infisical API** - For certificate requests and API communication - ```bash - # Example iptables rule - iptables -A OUTPUT -p tcp --dport 443 -d app.infisical.com -j ACCEPT - ``` - -## Cloud Provider Configuration - -### AWS EC2 - -Configure security groups: - -```json -{ - "SecurityGroupRules": [ - { - "IpProtocol": "tcp", - "FromPort": 2222, - "ToPort": 2222, - "CidrIpv4": "0.0.0.0/0", - "Description": "SSH for gateway connections" - }, - { - "IpProtocol": "tcp", - "FromPort": 8443, - "ToPort": 8443, - "CidrIpv4": "0.0.0.0/0", - "Description": "TCP with TLS for platform connections" - } - ] -} -``` - -### Google Cloud Platform - -Configure firewall rules: - -```bash -# Allow SSH from gateways -gcloud compute firewall-rules create allow-gateway-ssh \ - --allow tcp:2222 \ - --source-ranges 0.0.0.0/0 \ - --description "Allow SSH from gateways" - -# Allow TCP with TLS from platform -gcloud compute firewall-rules create allow-platform-tls \ - --allow tcp:8443 \ - --source-ranges 0.0.0.0/0 \ - --description "Allow TCP with TLS from platform" -``` - -### Azure - -Configure Network Security Groups: - -```json -{ - "securityRules": [ - { - "name": "AllowGatewaySSH", - "properties": { - "protocol": "Tcp", - "sourcePortRange": "*", - "destinationPortRange": "2222", - "sourceAddressPrefix": "*", - "destinationAddressPrefix": "*", - "access": "Allow", - "priority": 100, - "direction": "Inbound" - } - }, - { - "name": "AllowPlatformTLS", - "properties": { - "protocol": "Tcp", - "sourcePortRange": "*", - "destinationPortRange": "8443", - "sourceAddressPrefix": "*", - "destinationAddressPrefix": "*", - "access": "Allow", - "priority": 110, - "direction": "Inbound" - } - } - ] -} -``` - -## Protocol Details - -### SSH over TCP - -The gateway uses SSH reverse tunnels for primary communication: - -- **Port 2222**: SSH connection to relay servers -- **Built-in features**: Automatic reconnection, certificate-based authentication, encrypted tunneling -- **Encryption**: SSH with certificate-based authentication and key exchange - -### TCP Connection Handling - -SSH connections over TCP are stateful and handled seamlessly by all modern firewalls: - -- **Established connections** are automatically tracked -- **Return traffic** is allowed for established outbound connections -- **No special configuration** needed for connection tracking -- **Standard SSH protocol** that enterprise firewalls handle well - -## Common Network Scenarios - -### Corporate Firewalls - -For corporate environments with strict egress filtering: - -1. **Allow outbound TCP** to relay servers (IP addresses or hostnames) on port 2222 -2. **Allow outbound HTTPS** to the Infisical API server on port 443 -3. **No inbound rules required** - all connections are outbound only -4. **Standard TCP rules** - simple and straightforward configuration - -### Cloud Environments (AWS/GCP/Azure) - -Configure security groups to allow: - -- **Outbound TCP** to relay servers (IP addresses or hostnames) on port 2222 -- **Outbound HTTPS** to app.infisical.com/eu.infisical.com on port 443 -- **No inbound rules required** - SSH reverse tunnels are outbound only - -## Performance Considerations - -### Network Optimization - -- **Deploy close to gateways** - Reduce latency by placing relay servers geographically close to your gateways -- **Use high-bandwidth connections** - Ensure adequate bandwidth for encrypted traffic -- **Monitor network performance** - Track latency and throughput metrics -- **Consider multiple relays** - Deploy multiple relay servers for redundancy and load distribution - -## Frequently Asked Questions - - -The gateway is designed to handle network interruptions gracefully: - -- **Automatic reconnection**: The gateway will automatically attempt to reconnect to relay servers if the SSH connection is lost -- **Connection retry logic**: Built-in retry mechanisms handle temporary network outages without manual intervention -- **Persistent SSH tunnels**: SSH connections are automatically re-established when connectivity is restored -- **Certificate rotation**: The gateway handles certificate renewal automatically during reconnection -- **Graceful degradation**: The gateway logs connection issues and continues attempting to restore connectivity - -No manual intervention is typically required during network interruptions. - - - - -SSH over TCP provides several advantages for enterprise gateway communication: - -- **Firewall-friendly**: TCP is stateful and handled seamlessly by all enterprise firewalls -- **Standard protocol**: SSH is a well-established protocol that network teams are familiar with -- **Certificate-based security**: Uses SSH certificates for strong authentication without shared secrets -- **Automatic tunneling**: SSH reverse tunnels handle all the complexity of secure communication -- **Enterprise compatibility**: Works reliably across all enterprise network configurations - -TCP's reliability and firewall compatibility make it ideal for enterprise environments where network policies are strictly managed. - - - - -No inbound ports need to be opened for gateways. The gateway only makes outbound connections: - -- **Outbound SSH** to relay servers on port 2222 -- **Outbound HTTPS** to Infisical API endpoints on port 443 -- **SSH reverse tunnels** handle all communication - no return traffic configuration needed - -This design maintains security by avoiding the need for inbound firewall rules that could expose your network to external threats. - - - - -If your firewall has strict outbound restrictions: - -1. **Work with your network team** to allow outbound TCP connections on port 2222 to relay servers (IP addresses or hostnames) -2. **Allow standard SSH traffic** - most enterprises already have SSH policies in place -3. **Consider network policy exceptions** for the gateway host if needed -4. **Monitor firewall logs** to identify which specific rules are blocking traffic - - - - -The gateway connects to **one relay server**: - -- **Single SSH connection**: Each gateway establishes one SSH reverse tunnel to its assigned relay server -- **Named relay assignment**: Gateways connect to the specific relay server specified by `--relay` -- **Automatic reconnection**: If the relay connection is lost, the gateway automatically reconnects to the same relay -- **Certificate-based authentication**: Each connection uses SSH certificates issued by Infisical for secure authentication - - - - -No, relay servers cannot decrypt any traffic passing through them due to end-to-end encryption: - -- **Client-to-Gateway mTLS (via TLS-pinned tunnel)**: Clients connect via a proxy that establishes a TLS-pinned tunnel to the gateway; mTLS between the client and gateway is negotiated inside this tunnel, encrypting all application traffic -- **SSH tunnel encryption**: The mTLS-encrypted traffic is then transmitted through SSH reverse tunnels to relay servers -- **Double encryption**: Traffic is encrypted twice - once by client mTLS and again by SSH tunnels -- **Relay only routes traffic**: The relay server only routes the doubly-encrypted traffic without access to either encryption layer -- **No data storage**: Relay servers do not store any traffic or sensitive information -- **Certificate isolation**: Each connection uses unique certificates, ensuring complete tenant isolation - -The relay infrastructure is designed as a secure routing mechanism where only the client and gateway can decrypt the actual application traffic. - - diff --git a/docs/documentation/platform/gateways/overview.mdx b/docs/documentation/platform/gateways/overview.mdx index 84ca46d5f..266f2ad16 100644 --- a/docs/documentation/platform/gateways/overview.mdx +++ b/docs/documentation/platform/gateways/overview.mdx @@ -50,78 +50,49 @@ The Gateway system uses SSH reverse tunnels for secure, firewall-friendly connec - **Certificate-based authentication** provides enhanced security - **Automatic reconnection** if connections are lost -## Relay Types +## Understanding Relay Types -### Instance Relays (Shared Infrastructure) +Relays are the routing infrastructure that enables secure communication between Infisical and your gateways. There are two main approaches to relay deployment: -**Infisical Cloud:** +**Managed Relays** - Use Infisical's hosted relay infrastructure in US/EU regions for quick setup with minimal operational overhead. -- Pre-configured and ready to use -- No setup required -- Shared across all organizations -- Managed by Infisical +**Self-Deployed Relays** - Deploy your own relay servers for regional proximity, enhanced control, and custom network policies. -**Self-hosted:** +### Managed Relays (Recommended for Most Users) -- Set up by instance administrators -- Shared across all organizations on the instance -- Uses `INFISICAL_RELAY_AUTH_SECRET` for authentication +Managed relays are pre-configured relay servers hosted by Infisical that can serve multiple organizations with minimal operational overhead. -### Organization Relays (Customer-Deployed) +**Infisical Cloud (US/EU Regions):** -**Benefits:** +- Pre-configured relays in US and EU regions +- No setup or maintenance required +- Shared across all Infisical Cloud organizations +- Managed and monitored by Infisical +- Best for getting started quickly -- Full control over infrastructure -- Lower latency (deploy closer to resources) -- Enhanced security and compliance -- Custom network policies +**Self-Hosted Instance Relays:** -**Authentication:** +- Instance administrators can deploy shared relays for their entire instance +- All organizations on the instance can use these relays +- Reduces operational burden for individual Organizations +- Ideal for self-hosted instances wanting shared relay infrastructure +### Self-Deployed Relays (Organization-Specific) + +Organizations can deploy and manage their own dedicated relay servers for regional proximity, network control, compliance requirements, or enhanced security. + +**Key Benefits:** + +- Deploy in any region or cloud provider for lower latency +- Custom network configurations and security policies +- Dedicated resources not shared with other organizations +- Full control over relay infrastructure - Uses standard Infisical authentication methods -- Organization-specific credentials -- Full control over access and permissions - -## When to Use Each - -**Use Instance Relays when:** - -- You want minimal operational overhead -- You don't need custom network policies -- You're okay with shared infrastructure -- You want to get started quickly - -**Use Organization Relays when:** - -- You need lower latency -- You have security or compliance requirements -- You need custom network policies -- You want full control over infrastructure - -## Common Use Cases - -- **Database credential rotation** - Automatically rotate database passwords -- **Dynamic secret generation** - Generate temporary credentials for services -- **Private API access** - Connect to internal APIs and services -- **Compliance requirements** - Meet data sovereignty and air-gapped environment needs - -## Quick Start - -The Infisical Gateway is integrated into the Infisical CLI under the `gateway` command, making it simple to deploy and manage. -You can install the Gateway in all the same ways you install the Infisical CLI—whether via npm, Docker, or a binary. -For detailed installation instructions, refer to the Infisical [CLI Installation instructions](/cli/overview). - -**Prerequisites:** - -1. **Relay Server**: Before deploying gateways, you need a running relay server -2. **Machine Identity**: Configure a machine identity with appropriate permissions to create and manage gateways - -Once authenticated, the Gateway establishes an SSH reverse tunnel to the specified relay server, allowing secure access to your private resources. ## Next Steps Ready to get started? Follow these guides: -1. **[Deployment Guide](/documentation/platform/gateways/deployment)** - Complete deployment instructions -2. **[Networking Requirements](/documentation/platform/gateways/networking)** - Network configuration and firewall setup +1. **[Gateway Deployment](/documentation/platform/gateways/gateway-deployment)** - Complete gateway deployment and network configuration +2. **[Relay Deployment](/documentation/platform/gateways/relay-deployment)** - Complete relay deployment and network configuration 3. **[Security Architecture](/documentation/platform/gateways/security)** - Security model and best practices diff --git a/docs/documentation/platform/gateways/relay-deployment.mdx b/docs/documentation/platform/gateways/relay-deployment.mdx new file mode 100644 index 000000000..c90576d46 --- /dev/null +++ b/docs/documentation/platform/gateways/relay-deployment.mdx @@ -0,0 +1,424 @@ +--- +title: "Relay Deployment" +description: "Complete guide to deploying Infisical Relay Servers including network configuration and firewall requirements" +--- + +This guide covers everything you need to deploy and configure Infisical Relay Servers, including prerequisites, step-by-step deployment instructions, network configuration, and troubleshooting. + +## Prerequisites + +Before deploying relays, ensure you have: + +1. **Server Infrastructure** - A server or cloud instance to host the relay +2. **Network Connectivity** - Ability to accept inbound connections on ports 2222 and 8443, plus outbound HTTPS access to Infisical API +3. **Machine Identity** - Only required for organization relays; configured with appropriate permissions to create and manage relays + + + **Instance relays** don't require machine identities - they use a shared + secret configured by the instance administrator. + + +## Understanding Relay Types + +Relays are the routing infrastructure that enables secure communication between Infisical and your gateways. There are two main approaches to relay deployment: + +**Managed Relays** - Use Infisical's hosted relay infrastructure in US/EU regions for quick setup with minimal operational overhead. + +**Self-Deployed Relays** - Deploy your own relay servers for regional proximity, enhanced control, and custom network policies. + +### Managed Relays (Recommended for Most Users) + +Managed relays are pre-configured relay servers hosted by Infisical that can serve multiple organizations with minimal operational overhead. + +**Infisical Cloud (US/EU Regions):** + +- Pre-configured relays in US and EU regions +- No setup or maintenance required +- Shared across all Infisical Cloud organizations +- Managed and monitored by Infisical +- Best for getting started quickly + +**Self-Hosted Instance Relays:** + +- Instance administrators can deploy shared relays for their entire instance +- All organizations on the instance can use these relays +- Reduces operational burden for individual Organizations +- Ideal for self-hosted instances wanting shared relay infrastructure + +### Self-Deployed Relays (Organization-Specific) + +Organizations can deploy and manage their own dedicated relay servers for regional proximity, network control, compliance requirements, or enhanced security. + +**Key Benefits:** + +- Deploy in any region or cloud provider for lower latency +- Custom network configurations and security policies +- Dedicated resources not shared with other organizations +- Full control over relay infrastructure +- Uses standard Infisical authentication methods + +## Relay Deployment Methods + +If you've decided to deploy your own relay servers, choose the deployment type that best fits your needs: + +- **Instance Relays** - Deploy shared relays for your entire self-hosted Infisical instance. All organizations on the instance can use these relays without deploying their own. + +- **Organization Relays** - Deploy and manage your own dedicated relay servers. Choose this for lower latency, custom network policies, compliance requirements, or when you need full control over the relay infrastructure. + + + **Note:** If you're using Infisical Cloud, you likely don't need to deploy + relays at all - you can use the managed US/EU relays. This guide is for users + who need to deploy their own relay infrastructure. + + + + + Instance relays require **two-step configuration** with matching secrets: + + **Step 1: Instance Admin Configuration** + + The Infisical instance administrator must first set the relay auth secret in the instance environment: + + ```bash + # In the Infisical instance environment variables + RELAY_AUTH_SECRET= + ``` + + **Step 2: Relay Deployment** + + Deploy the relay using the **same secret value**: + + ```bash + # Set the matching secret for relay deployment + export INFISICAL_RELAY_AUTH_SECRET= + + # Start the instance relay + infisical relay start --type=instance --host= --name= + ``` + + **Install as daemon (Linux only):** + + ```bash + # Instance relay + sudo infisical relay systemd install --type=instance --name= --host= --relay-auth-secret= + + # Start the service + sudo systemctl start infisical-relay + + # Uninstall + sudo infisical relay systemd uninstall + ``` + + + The `RELAY_AUTH_SECRET` (instance environment) and + `INFISICAL_RELAY_AUTH_SECRET` (relay environment) **must match exactly** for + authentication to work. + + + + + + Deploy your own relay server for full control. Organization relays support all authentication methods: + + ```bash + # Token auth + infisical relay start --type=org --host= --name= --token= + + # Universal auth + infisical relay start --type=org --host= --name= --auth-method=universal-auth --client-id= --client-secret= + + # Other auth methods (kubernetes, aws-iam, gcp-id-token, azure, etc.) + infisical relay start --type=org --host= --name= --auth-method= --machine-identity-id= + ``` + + **Install as daemon (Linux only):** + + ```bash + # Organization relay + sudo infisical relay systemd install --token= --name= --host= --type=org + + # Start the service + sudo systemctl start infisical-relay + + # Uninstall + sudo infisical relay systemd uninstall + ``` + + + + +## Network Configuration + +### Relay Server Network Requirements + +The relay server must accept inbound connections and make outbound connections to function properly: + +#### Inbound Connections (Required) + +The relay server must accept the following inbound connections: + +| Protocol | Source | Port | Purpose | +| -------- | ------------------ | ---- | -------------------------------- | +| TCP | Gateways | 2222 | SSH reverse tunnel establishment | +| TCP | Infisical Platform | 8443 | Platform-to-relay communication | + +#### Outbound Connections (Required) + +The relay server requires outbound connectivity to: + +| Protocol | Destination | Port | Purpose | +| -------- | ------------------------------------ | ---- | ------------------------------------------ | +| TCP | app.infisical.com / eu.infisical.com | 443 | API communication and certificate requests | + +## Firewall Configuration + +### Relay Firewall Rules + +Configure your firewall to allow: + +1. **SSH on port 2222** - For gateway connections + + ```bash + # Example iptables rule + iptables -A INPUT -p tcp --dport 2222 -j ACCEPT + ``` + +2. **TCP with TLS on port 8443** - For platform connections + + ```bash + # Example iptables rule + iptables -A INPUT -p tcp --dport 8443 -j ACCEPT + ``` + +3. **HTTPS to Infisical API** - For certificate requests and API communication + ```bash + # Example iptables rule + iptables -A OUTPUT -p tcp --dport 443 -d app.infisical.com -j ACCEPT + ``` + +## Cloud Provider Configuration + +### AWS EC2 + +Configure security groups: + +```json +{ + "SecurityGroupRules": [ + { + "IpProtocol": "tcp", + "FromPort": 2222, + "ToPort": 2222, + "CidrIpv4": "0.0.0.0/0", + "Description": "SSH for gateway connections" + }, + { + "IpProtocol": "tcp", + "FromPort": 8443, + "ToPort": 8443, + "CidrIpv4": "0.0.0.0/0", + "Description": "TCP with TLS for platform connections" + } + ] +} +``` + +### Google Cloud Platform + +Configure firewall rules: + +```bash +# Allow SSH from gateways +gcloud compute firewall-rules create allow-gateway-ssh \ + --allow tcp:2222 \ + --source-ranges 0.0.0.0/0 \ + --description "Allow SSH from gateways" + +# Allow TCP with TLS from platform +gcloud compute firewall-rules create allow-platform-tls \ + --allow tcp:8443 \ + --source-ranges 0.0.0.0/0 \ + --description "Allow TCP with TLS from platform" +``` + +### Azure + +Configure Network Security Groups: + +```json +{ + "securityRules": [ + { + "name": "AllowGatewaySSH", + "properties": { + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "2222", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 100, + "direction": "Inbound" + } + }, + { + "name": "AllowPlatformTLS", + "properties": { + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "8443", + "sourceAddressPrefix": "*", + "destinationAddressPrefix": "*", + "access": "Allow", + "priority": 110, + "direction": "Inbound" + } + } + ] +} +``` + +## Protocol Details + +### SSH over TCP + +The gateway uses SSH reverse tunnels for primary communication: + +- **Port 2222**: SSH connection from gateways to relay servers +- **Built-in features**: Automatic reconnection, certificate-based authentication, encrypted tunneling +- **Encryption**: SSH with certificate-based authentication and key exchange + +### TCP Connection Handling + +SSH connections over TCP are stateful and handled seamlessly by all modern firewalls: + +- **Established connections** are automatically tracked +- **Return traffic** is allowed for established outbound connections +- **No special configuration** needed for connection tracking +- **Standard SSH protocol** that enterprise firewalls handle well + +## Performance Considerations + +### Network Optimization + +- **Deploy close to gateways** - Reduce latency by placing relay servers geographically close to your gateways +- **Use high-bandwidth connections** - Ensure adequate bandwidth for encrypted traffic +- **Monitor network performance** - Track latency and throughput metrics +- **Consider multiple relays** - Deploy multiple relay servers for redundancy and load distribution + +## Best Practices + +### Relay Security + +- **Secure secret management** - For instance relays, ensure `RELAY_AUTH_SECRET` and `INFISICAL_RELAY_AUTH_SECRET` are stored securely +- **Network isolation** - Use security groups and network ACLs to restrict relay access +- **Regular updates** - Keep relay servers updated and patched +- **Monitor traffic** - Watch for unusual connection patterns or traffic volume + +### Performance & Reliability + +- **Deploy relays strategically** - Deploy close to gateways to minimize latency +- **Use multiple instances** - Deploy multiple relay servers for redundancy +- **Monitor resource usage** - Track CPU, memory, and network usage for relays +- **Health monitoring** - Set up monitoring for relay connection status +- **Automatic restart** - Use systemd to automatically restart failed services + +## Troubleshooting + +### Common Issues + +**Relay not accessible from gateways:** + +- Check firewall rules allow inbound SSH on port 2222 +- Verify public IP is correctly configured +- Test connectivity: `telnet 2222` + +**Platform cannot connect to relay:** + +- Check firewall rules allow inbound TCP with TLS on port 8443 +- Verify SSL certificate is properly configured +- Test connectivity: `openssl s_client -connect :8443` + +**Authentication failures:** + +- For instance relays: Verify `RELAY_AUTH_SECRET` matches `INFISICAL_RELAY_AUTH_SECRET` +- For organization relays: Check machine identity credentials and permissions +- Ensure authentication method is properly configured + +### Logs + +Check relay logs for detailed error information: + +```bash +# systemd service +sudo journalctl -u infisical-relay -f + +# Local installation +# Logs appear in the terminal where you started the relay +``` + +### Network Testing + +Test relay connectivity: + +```bash +# Test SSH port from gateway +nc -zv 2222 + +# Test TCP with TLS port from platform +openssl s_client -connect :8443 + +# Test outbound API access +curl -I https://app.infisical.com +``` + +## Frequently Asked Questions + + +No, relay servers cannot decrypt any traffic passing through them due to end-to-end encryption: + +- **Client-to-Gateway mTLS (via TLS-pinned tunnel)**: Clients connect via a proxy that establishes a TLS-pinned tunnel to the gateway; mTLS between the client and gateway is negotiated inside this tunnel, encrypting all application traffic +- **SSH tunnel encryption**: The mTLS-encrypted traffic is then transmitted through SSH reverse tunnels to relay servers +- **Double encryption**: Traffic is encrypted twice - once by client mTLS and again by SSH tunnels +- **Relay only routes traffic**: The relay server only routes the doubly-encrypted traffic without access to either encryption layer +- **No data storage**: Relay servers do not store any traffic or sensitive information +- **Certificate isolation**: Each connection uses unique certificates, ensuring complete tenant isolation + +The relay infrastructure is designed as a secure routing mechanism where only the client and gateway can decrypt the actual application traffic. + + + + +Choose based on your specific requirements: + +**Use Instance Relays when:** + +- Getting started quickly is a priority +- You want minimal operational overhead +- Shared infrastructure is acceptable +- You don't have specific latency requirements + +**Use Organization Relays when:** + +- You need dedicated relay resources +- Lower latency is critical (deploy closer to gateways) +- You have compliance requirements for data routing +- You want full control over relay infrastructure +- You need custom network policies + +Most users should start with Instance Relays and migrate to Organization Relays only if they have specific requirements that aren't met by the managed infrastructure. + + + + +Relay server outages affect gateway connectivity: + +- **Gateway reconnection**: Gateways will automatically attempt to reconnect when the relay comes back online +- **Service interruption**: While the relay is down, the Infisical platform cannot reach gateways through that relay +- **Multiple relays**: Deploy multiple relay servers for redundancy and high availability +- **Monitoring**: Set up monitoring to detect relay outages quickly +- **Automatic restart**: Use systemd or container orchestration to automatically restart failed relay services + +For production environments, consider deploying multiple relay servers to avoid single points of failure. + + From 1c5dd0c35f7d300268f589db52d4f3655fd11796 Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Fri, 12 Sep 2025 17:35:45 -0300 Subject: [PATCH 05/66] Improve UI no reference found message --- .../InfisicalSecretInput.tsx | 156 +++++++++--------- .../components/v2/SecretInput/SecretInput.tsx | 24 +-- 2 files changed, 81 insertions(+), 99 deletions(-) diff --git a/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx b/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx index c4b438e9a..dbeeb8747 100644 --- a/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx +++ b/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx @@ -1,5 +1,5 @@ import { forwardRef, TextareaHTMLAttributes, useCallback, useMemo, useRef, useState } from "react"; -import { faFolder, faKey, faLayerGroup } from "@fortawesome/free-solid-svg-icons"; +import { faFolder, faKey, faLayerGroup, faSearch } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import * as Popover from "@radix-ui/react-popover"; @@ -9,15 +9,6 @@ import { useGetProjectFolders, useGetProjectSecrets } from "@app/hooks/api"; import { SecretInput } from "../SecretInput"; -// Regex to find all secret references in the format ${reference} -const REFERENCE_REGEX = /\${([^}]+)}/g; - -// Extract unique references from a value -const extractReferences = (value: string): string[] => { - const matches = Array.from(value.matchAll(REFERENCE_REGEX)); - return [...new Set(matches.map((match) => match[1]))]; -}; - const getIndexOfUnclosedRefToTheLeft = (value: string, pos: number) => { // take substring up to pos in order to consider edits for closed references for (let i = pos; i >= 1; i -= 1) { @@ -92,7 +83,7 @@ export const InfisicalSecretInput = forwardRef( const [highlightedIndex, setHighlightedIndex] = useState(-1); - const inputRef = useRef(null); + const inputRef = useRef(null); const popoverContentRef = useRef(null); const [isFocused, setIsFocused] = useToggle(false); const currentCursorPosition = inputRef.current?.selectionStart || 0; @@ -148,8 +139,6 @@ export const InfisicalSecretInput = forwardRef( } }); - const allReferences = useMemo(() => extractReferences(value), [value]); - const suggestions = useMemo(() => { if (!isPopupOpen) return []; // reset highlight whenever recomputation happens @@ -186,51 +175,22 @@ export const InfisicalSecretInput = forwardRef( type: ReferenceType.SECRET }); }); - return suggestionsArr; - }, [secrets, folders, currentWorkspace?.environments, isPopupOpen, suggestionSource.value]); - // Mark as invalid when editing and reference doesn't match any suggestion - const invalidReferences = useMemo(() => { - const invalid = new Set(); - - if (!isPopupOpen) { - return invalid; // No validation when not editing - } - - const suggestionsHaveLoaded = Boolean(secrets || folders || currentWorkspace?.environments); - - if (!suggestionsHaveLoaded) { - return invalid; - } - - // If we have an active suggestion context but no suggestions, it means the query returned empty - const suggestionsAreEmpty = suggestions.length === 0; - - allReferences.forEach((reference) => { - const matchesAnySuggestion = suggestions.some((suggestion) => { - if (!reference.includes(".")) { - return suggestion.slug === reference; - } - const parts = reference.split("."); - const finalPart = parts[parts.length - 1]; - return suggestion.slug === finalPart; + if (suggestionsArr.length === 0 && suggestionSource.predicate.trim()) { + suggestionsArr.push({ + label: "No matches found", + slug: "__no_match__", + type: ReferenceType.SECRET }); + } - // Mark as invalid only if: - // 1. We have suggestions loaded AND none match, OR - // 2. The query returned empty results - if (!matchesAnySuggestion && (suggestions.length > 0 || suggestionsAreEmpty)) { - invalid.add(reference); - } - }); - - return invalid; - }, [isPopupOpen, allReferences, suggestions, secrets, folders, currentWorkspace?.environments]); + return suggestionsArr; + }, [secrets, folders, currentWorkspace?.environments, isPopupOpen, suggestionSource.predicate]); const handleSuggestionSelect = (selectIndex?: number) => { const selectedSuggestion = suggestions[typeof selectIndex !== "undefined" ? selectIndex : highlightedIndex]; - if (!selectedSuggestion) { + if (!selectedSuggestion || selectedSuggestion.slug === "__no_match__") { return; } @@ -275,21 +235,40 @@ export const InfisicalSecretInput = forwardRef( if (isPopupOpen) { if (e.key === "ArrowDown" || (e.key === "Tab" && !e.shiftKey)) { setHighlightedIndex((prevIndex) => { - const pos = mod(prevIndex + 1, suggestions.length); - popoverContentRef.current?.children?.[pos]?.scrollIntoView({ + let nextIndex = mod(prevIndex + 1, suggestions.length); + // Skip "no match" messages + while ( + nextIndex < suggestions.length && + suggestions[nextIndex].slug === "__no_match__" + ) { + nextIndex = mod(nextIndex + 1, suggestions.length); + } + // If we only have no-match messages, don't highlight anything + if (suggestions[nextIndex]?.slug === "__no_match__") { + return -1; + } + popoverContentRef.current?.children?.[nextIndex]?.scrollIntoView({ block: "nearest", behavior: "smooth" }); - return pos; + return nextIndex; }); } else if (e.key === "ArrowUp" || (e.key === "Tab" && e.shiftKey)) { setHighlightedIndex((prevIndex) => { - const pos = mod(prevIndex - 1, suggestions.length); - popoverContentRef.current?.children?.[pos]?.scrollIntoView({ + let prevIdx = mod(prevIndex - 1, suggestions.length); + // Skip "no match" messages + while (prevIdx >= 0 && suggestions[prevIdx].slug === "__no_match__") { + prevIdx = mod(prevIdx - 1, suggestions.length); + } + // If we only have no-match messages, don't highlight anything + if (suggestions[prevIdx]?.slug === "__no_match__") { + return -1; + } + popoverContentRef.current?.children?.[prevIdx]?.scrollIntoView({ block: "nearest", behavior: "smooth" }); - return pos; + return prevIdx; }); } else if (e.key === "Enter" && highlightedIndex >= 0) { e.preventDefault(); @@ -306,20 +285,18 @@ export const InfisicalSecretInput = forwardRef( }; // to handle multiple ref for single component - const handleRef = useCallback( - (el: HTMLTextAreaElement) => { - inputRef.current = el; - if (ref) { - if (typeof ref === "function") { - ref(el); - } else { - // eslint-disable-next-line no-param-reassign - ref.current = el; - } + const handleRef = useCallback((el: HTMLTextAreaElement) => { + // @ts-expect-error this is for multiple ref single component + inputRef.current = el; + if (ref) { + if (typeof ref === "function") { + ref(el); + } else { + // eslint-disable-next-line + ref.current = el; } - }, - [ref] - ); + } + }, []); return ( @@ -338,7 +315,6 @@ export const InfisicalSecretInput = forwardRef( }} onChange={(e) => onChange?.(e.target.value)} containerClassName={containerClassName} - invalidReferences={invalidReferences} /> ( {suggestions.map((item, i) => { let entryIcon; let subText; - if (item.type === ReferenceType.SECRET) { + const isNoMatchMessage = item.slug === "__no_match__"; + + if (isNoMatchMessage) { + entryIcon = ; + subText = "No results"; + } else if (item.type === ReferenceType.SECRET) { entryIcon = ; subText = "Secret"; } else if (item.type === ReferenceType.ENVIRONMENT) { @@ -367,10 +348,28 @@ export const InfisicalSecretInput = forwardRef( subText = "Folder"; } - return ( + return isNoMatchMessage ? (
+
+
+
{entryIcon}
+
+ {item.label} +
+ {subText} +
+
+
+
+
+ ) : ( + ); })} diff --git a/frontend/src/components/v2/SecretInput/SecretInput.tsx b/frontend/src/components/v2/SecretInput/SecretInput.tsx index e9f6d2625..f9b5b6f1b 100644 --- a/frontend/src/components/v2/SecretInput/SecretInput.tsx +++ b/frontend/src/components/v2/SecretInput/SecretInput.tsx @@ -7,12 +7,7 @@ import { HIDDEN_SECRET_VALUE } from "@app/pages/secret-manager/SecretDashboardPa const REGEX = /(\${([a-zA-Z0-9-_.]+)})/g; -const syntaxHighlight = ( - content?: string | null, - isVisible?: boolean, - isImport?: boolean, - invalidReferences?: Set -) => { +const syntaxHighlight = (content?: string | null, isVisible?: boolean, isImport?: boolean) => { if (isImport && !content) return "IMPORTED"; if (content === "") return "EMPTY"; if (!content) return "EMPTY"; @@ -23,18 +18,10 @@ const syntaxHighlight = ( const isInterpolationSyntax = el.startsWith("${") && el.endsWith("}"); if (isInterpolationSyntax) { skipNext = true; - const referenceContent = el.slice(2, -1); - const isInvalid = invalidReferences?.has(referenceContent) ?? false; - return ( - + ${ - - {referenceContent} - + {el.slice(2, -1)} } ); @@ -62,7 +49,6 @@ type Props = TextareaHTMLAttributes & { isDisabled?: boolean; containerClassName?: string; canEditButNotView?: boolean; - invalidReferences?: Set; }; const commonClassName = "font-mono text-sm caret-white border-none outline-none w-full break-all"; @@ -80,7 +66,6 @@ export const SecretInput = forwardRef( isReadOnly, onFocus, canEditButNotView, - invalidReferences, ...props }, ref @@ -99,8 +84,7 @@ export const SecretInput = forwardRef( {syntaxHighlight( value, isVisible || (isSecretFocused && !valueAlwaysHidden), - isImport, - invalidReferences + isImport )} From 369c620fcfd0f14fe95cf9d58ffd8d75b24c8392 Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Fri, 12 Sep 2025 21:02:17 -0300 Subject: [PATCH 06/66] Prevent secrets referencing themselves to loop over and over again, compute them only once --- .../secret-v2-bridge/secret-v2-bridge-fns.ts | 24 +++++-- .../SecretReferenceDetails.tsx | 62 +++++++++++++++++-- 2 files changed, 76 insertions(+), 10 deletions(-) diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts index 7aee91273..7929dc0e6 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts @@ -622,11 +622,15 @@ export const expandSecretReferencesFactory = ({ const stackTrace = { ...dto, key: "root", children: [] } as TSecretReferenceTraceNode; if (!dto.value) return { expandedValue: "", stackTrace }; - const stack = [{ ...dto, depth: 0, trace: stackTrace }]; + + // Track visited secrets to prevent circular references + const createSecretId = (env: string, secretPath: string, key: string) => `${env}:${secretPath}:${key}`; + + const stack = [{ ...dto, depth: 0, trace: stackTrace, visitedSecrets: new Set() }]; let expandedValue = dto.value; while (stack.length) { - const { value, secretPath, environment, depth, trace } = stack.pop()!; + const { value, secretPath, environment, depth, trace, visitedSecrets } = stack.pop()!; // eslint-disable-next-line no-continue if (depth > MAX_SECRET_REFERENCE_DEPTH) continue; @@ -700,17 +704,27 @@ export const expandSecretReferencesFactory = ({ trace }; - const shouldExpandMore = INTERPOLATION_TEST_REGEX.test(referencedSecretValue); + // Check for circular reference + const referencedSecretId = createSecretId( + referencedSecretEnvironmentSlug, + referencedSecretPath, + referencedSecretKey + ); + const isCircular = visitedSecrets.has(referencedSecretId); + + const newVisitedSecrets = new Set([...visitedSecrets, referencedSecretId]); + + const shouldExpandMore = INTERPOLATION_TEST_REGEX.test(referencedSecretValue) && !isCircular; if (dto.shouldStackTrace) { const stackTraceNode = { ...node, children: [], key: referencedSecretKey, trace: null }; trace?.children.push(stackTraceNode); // if stack trace this would be child node if (shouldExpandMore) { - stack.push({ ...node, trace: stackTraceNode }); + stack.push({ ...node, trace: stackTraceNode, visitedSecrets: newVisitedSecrets }); } } else if (shouldExpandMore) { // if no stack trace is needed we just keep going with root node - stack.push(node); + stack.push({ ...node, visitedSecrets: newVisitedSecrets }); } if (referencedSecretValue) { diff --git a/frontend/src/components/secrets/SecretReferenceDetails/SecretReferenceDetails.tsx b/frontend/src/components/secrets/SecretReferenceDetails/SecretReferenceDetails.tsx index a9542c90c..df7649109 100644 --- a/frontend/src/components/secrets/SecretReferenceDetails/SecretReferenceDetails.tsx +++ b/frontend/src/components/secrets/SecretReferenceDetails/SecretReferenceDetails.tsx @@ -29,17 +29,54 @@ const INTERPOLATION_SYNTAX_REG = /\${([^}]+)}/; export const hasSecretReference = (value: string | undefined) => value ? INTERPOLATION_SYNTAX_REG.test(value) : false; +const createNodeId = (node: TSecretReferenceTraceNode): string => + `${node.environment}:${node.secretPath}:${node.key}`; + +const isCircularReference = ( + node: TSecretReferenceTraceNode, + visitedPath: Set +): boolean => { + const nodeId = createNodeId(node); + return visitedPath.has(nodeId); +}; + +const hasCircularReferences = ( + node: TSecretReferenceTraceNode, + visitedPath: Set = new Set() +): boolean => { + const nodeId = createNodeId(node); + + if (visitedPath.has(nodeId)) { + return true; + } + + const newVisitedPath = new Set([...visitedPath, nodeId]); + + return node.children.some((child) => hasCircularReferences(child, newVisitedPath)); +}; + export const SecretReferenceNode = ({ node, isRoot, - secretKey + secretKey, + visitedPath = new Set() }: { node: TSecretReferenceTraceNode; isRoot?: boolean; secretKey?: string; + visitedPath?: Set; }) => { const [isOpen, setIsOpen] = useState(false); - const hasChildren = node.children.length > 0; + + const nodeId = createNodeId(node); + const isCircular = !isRoot && isCircularReference(node, visitedPath); + + const newVisitedPath = isCircular ? visitedPath : new Set([...visitedPath, nodeId]); + + const safeChildren = isCircular + ? [] + : node.children.filter((child) => !isCircularReference(child, newVisitedPath)); + const hasChildren = safeChildren.length > 0; return (
  • @@ -77,8 +114,12 @@ export const SecretReferenceNode = ({ {hasChildren && (
      - {node.children.map((el, index) => ( - + {safeChildren.map((el, index) => ( + ))}
    )} @@ -102,6 +143,9 @@ export const SecretReferenceTree = ({ secretPath, environment, secretKey }: Prop const tree = data?.tree; const secretValue = data?.value; + // Check if the tree contains circular references + const hasCirculars = tree ? hasCircularReferences(tree) : false; + useEffect(() => { if (error instanceof AxiosError) { const err = error?.response?.data as TApiErrors; @@ -132,7 +176,15 @@ export const SecretReferenceTree = ({ secretPath, environment, secretKey }: Prop return (
    - + Date: Tue, 16 Sep 2025 01:36:06 -0300 Subject: [PATCH 07/66] Add Certificate Syncs --- backend/src/@types/fastify.d.ts | 2 + backend/src/@types/knex.d.ts | 4 + .../db/migrations/20250910193000_pki-sync.ts | 47 + backend/src/db/schemas/index.ts | 1 + backend/src/db/schemas/models.ts | 1 + backend/src/db/schemas/pki-syncs.ts | 40 + .../ee/services/audit-log/audit-log-types.ts | 87 ++ .../ee/services/permission/default-roles.ts | 28 + .../services/permission/project-permission.ts | 35 + backend/src/keystore/keystore.ts | 1 + backend/src/queue/queue-service.ts | 29 + backend/src/server/routes/index.ts | 65 +- backend/src/server/routes/v1/index.ts | 2 + .../src/server/routes/v1/pki-sync-router.ts | 540 +++++++++ .../services/certificate/certificate-dal.ts | 17 +- .../certificate/certificate-service.ts | 42 +- .../pki-subscriber/pki-subscriber-service.ts | 42 +- .../azure-key-vault-pki-sync-fns.ts | 521 +++++++++ .../azure-key-vault-pki-sync-types.ts | 29 + backend/src/services/pki-sync/pki-sync-dal.ts | 181 +++ .../src/services/pki-sync/pki-sync-enums.ts | 22 + .../src/services/pki-sync/pki-sync-errors.ts | 25 + backend/src/services/pki-sync/pki-sync-fns.ts | 121 ++ .../src/services/pki-sync/pki-sync-maps.ts | 11 + .../src/services/pki-sync/pki-sync-queue.ts | 1032 +++++++++++++++++ .../src/services/pki-sync/pki-sync-schemas.ts | 37 + .../src/services/pki-sync/pki-sync-service.ts | 384 ++++++ .../src/services/pki-sync/pki-sync-types.ts | 183 +++ .../pki-syncs/CreatePkiSyncModal.tsx | 76 ++ .../pki-syncs/DeletePkiSyncModal.tsx | 60 + .../components/pki-syncs/EditPkiSyncModal.tsx | 29 + .../PkiSyncImportCertificatesModal.tsx | 94 ++ .../pki-syncs/PkiSyncImportStatusBadge.tsx | 112 ++ .../pki-syncs/PkiSyncModalHeader.tsx | 49 + .../PkiSyncRemoveCertificatesModal.tsx | 86 ++ .../pki-syncs/PkiSyncRemoveStatusBadge.tsx | 112 ++ .../components/pki-syncs/PkiSyncSelect.tsx | 155 +++ .../pki-syncs/PkiSyncStatusBadge.tsx | 55 + .../src/components/pki-syncs/PkiSyncTable.tsx | 121 ++ .../forms/AzureKeyVaultPkiSyncFields.tsx | 37 + .../pki-syncs/forms/CreatePkiSyncForm.tsx | 243 ++++ .../pki-syncs/forms/EditPkiSyncForm.tsx | 107 ++ .../forms/PkiSyncConnectionField.tsx | 86 ++ .../forms/PkiSyncDestinationFields.tsx | 19 + .../pki-syncs/forms/PkiSyncDetailsFields.tsx | 51 + .../PkiSyncOptionsFields.tsx | 89 ++ .../forms/PkiSyncOptionsFields/index.tsx | 1 + .../pki-syncs/forms/PkiSyncReviewFields.tsx | 100 ++ .../pki-syncs/forms/PkiSyncSourceFields.tsx | 53 + .../src/components/pki-syncs/forms/index.ts | 8 + .../src/components/pki-syncs/forms/schemas.ts | 35 + frontend/src/components/pki-syncs/index.ts | 9 + .../src/components/pki-syncs/types/index.ts | 6 + frontend/src/const/routes.ts | 8 + .../context/ProjectPermissionContext/types.ts | 23 + frontend/src/helpers/pkiSyncs.ts | 19 + .../src/hooks/api/appConnections/index.ts | 1 + .../src/hooks/api/auditLogs/constants.tsx | 8 + frontend/src/hooks/api/auditLogs/enums.tsx | 8 + frontend/src/hooks/api/pkiSyncs/enums.ts | 10 + frontend/src/hooks/api/pkiSyncs/index.ts | 7 + frontend/src/hooks/api/pkiSyncs/mutations.tsx | 125 ++ frontend/src/hooks/api/pkiSyncs/queries.tsx | 88 ++ .../pkiSyncs/types/azure-key-vault-sync.ts | 16 + .../src/hooks/api/pkiSyncs/types/common.ts | 44 + .../src/hooks/api/pkiSyncs/types/index.ts | 57 + .../PkiManagerLayout/PkiManagerLayout.tsx | 18 + .../IntegrationsListPage.tsx | 69 ++ .../PkiSyncDestinationCol.tsx | 14 + .../PkiSyncDestinationCol/index.ts | 1 + .../PkiSyncsTab/PkiSyncTable/PkiSyncRow.tsx | 398 +++++++ .../PkiSyncTable/PkiSyncTableCell.tsx | 71 ++ .../PkiSyncTable/PkiSyncsTable.tsx | 473 ++++++++ .../PkiSyncsTab/PkiSyncTable/helpers.ts | 13 + .../PkiSyncsTab/PkiSyncTable/index.ts | 1 + .../components/PkiSyncsTab/PkiSyncsTab.tsx | 110 ++ .../components/PkiSyncsTab/index.ts | 1 + .../IntegrationsListPage/components/index.ts | 1 + .../IntegrationsListPage/index.ts | 1 + .../IntegrationsListPage/route.tsx | 30 + .../PkiSyncDetailsByIDPage.tsx | 146 +++ .../components/PkiSyncActionTriggers.tsx | 318 +++++ .../components/PkiSyncAuditLogsSection.tsx | 85 ++ .../components/PkiSyncDestinationSection.tsx | 73 ++ ...AzureKeyVaultPkiSyncDestinationSection.tsx | 21 + .../PkiSyncDestinationSection/index.ts | 1 + .../components/PkiSyncDetailsSection.tsx | 101 ++ .../PkiSyncOptionsSection.tsx | 62 + .../components/PkiSyncOptionsSection/index.ts | 1 + .../components/PkiSyncSourceSection.tsx | 75 ++ .../components/index.ts | 6 + .../PkiSyncDetailsByIDPage/index.tsx | 1 + .../PkiSyncDetailsByIDPage/route.tsx | 31 + .../components/ConditionsFields.tsx | 18 +- .../GeneralPermissionConditions.tsx | 3 +- .../ProjectRoleModifySection.utils.tsx | 79 ++ .../IntegrationsListPage.tsx | 4 +- frontend/src/routeTree.gen.ts | 106 ++ frontend/src/routes.ts | 4 + frontend/src/types/integrations.ts | 3 +- 100 files changed, 8044 insertions(+), 31 deletions(-) create mode 100644 backend/src/db/migrations/20250910193000_pki-sync.ts create mode 100644 backend/src/db/schemas/pki-syncs.ts create mode 100644 backend/src/server/routes/v1/pki-sync-router.ts create mode 100644 backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-fns.ts create mode 100644 backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-types.ts create mode 100644 backend/src/services/pki-sync/pki-sync-dal.ts create mode 100644 backend/src/services/pki-sync/pki-sync-enums.ts create mode 100644 backend/src/services/pki-sync/pki-sync-errors.ts create mode 100644 backend/src/services/pki-sync/pki-sync-fns.ts create mode 100644 backend/src/services/pki-sync/pki-sync-maps.ts create mode 100644 backend/src/services/pki-sync/pki-sync-queue.ts create mode 100644 backend/src/services/pki-sync/pki-sync-schemas.ts create mode 100644 backend/src/services/pki-sync/pki-sync-service.ts create mode 100644 backend/src/services/pki-sync/pki-sync-types.ts create mode 100644 frontend/src/components/pki-syncs/CreatePkiSyncModal.tsx create mode 100644 frontend/src/components/pki-syncs/DeletePkiSyncModal.tsx create mode 100644 frontend/src/components/pki-syncs/EditPkiSyncModal.tsx create mode 100644 frontend/src/components/pki-syncs/PkiSyncImportCertificatesModal.tsx create mode 100644 frontend/src/components/pki-syncs/PkiSyncImportStatusBadge.tsx create mode 100644 frontend/src/components/pki-syncs/PkiSyncModalHeader.tsx create mode 100644 frontend/src/components/pki-syncs/PkiSyncRemoveCertificatesModal.tsx create mode 100644 frontend/src/components/pki-syncs/PkiSyncRemoveStatusBadge.tsx create mode 100644 frontend/src/components/pki-syncs/PkiSyncSelect.tsx create mode 100644 frontend/src/components/pki-syncs/PkiSyncStatusBadge.tsx create mode 100644 frontend/src/components/pki-syncs/PkiSyncTable.tsx create mode 100644 frontend/src/components/pki-syncs/forms/AzureKeyVaultPkiSyncFields.tsx create mode 100644 frontend/src/components/pki-syncs/forms/CreatePkiSyncForm.tsx create mode 100644 frontend/src/components/pki-syncs/forms/EditPkiSyncForm.tsx create mode 100644 frontend/src/components/pki-syncs/forms/PkiSyncConnectionField.tsx create mode 100644 frontend/src/components/pki-syncs/forms/PkiSyncDestinationFields.tsx create mode 100644 frontend/src/components/pki-syncs/forms/PkiSyncDetailsFields.tsx create mode 100644 frontend/src/components/pki-syncs/forms/PkiSyncOptionsFields/PkiSyncOptionsFields.tsx create mode 100644 frontend/src/components/pki-syncs/forms/PkiSyncOptionsFields/index.tsx create mode 100644 frontend/src/components/pki-syncs/forms/PkiSyncReviewFields.tsx create mode 100644 frontend/src/components/pki-syncs/forms/PkiSyncSourceFields.tsx create mode 100644 frontend/src/components/pki-syncs/forms/index.ts create mode 100644 frontend/src/components/pki-syncs/forms/schemas.ts create mode 100644 frontend/src/components/pki-syncs/index.ts create mode 100644 frontend/src/components/pki-syncs/types/index.ts create mode 100644 frontend/src/helpers/pkiSyncs.ts create mode 100644 frontend/src/hooks/api/pkiSyncs/enums.ts create mode 100644 frontend/src/hooks/api/pkiSyncs/index.ts create mode 100644 frontend/src/hooks/api/pkiSyncs/mutations.tsx create mode 100644 frontend/src/hooks/api/pkiSyncs/queries.tsx create mode 100644 frontend/src/hooks/api/pkiSyncs/types/azure-key-vault-sync.ts create mode 100644 frontend/src/hooks/api/pkiSyncs/types/common.ts create mode 100644 frontend/src/hooks/api/pkiSyncs/types/index.ts create mode 100644 frontend/src/pages/cert-manager/IntegrationsListPage/IntegrationsListPage.tsx create mode 100644 frontend/src/pages/cert-manager/IntegrationsListPage/components/PkiSyncsTab/PkiSyncTable/PkiSyncDestinationCol/PkiSyncDestinationCol.tsx create mode 100644 frontend/src/pages/cert-manager/IntegrationsListPage/components/PkiSyncsTab/PkiSyncTable/PkiSyncDestinationCol/index.ts create mode 100644 frontend/src/pages/cert-manager/IntegrationsListPage/components/PkiSyncsTab/PkiSyncTable/PkiSyncRow.tsx create mode 100644 frontend/src/pages/cert-manager/IntegrationsListPage/components/PkiSyncsTab/PkiSyncTable/PkiSyncTableCell.tsx create mode 100644 frontend/src/pages/cert-manager/IntegrationsListPage/components/PkiSyncsTab/PkiSyncTable/PkiSyncsTable.tsx create mode 100644 frontend/src/pages/cert-manager/IntegrationsListPage/components/PkiSyncsTab/PkiSyncTable/helpers.ts create mode 100644 frontend/src/pages/cert-manager/IntegrationsListPage/components/PkiSyncsTab/PkiSyncTable/index.ts create mode 100644 frontend/src/pages/cert-manager/IntegrationsListPage/components/PkiSyncsTab/PkiSyncsTab.tsx create mode 100644 frontend/src/pages/cert-manager/IntegrationsListPage/components/PkiSyncsTab/index.ts create mode 100644 frontend/src/pages/cert-manager/IntegrationsListPage/components/index.ts create mode 100644 frontend/src/pages/cert-manager/IntegrationsListPage/index.ts create mode 100644 frontend/src/pages/cert-manager/IntegrationsListPage/route.tsx create mode 100644 frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/PkiSyncDetailsByIDPage.tsx create mode 100644 frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncActionTriggers.tsx create mode 100644 frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncAuditLogsSection.tsx create mode 100644 frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncDestinationSection.tsx create mode 100644 frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncDestinationSection/AzureKeyVaultPkiSyncDestinationSection.tsx create mode 100644 frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncDestinationSection/index.ts create mode 100644 frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncDetailsSection.tsx create mode 100644 frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncOptionsSection/PkiSyncOptionsSection.tsx create mode 100644 frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncOptionsSection/index.ts create mode 100644 frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncSourceSection.tsx create mode 100644 frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/index.ts create mode 100644 frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/index.tsx create mode 100644 frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/route.tsx diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 8ca4288b5..e30d55d8c 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -93,6 +93,7 @@ import { TOrgAdminServiceFactory } from "@app/services/org-admin/org-admin-servi import { TPkiAlertServiceFactory } from "@app/services/pki-alert/pki-alert-service"; import { TPkiCollectionServiceFactory } from "@app/services/pki-collection/pki-collection-service"; import { TPkiSubscriberServiceFactory } from "@app/services/pki-subscriber/pki-subscriber-service"; +import { TPkiSyncServiceFactory } from "@app/services/pki-sync/pki-sync-service"; import { TPkiTemplatesServiceFactory } from "@app/services/pki-templates/pki-templates-service"; import { TProjectServiceFactory } from "@app/services/project/project-service"; import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; @@ -267,6 +268,7 @@ declare module "fastify" { certificateEst: TCertificateEstServiceFactory; pkiCollection: TPkiCollectionServiceFactory; pkiSubscriber: TPkiSubscriberServiceFactory; + pkiSync: TPkiSyncServiceFactory; secretScanning: TSecretScanningServiceFactory; license: TLicenseServiceFactory; trustedIp: TTrustedIpServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 75a358341..b6bde44bc 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -263,6 +263,9 @@ import { TPkiSubscribers, TPkiSubscribersInsert, TPkiSubscribersUpdate, + TPkiSyncs, + TPkiSyncsInsert, + TPkiSyncsUpdate, TProjectBots, TProjectBotsInsert, TProjectBotsUpdate, @@ -680,6 +683,7 @@ declare module "knex/types/tables" { TPkiSubscribersInsert, TPkiSubscribersUpdate >; + [TableName.PkiSync]: KnexOriginal.CompositeTableType; [TableName.UserGroupMembership]: KnexOriginal.CompositeTableType< TUserGroupMembership, TUserGroupMembershipInsert, diff --git a/backend/src/db/migrations/20250910193000_pki-sync.ts b/backend/src/db/migrations/20250910193000_pki-sync.ts new file mode 100644 index 000000000..73dc620d9 --- /dev/null +++ b/backend/src/db/migrations/20250910193000_pki-sync.ts @@ -0,0 +1,47 @@ +import { Knex } from "knex"; + +import { TableName } from "@app/db/schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "@app/db/utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.PkiSync))) { + await knex.schema.createTable(TableName.PkiSync, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("name", 32).notNullable(); + t.string("description"); + t.string("destination").notNullable(); + t.boolean("isAutoSyncEnabled").notNullable().defaultTo(true); + t.integer("version").defaultTo(1).notNullable(); + t.jsonb("destinationConfig").notNullable(); + t.jsonb("syncOptions").notNullable(); + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.uuid("subscriberId"); + t.foreign("subscriberId").references("id").inTable(TableName.PkiSubscriber).onDelete("SET NULL"); + t.uuid("connectionId").notNullable(); + t.foreign("connectionId").references("id").inTable(TableName.AppConnection); + t.timestamps(true, true, true); + t.string("syncStatus"); + t.string("lastSyncJobId"); + t.string("lastSyncMessage"); + t.datetime("lastSyncedAt"); + t.string("importStatus"); + t.string("lastImportJobId"); + t.string("lastImportMessage"); + t.datetime("lastImportedAt"); + t.string("removeStatus"); + t.string("lastRemoveJobId"); + t.string("lastRemoveMessage"); + t.datetime("lastRemovedAt"); + + t.unique(["name", "projectId"], { indexName: "pki_syncs_name_project_id_unique" }); + }); + + await createOnUpdateTrigger(knex, TableName.PkiSync); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.PkiSync); + await dropOnUpdateTrigger(knex, TableName.PkiSync); +} diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index f09e3c263..8cbaa00bb 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -87,6 +87,7 @@ export * from "./pki-alerts"; export * from "./pki-collection-items"; export * from "./pki-collections"; export * from "./pki-subscribers"; +export * from "./pki-syncs"; export * from "./project-bots"; export * from "./project-environments"; export * from "./project-gateways"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index a4585972f..385a328b6 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -156,6 +156,7 @@ export enum TableName { ProjectSlackConfigs = "project_slack_configs", AppConnection = "app_connections", SecretSync = "secret_syncs", + PkiSync = "pki_syncs", KmipClient = "kmip_clients", KmipOrgConfig = "kmip_org_configs", KmipOrgServerCertificates = "kmip_org_server_certificates", diff --git a/backend/src/db/schemas/pki-syncs.ts b/backend/src/db/schemas/pki-syncs.ts new file mode 100644 index 000000000..d4ef2ed6d --- /dev/null +++ b/backend/src/db/schemas/pki-syncs.ts @@ -0,0 +1,40 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const PkiSyncsSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + description: z.string().nullable().optional(), + destination: z.string(), + isAutoSyncEnabled: z.boolean().default(true), + version: z.number().default(1), + destinationConfig: z.unknown(), + syncOptions: z.unknown(), + projectId: z.string(), + subscriberId: z.string().uuid().nullable().optional(), + connectionId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date(), + syncStatus: z.string().nullable().optional(), + lastSyncJobId: z.string().nullable().optional(), + lastSyncMessage: z.string().nullable().optional(), + lastSyncedAt: z.date().nullable().optional(), + importStatus: z.string().nullable().optional(), + lastImportJobId: z.string().nullable().optional(), + lastImportMessage: z.string().nullable().optional(), + lastImportedAt: z.date().nullable().optional(), + removeStatus: z.string().nullable().optional(), + lastRemoveJobId: z.string().nullable().optional(), + lastRemoveMessage: z.string().nullable().optional(), + lastRemovedAt: z.date().nullable().optional() +}); + +export type TPkiSyncs = z.infer; +export type TPkiSyncsInsert = Omit, TImmutableDBKeys>; +export type TPkiSyncsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 10c84cc4a..daa3cc962 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -404,6 +404,14 @@ export enum EventType { SECRET_SYNC_SYNC_SECRETS = "secret-sync-sync-secrets", SECRET_SYNC_IMPORT_SECRETS = "secret-sync-import-secrets", SECRET_SYNC_REMOVE_SECRETS = "secret-sync-remove-secrets", + GET_PKI_SYNCS = "get-pki-syncs", + GET_PKI_SYNC = "get-pki-sync", + CREATE_PKI_SYNC = "create-pki-sync", + UPDATE_PKI_SYNC = "update-pki-sync", + DELETE_PKI_SYNC = "delete-pki-sync", + PKI_SYNC_SYNC_CERTIFICATES = "pki-sync-sync-certificates", + PKI_SYNC_IMPORT_CERTIFICATES = "pki-sync-import-certificates", + PKI_SYNC_REMOVE_CERTIFICATES = "pki-sync-remove-certificates", OIDC_GROUP_MEMBERSHIP_MAPPING_ASSIGN_USER = "oidc-group-membership-mapping-assign-user", OIDC_GROUP_MEMBERSHIP_MAPPING_REMOVE_USER = "oidc-group-membership-mapping-remove-user", CREATE_KMIP_CLIENT = "create-kmip-client", @@ -2908,6 +2916,77 @@ interface SecretSyncRemoveSecretsEvent { }; } +interface GetPkiSyncsEvent { + type: EventType.GET_PKI_SYNCS; + metadata: { + projectId: string; + }; +} + +interface GetPkiSyncEvent { + type: EventType.GET_PKI_SYNC; + metadata: { + destination: string; + syncId: string; + }; +} + +interface CreatePkiSyncEvent { + type: EventType.CREATE_PKI_SYNC; + metadata: { + pkiSyncId: string; + name: string; + destination: string; + }; +} + +interface UpdatePkiSyncEvent { + type: EventType.UPDATE_PKI_SYNC; + metadata: { + pkiSyncId: string; + name: string; + }; +} + +interface DeletePkiSyncEvent { + type: EventType.DELETE_PKI_SYNC; + metadata: { + pkiSyncId: string; + name: string; + destination: string; + }; +} + +interface PkiSyncSyncCertificatesEvent { + type: EventType.PKI_SYNC_SYNC_CERTIFICATES; + metadata: { + syncId: string; + syncMessage: string | null; + jobId: string; + jobRanAt: Date; + }; +} + +interface PkiSyncImportCertificatesEvent { + type: EventType.PKI_SYNC_IMPORT_CERTIFICATES; + metadata: { + syncId: string; + importMessage: string | null; + jobId: string; + jobRanAt: Date; + }; +} + +interface PkiSyncRemoveCertificatesEvent { + type: EventType.PKI_SYNC_REMOVE_CERTIFICATES; + metadata: { + syncId: string; + removeMessage: string | null; + jobId: string; + jobRanAt: Date; + }; +} + interface OidcGroupMembershipMappingAssignUserEvent { type: EventType.OIDC_GROUP_MEMBERSHIP_MAPPING_ASSIGN_USER; metadata: { @@ -3715,6 +3794,14 @@ export type Event = | SecretSyncSyncSecretsEvent | SecretSyncImportSecretsEvent | SecretSyncRemoveSecretsEvent + | GetPkiSyncsEvent + | GetPkiSyncEvent + | CreatePkiSyncEvent + | UpdatePkiSyncEvent + | DeletePkiSyncEvent + | PkiSyncSyncCertificatesEvent + | PkiSyncImportCertificatesEvent + | PkiSyncRemoveCertificatesEvent | OidcGroupMembershipMappingAssignUserEvent | OidcGroupMembershipMappingRemoveUserEvent | CreateKmipClientEvent diff --git a/backend/src/ee/services/permission/default-roles.ts b/backend/src/ee/services/permission/default-roles.ts index 9329c3c7f..45a5ce88a 100644 --- a/backend/src/ee/services/permission/default-roles.ts +++ b/backend/src/ee/services/permission/default-roles.ts @@ -12,6 +12,7 @@ import { ProjectPermissionKmipActions, ProjectPermissionMemberActions, ProjectPermissionPkiSubscriberActions, + ProjectPermissionPkiSyncActions, ProjectPermissionPkiTemplateActions, ProjectPermissionSecretActions, ProjectPermissionSecretEventActions, @@ -208,6 +209,19 @@ const buildAdminPermissionRules = () => { ProjectPermissionSub.SecretSyncs ); + can( + [ + ProjectPermissionPkiSyncActions.Create, + ProjectPermissionPkiSyncActions.Edit, + ProjectPermissionPkiSyncActions.Delete, + ProjectPermissionPkiSyncActions.Read, + ProjectPermissionPkiSyncActions.SyncCertificates, + ProjectPermissionPkiSyncActions.ImportCertificates, + ProjectPermissionPkiSyncActions.RemoveCertificates + ], + ProjectPermissionSub.PkiSyncs + ); + can( [ ProjectPermissionKmipActions.CreateClients, @@ -450,6 +464,19 @@ const buildMemberPermissionRules = () => { ProjectPermissionSub.SecretSyncs ); + can( + [ + ProjectPermissionPkiSyncActions.Create, + ProjectPermissionPkiSyncActions.Edit, + ProjectPermissionPkiSyncActions.Delete, + ProjectPermissionPkiSyncActions.Read, + ProjectPermissionPkiSyncActions.SyncCertificates, + ProjectPermissionPkiSyncActions.ImportCertificates, + ProjectPermissionPkiSyncActions.RemoveCertificates + ], + ProjectPermissionSub.PkiSyncs + ); + can( [ ProjectPermissionSecretScanningDataSourceActions.Read, @@ -512,6 +539,7 @@ const buildViewerPermissionRules = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificates); can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateTemplates); can(ProjectPermissionSecretSyncActions.Read, ProjectPermissionSub.SecretSyncs); + can(ProjectPermissionPkiSyncActions.Read, ProjectPermissionSub.PkiSyncs); can(ProjectPermissionCommitsActions.Read, ProjectPermissionSub.Commits); can( diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 20b4344d3..8a41ce53a 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -120,6 +120,16 @@ export enum ProjectPermissionSecretSyncActions { RemoveSecrets = "remove-secrets" } +export enum ProjectPermissionPkiSyncActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete", + SyncCertificates = "sync-certificates", + ImportCertificates = "import-certificates", + RemoveCertificates = "remove-certificates" +} + export enum ProjectPermissionSecretRotationActions { Read = "read", ReadGeneratedCredentials = "read-generated-credentials", @@ -204,6 +214,7 @@ export enum ProjectPermissionSub { Kms = "kms", Cmek = "cmek", SecretSyncs = "secret-syncs", + PkiSyncs = "pki-syncs", Kmip = "kmip", SecretScanningDataSources = "secret-scanning-data-sources", SecretScanningFindings = "secret-scanning-findings", @@ -235,6 +246,10 @@ export type SecretSyncSubjectFields = { secretPath: string; }; +export type PkiSyncSubjectFields = { + projectId: string; +}; + export type DynamicSecretSubjectFields = { environment: string; secretPath: string; @@ -295,6 +310,10 @@ export type ProjectPermissionSet = ProjectPermissionSecretSyncActions, ProjectPermissionSub.SecretSyncs | (ForcedSubject & SecretSyncSubjectFields) ] + | [ + ProjectPermissionPkiSyncActions, + ProjectPermissionSub.PkiSyncs | (ForcedSubject & PkiSyncSubjectFields) + ] | [ ProjectPermissionActions, ( @@ -460,6 +479,12 @@ const SecretSyncConditionV2Schema = z }) .partial(); +const PkiSyncConditionSchema = z + .object({ + projectId: z.string() + }) + .partial(); + const SecretImportConditionSchema = z .object({ environment: z.union([ @@ -898,6 +923,16 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [ "When specified, only matching conditions will be allowed to access given resource." ).optional() }), + z.object({ + subject: z.literal(ProjectPermissionSub.PkiSyncs).describe("The entity this permission pertains to."), + inverted: z.boolean().optional().describe("Whether rule allows or forbids."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionPkiSyncActions).describe( + "Describe what action an entity can take." + ), + conditions: PkiSyncConditionSchema.describe( + "When specified, only matching conditions will be allowed to access given resource." + ).optional() + }), z.object({ subject: z.literal(ProjectPermissionSub.SecretEvents).describe("The entity this permission pertains to."), inverted: z.boolean().optional().describe("Whether rule allows or forbids."), diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts index 8b72ce464..04ba8428b 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -47,6 +47,7 @@ export const KeyStorePrefixes = { SyncSecretIntegrationLastRunTimestamp: (projectId: string, environmentSlug: string, secretPath: string) => `sync-integration-last-run-${projectId}-${environmentSlug}-${secretPath}` as const, SecretSyncLock: (syncId: string) => `secret-sync-mutex-${syncId}` as const, + PkiSyncLock: (syncId: string) => `pki-sync-mutex-${syncId}` as const, AppConnectionConcurrentJobs: (connectionId: string) => `app-connection-concurrency-${connectionId}` as const, SecretRotationLock: (rotationId: string) => `secret-rotation-v2-mutex-${rotationId}` as const, SecretScanningLock: (dataSourceId: string, resourceExternalId: string) => diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index 5a7c92f22..159735053 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -24,6 +24,12 @@ import { QueueWorkerProfile } from "@app/lib/types"; import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; import { ExternalPlatforms } from "@app/services/external-migration/external-migration-types"; import { TCreateUserNotificationDTO } from "@app/services/notification/notification-types"; +import { + TQueuePkiSyncImportCertificatesByIdDTO, + TQueuePkiSyncRemoveCertificatesByIdDTO, + TQueuePkiSyncSyncCertificatesByIdDTO, + TQueueSendPkiSyncActionFailedNotificationsDTO +} from "@app/services/pki-sync/pki-sync-types"; import { TFailedIntegrationSyncEmailsPayload, TIntegrationSyncPayload, @@ -58,6 +64,7 @@ export enum QueueName { CaLifecycle = "ca-lifecycle", // parent queue to ca-order-certificate-for-subscriber SecretReplication = "secret-replication", SecretSync = "secret-sync", // parent queue to push integration sync, webhook, and secret replication + PkiSync = "pki-sync", ProjectV3Migration = "project-v3-migration", AccessTokenStatusUpdate = "access-token-status-update", ImportSecretsFromExternalSource = "import-secrets-from-external-source", @@ -91,6 +98,7 @@ export enum QueueJobs { CaCrlRotation = "ca-crl-rotation-job", SecretReplication = "secret-replication", SecretSync = "secret-sync", // parent queue to push integration sync, webhook, and secret replication + PkiSync = "pki-sync", ProjectV3Migration = "project-v3-migration", IdentityAccessTokenStatusUpdate = "identity-access-token-status-update", ServiceTokenStatusUpdate = "service-token-status-update", @@ -99,6 +107,10 @@ export enum QueueJobs { SecretSyncImportSecrets = "secret-sync-import-secrets", SecretSyncRemoveSecrets = "secret-sync-remove-secrets", SecretSyncSendActionFailedNotifications = "secret-sync-send-action-failed-notifications", + PkiSyncSyncCertificates = "pki-sync-sync-certificates", + PkiSyncImportCertificates = "pki-sync-import-certificates", + PkiSyncRemoveCertificates = "pki-sync-remove-certificates", + PkiSyncSendActionFailedNotifications = "pki-sync-send-action-failed-notifications", SecretRotationV2QueueRotations = "secret-rotation-v2-queue-rotations", SecretRotationV2RotateSecrets = "secret-rotation-v2-rotate-secrets", SecretRotationV2SendNotification = "secret-rotation-v2-send-notification", @@ -218,6 +230,23 @@ export type TQueueJobTypes = { name: QueueJobs.SecretSync; payload: TSyncSecretsDTO; }; + [QueueName.PkiSync]: + | { + name: QueueJobs.PkiSyncSyncCertificates; + payload: TQueuePkiSyncSyncCertificatesByIdDTO; + } + | { + name: QueueJobs.PkiSyncImportCertificates; + payload: TQueuePkiSyncImportCertificatesByIdDTO; + } + | { + name: QueueJobs.PkiSyncRemoveCertificates; + payload: TQueuePkiSyncRemoveCertificatesByIdDTO; + } + | { + name: QueueJobs.PkiSyncSendActionFailedNotifications; + payload: TQueueSendPkiSyncActionFailedNotificationsDTO; + }; [QueueName.ProjectV3Migration]: { name: QueueJobs.ProjectV3Migration; payload: { projectId: string }; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index eccad2956..ebadff15d 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -248,6 +248,9 @@ import { pkiCollectionServiceFactory } from "@app/services/pki-collection/pki-co import { pkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal"; import { pkiSubscriberQueueServiceFactory } from "@app/services/pki-subscriber/pki-subscriber-queue"; import { pkiSubscriberServiceFactory } from "@app/services/pki-subscriber/pki-subscriber-service"; +import { pkiSyncDALFactory } from "@app/services/pki-sync/pki-sync-dal"; +import { pkiSyncQueueFactory } from "@app/services/pki-sync/pki-sync-queue"; +import { pkiSyncServiceFactory } from "@app/services/pki-sync/pki-sync-service"; import { pkiTemplatesDALFactory } from "@app/services/pki-templates/pki-templates-dal"; import { pkiTemplatesServiceFactory } from "@app/services/pki-templates/pki-templates-service"; import { projectDALFactory } from "@app/services/project/project-dal"; @@ -975,6 +978,7 @@ export const registerRoutes = async ( const pkiCollectionDAL = pkiCollectionDALFactory(db); const pkiCollectionItemDAL = pkiCollectionItemDALFactory(db); const pkiSubscriberDAL = pkiSubscriberDALFactory(db); + const pkiSyncDAL = pkiSyncDALFactory(db); const pkiTemplatesDAL = pkiTemplatesDALFactory(db); const instanceRelayConfigDAL = instanceRelayConfigDalFactory(db); @@ -984,21 +988,6 @@ export const registerRoutes = async ( const orgGatewayConfigV2DAL = orgGatewayConfigV2DalFactory(db); - const certificateService = certificateServiceFactory({ - certificateDAL, - certificateBodyDAL, - certificateSecretDAL, - certificateAuthorityDAL, - certificateAuthorityCertDAL, - certificateAuthorityCrlDAL, - certificateAuthoritySecretDAL, - projectDAL, - kmsService, - permissionService, - pkiCollectionDAL, - pkiCollectionItemDAL - }); - const sshCertificateAuthorityService = sshCertificateAuthorityServiceFactory({ sshCertificateAuthorityDAL, sshCertificateAuthoritySecretDAL, @@ -1977,6 +1966,38 @@ export const registerRoutes = async ( internalCaFns }); + const pkiSyncQueue = pkiSyncQueueFactory({ + queueService, + kmsService, + appConnectionDAL, + keyStore, + pkiSyncDAL, + auditLogService, + projectMembershipDAL, + projectDAL, + licenseService, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL + }); + + const certificateService = certificateServiceFactory({ + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + certificateAuthorityCrlDAL, + certificateAuthoritySecretDAL, + projectDAL, + kmsService, + permissionService, + pkiCollectionDAL, + pkiCollectionItemDAL, + pkiSyncDAL, + pkiSyncQueue + }); + const pkiSubscriberService = pkiSubscriberServiceFactory({ pkiSubscriberDAL, certificateAuthorityDAL, @@ -1990,7 +2011,18 @@ export const registerRoutes = async ( kmsService, permissionService, certificateAuthorityQueue, - internalCaFns + internalCaFns, + pkiSyncDAL, + pkiSyncQueue + }); + + const pkiSyncService = pkiSyncServiceFactory({ + pkiSyncDAL, + pkiSubscriberDAL, + appConnectionService, + permissionService, + licenseService, + pkiSyncQueue }); const pkiTemplateService = pkiTemplatesServiceFactory({ @@ -2136,6 +2168,7 @@ export const registerRoutes = async ( pkiAlert: pkiAlertService, pkiCollection: pkiCollectionService, pkiSubscriber: pkiSubscriberService, + pkiSync: pkiSyncService, pkiTemplate: pkiTemplateService, secretScanning: secretScanningService, license: licenseService, diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 6108be32b..2360496f3 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -40,6 +40,7 @@ import { registerPasswordRouter } from "./password-router"; import { registerPkiAlertRouter } from "./pki-alert-router"; import { registerPkiCollectionRouter } from "./pki-collection-router"; import { registerPkiSubscriberRouter } from "./pki-subscriber-router"; +import { registerPkiSyncRouter } from "./pki-sync-router"; import { registerProjectEnvRouter } from "./project-env-router"; import { registerProjectKeyRouter } from "./project-key-router"; import { registerProjectMembershipRouter } from "./project-membership-router"; @@ -137,6 +138,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await server.register(registerIntegrationAuthRouter, { prefix: "/integration-auth" }); await server.register(registerWebhookRouter, { prefix: "/webhooks" }); await server.register(registerIdentityRouter, { prefix: "/identities" }); + await server.register(registerPkiSyncRouter, { prefix: "/pki-syncs" }); await server.register( async (secretSharingRouter) => { diff --git a/backend/src/server/routes/v1/pki-sync-router.ts b/backend/src/server/routes/v1/pki-sync-router.ts new file mode 100644 index 000000000..547acf75d --- /dev/null +++ b/backend/src/server/routes/v1/pki-sync-router.ts @@ -0,0 +1,540 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { logger } from "@app/lib/logger"; +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { AzureKeyVaultPkiSyncConfigSchema } from "@app/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-types"; +import { PkiSync } from "@app/services/pki-sync/pki-sync-enums"; +import { PkiSyncDetailsSchema, PkiSyncListItemSchema, PkiSyncSchema } from "@app/services/pki-sync/pki-sync-schemas"; +import { TCreatePkiSyncDTO, TUpdatePkiSyncDTO } from "@app/services/pki-sync/pki-sync-types"; + +const CreatePkiSyncRequestBodySchema = z.object({ + name: z.string().trim().min(1).max(64), + description: z.string().optional(), + destination: z.nativeEnum(PkiSync), + isAutoSyncEnabled: z.boolean().default(true), + destinationConfig: z + .discriminatedUnion("destination", [ + z.object({ + destination: z.literal(PkiSync.AzureKeyVault), + config: AzureKeyVaultPkiSyncConfigSchema + }) + ]) + .transform(({ config }) => config), + syncOptions: z.record(z.unknown()).default({}), + subscriberId: z.string().optional(), + connectionId: z.string(), + projectId: z.string().trim().min(1) +}); + +const UpdatePkiSyncRequestBodySchema = z.object({ + name: z.string().trim().min(1).max(64).optional(), + description: z.string().optional(), + isAutoSyncEnabled: z.boolean().optional(), + destinationConfig: z.record(z.unknown()).optional(), + syncOptions: z.record(z.unknown()).optional(), + subscriberId: z.string().optional(), + connectionId: z.string().optional() +}); + +export const registerPkiSyncRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/options", + config: { + rateLimit: readLimit + }, + schema: { + description: "Get PKI sync options", + security: [ + { + bearerAuth: [] + } + ], + response: { + 200: { + description: "PKI sync options retrieved successfully", + content: { + "application/json": { + schema: z.object({ + pkiSyncOptions: z.array( + z.object({ + name: z.string(), + destination: z.nativeEnum(PkiSync), + canImportCertificates: z.boolean(), + canRemoveCertificates: z.boolean(), + enterprise: z.boolean().optional() + }) + ) + }) + } + } + } + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN]), + handler: async () => { + const pkiSyncOptions = [ + { + name: "Azure Key Vault", + destination: PkiSync.AzureKeyVault, + canImportCertificates: true, + canRemoveCertificates: true, + enterprise: false + } + ]; + + return { pkiSyncOptions }; + } + }); + + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + description: "Create PKI sync", + security: [ + { + bearerAuth: [] + } + ], + requestBody: { + content: { + "application/json": { + schema: CreatePkiSyncRequestBodySchema + } + } + }, + response: { + 200: { + description: "PKI sync created successfully", + content: { + "application/json": { + schema: z.object({ + pkiSync: PkiSyncSchema + }) + } + } + } + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN]), + handler: async (req) => { + const requestBody = CreatePkiSyncRequestBodySchema.parse(req.body); + const createData: Omit = requestBody; + + try { + const pkiSync = await server.services.pkiSync.createPkiSync(createData, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: createData.projectId, + event: { + type: EventType.CREATE_PKI_SYNC, + metadata: { + pkiSyncId: pkiSync.id, + name: pkiSync.name, + destination: pkiSync.destination + } + } + }); + + return { pkiSync }; + } catch (error) { + logger.error("Failed to create PKI sync"); + logger.error(error); + throw error; + } + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + description: "List PKI syncs", + security: [ + { + bearerAuth: [] + } + ], + querystring: z.object({ + projectId: z.string().trim().min(1) + }), + response: { + 200: { + description: "PKI syncs retrieved successfully", + content: { + "application/json": { + schema: z.object({ + pkiSyncs: z.array(PkiSyncListItemSchema) + }) + } + } + } + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN]), + handler: async (req) => { + const pkiSyncs = await server.services.pkiSync.listPkiSyncsByProjectId( + { + projectId: req.query.projectId + }, + req.permission + ); + + return { pkiSyncs }; + } + }); + + server.route({ + method: "GET", + url: "/:pkiSyncId", + config: { + rateLimit: readLimit + }, + schema: { + description: "Get PKI sync by ID", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + pkiSyncId: z.string() + }), + querystring: z.object({ + projectId: z.string().trim().min(1) + }), + response: { + 200: { + description: "PKI sync retrieved successfully", + content: { + "application/json": { + schema: z.object({ + pkiSync: PkiSyncDetailsSchema + }) + } + } + } + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN]), + handler: async (req) => { + const pkiSync = await server.services.pkiSync.findPkiSyncById( + { + id: req.params.pkiSyncId, + projectId: req.query.projectId + }, + req.permission + ); + + return { pkiSync }; + } + }); + + server.route({ + method: "PATCH", + url: "/:pkiSyncId", + config: { + rateLimit: readLimit + }, + schema: { + description: "Update PKI sync", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + pkiSyncId: z.string() + }), + querystring: z.object({ + projectId: z.string().trim().min(1) + }), + requestBody: { + content: { + "application/json": { + schema: UpdatePkiSyncRequestBodySchema + } + } + }, + response: { + 200: { + description: "PKI sync updated successfully", + content: { + "application/json": { + schema: z.object({ + pkiSync: PkiSyncSchema + }) + } + } + } + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN]), + handler: async (req) => { + const requestBody = UpdatePkiSyncRequestBodySchema.parse(req.body); + const updateData: Omit = { + id: req.params.pkiSyncId, + projectId: req.query.projectId, + ...requestBody + }; + + try { + const pkiSync = await server.services.pkiSync.updatePkiSync(updateData, req.permission); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.query.projectId, + event: { + type: EventType.UPDATE_PKI_SYNC, + metadata: { + pkiSyncId: pkiSync.id, + name: pkiSync.name + } + } + }); + + return { pkiSync }; + } catch (error) { + logger.error("Failed to update PKI sync"); + logger.error(error); + throw error; + } + } + }); + + server.route({ + method: "DELETE", + url: "/:pkiSyncId", + config: { + rateLimit: readLimit + }, + schema: { + description: "Delete PKI sync", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + pkiSyncId: z.string() + }), + querystring: z.object({ + projectId: z.string().trim().min(1) + }), + response: { + 200: { + description: "PKI sync deleted successfully", + content: { + "application/json": { + schema: z.object({ + pkiSync: z.object({ + id: z.string(), + name: z.string(), + destination: z.nativeEnum(PkiSync) + }) + }) + } + } + } + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN]), + handler: async (req) => { + try { + const pkiSync = await server.services.pkiSync.deletePkiSync( + { + id: req.params.pkiSyncId, + projectId: req.query.projectId + }, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.query.projectId, + event: { + type: EventType.DELETE_PKI_SYNC, + metadata: { + pkiSyncId: pkiSync.id, + name: pkiSync.name, + destination: pkiSync.destination + } + } + }); + + return { pkiSync }; + } catch (error) { + logger.error("Failed to delete PKI sync"); + logger.error(error); + throw error; + } + } + }); + + server.route({ + method: "POST", + url: "/:pkiSyncId/sync", + config: { + rateLimit: readLimit + }, + schema: { + description: "Trigger PKI sync", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + pkiSyncId: z.string() + }), + querystring: z.object({ + projectId: z.string().trim().min(1) + }), + response: { + 200: { + description: "PKI sync triggered successfully", + content: { + "application/json": { + schema: z.object({ + message: z.string() + }) + } + } + } + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN]), + handler: async (req) => { + try { + const result = await server.services.pkiSync.triggerPkiSyncSyncCertificatesById( + { + id: req.params.pkiSyncId, + projectId: req.query.projectId + }, + req.permission + ); + + return result; + } catch (error) { + logger.error("Failed to trigger PKI sync"); + logger.error(error); + throw error; + } + } + }); + + server.route({ + method: "POST", + url: "/:pkiSyncId/import", + config: { + rateLimit: readLimit + }, + schema: { + description: "Import certificates from PKI sync destination", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + pkiSyncId: z.string() + }), + querystring: z.object({ + projectId: z.string().trim().min(1) + }), + response: { + 200: { + description: "PKI sync import triggered successfully", + content: { + "application/json": { + schema: z.object({ + message: z.string() + }) + } + } + } + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN]), + handler: async (req) => { + try { + const result = await server.services.pkiSync.triggerPkiSyncImportCertificatesById( + { + id: req.params.pkiSyncId, + projectId: req.query.projectId + }, + req.permission + ); + + return result; + } catch (error) { + logger.error("Failed to trigger PKI sync import certificates"); + logger.error(error); + throw error; + } + } + }); + + server.route({ + method: "POST", + url: "/:pkiSyncId/remove", + config: { + rateLimit: readLimit + }, + schema: { + description: "Remove certificates from PKI sync destination", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + pkiSyncId: z.string() + }), + querystring: z.object({ + projectId: z.string().trim().min(1) + }), + response: { + 200: { + description: "PKI sync remove triggered successfully", + content: { + "application/json": { + schema: z.object({ + message: z.string() + }) + } + } + } + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN]), + handler: async (req) => { + try { + const result = await server.services.pkiSync.triggerPkiSyncRemoveCertificatesById( + { + id: req.params.pkiSyncId, + projectId: req.query.projectId + }, + req.permission + ); + + return result; + } catch (error) { + logger.error("Failed to trigger PKI sync remove certificates"); + logger.error(error); + throw error; + } + } + }); +}; diff --git a/backend/src/services/certificate/certificate-dal.ts b/backend/src/services/certificate/certificate-dal.ts index 9db473236..377563ced 100644 --- a/backend/src/services/certificate/certificate-dal.ts +++ b/backend/src/services/certificate/certificate-dal.ts @@ -25,6 +25,20 @@ export const certificateDALFactory = (db: TDbClient) => { } }; + const findAllActiveCertsForSubscriber = async ({ subscriberId }: { subscriberId: string }) => { + try { + const certs = await db + .replicaNode()(TableName.Certificate) + .where({ pkiSubscriberId: subscriberId, status: CertStatus.ACTIVE }) + .where("notAfter", ">", new Date()) + .orderBy("notBefore", "desc"); + + return certs; + } catch (error) { + throw new DatabaseError({ error, name: "Find all active certificates for subscriber" }); + } + }; + const countCertificatesInProject = async ({ projectId, friendlyName, @@ -83,6 +97,7 @@ export const certificateDALFactory = (db: TDbClient) => { ...certificateOrm, countCertificatesInProject, countCertificatesForPkiSubscriber, - findLatestActiveCertForSubscriber + findLatestActiveCertForSubscriber, + findAllActiveCertsForSubscriber }; }; diff --git a/backend/src/services/certificate/certificate-service.ts b/backend/src/services/certificate/certificate-service.ts index 7adc60d52..f20f69949 100644 --- a/backend/src/services/certificate/certificate-service.ts +++ b/backend/src/services/certificate/certificate-service.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-await-in-loop */ import { ForbiddenError } from "@casl/ability"; import * as x509 from "@peculiar/x509"; @@ -10,6 +11,7 @@ import { } from "@app/ee/services/permission/project-permission"; import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; import { TCertificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal"; @@ -20,6 +22,8 @@ import { TCertificateAuthoritySecretDALFactory } from "@app/services/certificate import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TPkiCollectionDALFactory } from "@app/services/pki-collection/pki-collection-dal"; import { TPkiCollectionItemDALFactory } from "@app/services/pki-collection/pki-collection-item-dal"; +import { TPkiSyncDALFactory } from "@app/services/pki-sync/pki-sync-dal"; +import { TPkiSyncQueueFactory } from "@app/services/pki-sync/pki-sync-queue"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; @@ -53,6 +57,8 @@ type TCertificateServiceFactoryDep = { projectDAL: Pick; kmsService: Pick; permissionService: Pick; + pkiSyncDAL: Pick; + pkiSyncQueue: Pick; }; export type TCertificateServiceFactory = ReturnType; @@ -69,8 +75,32 @@ export const certificateServiceFactory = ({ pkiCollectionItemDAL, projectDAL, kmsService, - permissionService + permissionService, + pkiSyncDAL, + pkiSyncQueue }: TCertificateServiceFactoryDep) => { + /** + * Trigger auto sync for PKI syncs connected to a PKI subscriber when certificates are issued/revoked/deleted + */ + const triggerAutoSyncForSubscriber = async (subscriberId: string) => { + try { + // Find all PKI syncs that are connected to this subscriber and have auto sync enabled + const pkiSyncs = await pkiSyncDAL.find({ + subscriberId, + isAutoSyncEnabled: true + }); + + // Queue sync jobs for each auto sync enabled PKI sync + for (const pkiSync of pkiSyncs) { + await pkiSyncQueue.queuePkiSyncSyncCertificatesById({ syncId: pkiSync.id }); + } + } catch (error) { + // Don't throw error to avoid breaking the main certificate operation + // Just log the auto sync failure + logger.error(error, `Failed to trigger auto sync for subscriber ${subscriberId}:`); + } + }; + /** * Return details for certificate with serial number [serialNumber] */ @@ -158,6 +188,11 @@ export const certificateServiceFactory = ({ const deletedCert = await certificateDAL.deleteById(cert.id); + // Trigger auto sync for PKI syncs connected to this certificate's subscriber + if (cert.pkiSubscriberId) { + await triggerAutoSyncForSubscriber(cert.pkiSubscriberId); + } + return { deletedCert }; @@ -222,6 +257,11 @@ export const certificateServiceFactory = ({ } ); + // Trigger auto sync for PKI syncs connected to this certificate's subscriber + if (cert.pkiSubscriberId) { + await triggerAutoSyncForSubscriber(cert.pkiSubscriberId); + } + // Note: External CA revocation handling would go here for supported CA types // Currently, only internal CAs and ACME CAs support revocation diff --git a/backend/src/services/pki-subscriber/pki-subscriber-service.ts b/backend/src/services/pki-subscriber/pki-subscriber-service.ts index 391b5b2a8..43250c8c3 100644 --- a/backend/src/services/pki-subscriber/pki-subscriber-service.ts +++ b/backend/src/services/pki-subscriber/pki-subscriber-service.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-await-in-loop */ /* eslint-disable no-bitwise */ import { ForbiddenError, subject } from "@casl/ability"; import * as x509 from "@peculiar/x509"; @@ -12,6 +13,7 @@ import { } from "@app/ee/services/permission/project-permission"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; import { ms } from "@app/lib/ms"; import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; @@ -36,6 +38,8 @@ import { import { TCertificateAuthoritySecretDALFactory } from "@app/services/certificate-authority/certificate-authority-secret-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal"; +import { TPkiSyncDALFactory } from "@app/services/pki-sync/pki-sync-dal"; +import { TPkiSyncQueueFactory } from "@app/services/pki-sync/pki-sync-queue"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; @@ -79,6 +83,8 @@ type TPkiSubscriberServiceFactoryDep = { kmsService: Pick; permissionService: Pick; internalCaFns: ReturnType; + pkiSyncDAL: Pick; + pkiSyncQueue: Pick; }; export type TPkiSubscriberServiceFactory = ReturnType; @@ -96,8 +102,32 @@ export const pkiSubscriberServiceFactory = ({ kmsService, permissionService, certificateAuthorityQueue, - internalCaFns + internalCaFns, + pkiSyncDAL, + pkiSyncQueue }: TPkiSubscriberServiceFactoryDep) => { + /** + * Trigger auto sync for PKI syncs connected to a PKI subscriber when certificates are issued + */ + const triggerAutoSyncForSubscriber = async (subscriberId: string) => { + try { + // Find all PKI syncs that are connected to this subscriber and have auto sync enabled + const pkiSyncs = await pkiSyncDAL.find({ + subscriberId, + isAutoSyncEnabled: true + }); + + // Queue sync jobs for each auto sync enabled PKI sync + for (const pkiSync of pkiSyncs) { + await pkiSyncQueue.queuePkiSyncSyncCertificatesById({ syncId: pkiSync.id }); + } + } catch (error) { + // Don't throw error to avoid breaking the main certificate operation + // Just log the auto sync failure + logger.error(error, `Failed to trigger auto sync for subscriber ${subscriberId}:`); + } + }; + const createSubscriber = async ({ name, commonName, @@ -413,7 +443,12 @@ export const pkiSubscriberServiceFactory = ({ const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(subscriber.caId); if (ca.internalCa?.id) { - return internalCaFns.issueCertificate(subscriber, ca); + const result = await internalCaFns.issueCertificate(subscriber, ca); + + // Trigger auto sync for PKI syncs connected to this subscriber after certificate issuance + await triggerAutoSyncForSubscriber(subscriber.id); + + return result; } throw new BadRequestError({ message: "CA does not support immediate issuance of certificates" }); @@ -671,6 +706,9 @@ export const pkiSubscriberServiceFactory = ({ return cert; }); + // Trigger auto sync for PKI syncs connected to this subscriber after certificate signing + await triggerAutoSyncForSubscriber(subscriber.id); + return { certificate: leafCert.toString("pem"), certificateChain: `${issuingCaCertificate}\n${caCertChain}`.trim(), diff --git a/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-fns.ts b/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-fns.ts new file mode 100644 index 000000000..da308bcdb --- /dev/null +++ b/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-fns.ts @@ -0,0 +1,521 @@ +/* eslint-disable no-await-in-loop */ +import { AxiosError } from "axios"; + +import { request } from "@app/lib/config/request"; +import { logger } from "@app/lib/logger"; +import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; +import { getAzureConnectionAccessToken } from "@app/services/app-connection/azure-key-vault"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TCertificateMap } from "@app/services/pki-sync/pki-sync-types"; + +import { PkiSyncError } from "../pki-sync-errors"; +import { GetAzureKeyVaultCertificate, TAzureKeyVaultPkiSyncWithCredentials } from "./azure-key-vault-pki-sync-types"; + +type TAzureKeyVaultPkiSyncFactoryDeps = { + appConnectionDAL: Pick; + kmsService: Pick; +}; + +export const azureKeyVaultPkiSyncFactory = ({ kmsService, appConnectionDAL }: TAzureKeyVaultPkiSyncFactoryDeps) => { + const $getAzureKeyVaultCertificates = async (accessToken: string, vaultBaseUrl: string) => { + const paginateAzureKeyVaultCertificates = async () => { + let result: GetAzureKeyVaultCertificate[] = []; + + let currentUrl = `${vaultBaseUrl}/certificates?api-version=7.4`; + + while (currentUrl) { + const res = await request.get<{ value: GetAzureKeyVaultCertificate[]; nextLink: string }>(currentUrl, { + headers: { + Authorization: `Bearer ${accessToken}` + } + }); + + result = result.concat(res.data.value); + currentUrl = res.data.nextLink; + } + + return result; + }; + + const getAzureKeyVaultCertificates = await paginateAzureKeyVaultCertificates(); + + const enabledAzureKeyVaultCertificates = getAzureKeyVaultCertificates.filter((cert) => cert.attributes.enabled); + + // disabled certificates to skip sending updates to + const disabledAzureKeyVaultCertificateKeys = getAzureKeyVaultCertificates + .filter(({ attributes }) => !attributes.enabled) + .map((getAzureKeyVaultCertificate) => { + return getAzureKeyVaultCertificate.id.substring(getAzureKeyVaultCertificate.id.lastIndexOf("/") + 1); + }); + + let lastSlashIndex: number; + const res = ( + await Promise.all( + enabledAzureKeyVaultCertificates.map(async (getAzureKeyVaultCertificate) => { + if (!lastSlashIndex) { + lastSlashIndex = getAzureKeyVaultCertificate.id.lastIndexOf("/"); + } + + // Get the certificate details + const azureKeyVaultCertificate = await request.get( + `${getAzureKeyVaultCertificate.id}?api-version=7.4`, + { + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); + + // Convert base64 certificate to PEM format if available + let certPem = ""; + if (azureKeyVaultCertificate.data.cer) { + try { + // Azure Key Vault stores certificate in base64 DER format + // We need to convert it to PEM format with proper headers + const base64Cert = azureKeyVaultCertificate.data.cer; + certPem = `-----BEGIN CERTIFICATE-----\n${base64Cert.match(/.{1,64}/g)?.join("\n")}\n-----END CERTIFICATE-----`; + } catch (error) { + // If conversion fails, assume it's already in PEM format + certPem = azureKeyVaultCertificate.data.cer; + } + } + + return { + ...azureKeyVaultCertificate.data, + key: getAzureKeyVaultCertificate.id.substring(lastSlashIndex + 1), + cert: certPem, + privateKey: "" // Private keys cannot be extracted from Azure Key Vault for security reasons + }; + }) + ) + ).reduce( + (obj, certificate) => ({ + ...obj, + [certificate.key]: { + cert: certificate.cert, + privateKey: certificate.privateKey + } + }), + {} as Record + ); + + return { + vaultCertificates: res, + disabledAzureKeyVaultCertificateKeys + }; + }; + + const syncCertificates = async (pkiSync: TAzureKeyVaultPkiSyncWithCredentials, certificateMap: TCertificateMap) => { + logger.info( + { + syncId: pkiSync.id, + vaultUrl: pkiSync.destinationConfig.vaultBaseUrl, + certificateCount: Object.keys(certificateMap).length + }, + "Starting Azure Key Vault certificate sync" + ); + + const { accessToken } = await getAzureConnectionAccessToken(pkiSync.connection.id, appConnectionDAL, kmsService); + + const { vaultCertificates, disabledAzureKeyVaultCertificateKeys } = await $getAzureKeyVaultCertificates( + accessToken, + pkiSync.destinationConfig.vaultBaseUrl + ); + + logger.info( + { + syncId: pkiSync.id, + existingCertCount: Object.keys(vaultCertificates).length, + disabledCertCount: disabledAzureKeyVaultCertificateKeys.length + }, + "Retrieved existing certificates from Azure Key Vault" + ); + + const setCertificates: { + key: string; + cert: string; + privateKey: string; + }[] = []; + + // Track which certificates should exist in Azure Key Vault + const activeCertificateNames = Object.keys(certificateMap); + + // Iterate through certificates to sync to Azure Key Vault + Object.entries(certificateMap).forEach(([certName, { cert, privateKey }]) => { + if (disabledAzureKeyVaultCertificateKeys.includes(certName)) { + logger.debug( + { syncId: pkiSync.id, certificateName: certName }, + "Skipping disabled certificate in Azure Key Vault" + ); + return; + } + + const existingCert = vaultCertificates[certName]; + const shouldUpdateCert = !existingCert || existingCert.cert !== cert; + + if (shouldUpdateCert) { + setCertificates.push({ + key: certName, + cert, + privateKey + }); + logger.debug( + { syncId: pkiSync.id, certificateName: certName, isUpdate: !!existingCert }, + "Certificate will be uploaded to Azure Key Vault" + ); + } else { + logger.debug( + { syncId: pkiSync.id, certificateName: certName }, + "Certificate already up to date in Azure Key Vault" + ); + } + }); + + // Identify expired/removed certificates that need to be cleaned up from Azure Key Vault + // Only remove certificates that were managed by Infisical (start with 'Infisical-') + const certificatesToRemove = Object.keys(vaultCertificates).filter( + (vaultCertName) => + vaultCertName.startsWith("Infisical-") && + !activeCertificateNames.includes(vaultCertName) && + !disabledAzureKeyVaultCertificateKeys.includes(vaultCertName) + ); + + logger.info( + { + syncId: pkiSync.id, + certificatesToUpload: setCertificates.length, + certificatesToRemove: certificatesToRemove.length, + totalCertificates: Object.keys(certificateMap).length + }, + "Determined certificates to upload and remove from Azure Key Vault" + ); + + // Upload certificates to Azure Key Vault + const uploadPromises = setCertificates.map(async ({ key, cert, privateKey }) => { + try { + // Combine certificate and private key in PEM format for Azure Key Vault + // Azure Key Vault accepts PEM format with both cert and private key + let combinedPem = cert; + if (privateKey) { + combinedPem = `${privateKey}\n${cert}`; + } + + // Convert to base64 for Azure Key Vault import + const base64Cert = Buffer.from(combinedPem).toString("base64"); + + const importData = { + value: base64Cert, + policy: { + key_props: { + exportable: true, + key_size: 2048, + kty: "RSA", + reuse_key: false + }, + secret_props: { + contentType: "application/x-pem-file" + }, + x509_props: { + subject: "", + sans: { + dns_names: [], + emails: [], + upns: [] + } + } + } + }; + + const response = await request.post( + `${pkiSync.destinationConfig.vaultBaseUrl}/certificates/${encodeURIComponent(key)}/import?api-version=7.4`, + importData, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json" + } + } + ); + + logger.info( + { syncId: pkiSync.id, certificateName: key }, + "Successfully uploaded certificate to Azure Key Vault" + ); + + return { key, success: true, response: response.data as unknown }; + } catch (error) { + if (error instanceof AxiosError) { + const errorMessage = + error.response?.data && typeof error.response.data === "object" && "error" in error.response.data + ? (error.response.data as { error?: { message?: string } }).error?.message || error.message + : error.message; + + // Check if the error is due to certificate in deleted but recoverable state + const isDeletedButRecoverable = + errorMessage.includes("deleted but recoverable state") || errorMessage.includes("name cannot be reused"); + + if (isDeletedButRecoverable) { + logger.warn( + { certificateKey: key, syncId: pkiSync.id }, + "Certificate exists in deleted but recoverable state in Azure Key Vault - skipping upload" + ); + // Return a successful result to avoid failing the entire sync + return { key, success: false, skipped: true, reason: "Certificate in deleted but recoverable state" }; + } + + throw new PkiSyncError({ + message: `Failed to upload certificate ${key} to Azure Key Vault: ${errorMessage}`, + cause: error, + context: { + certificateKey: key, + statusCode: error.response?.status, + responseData: error.response?.data + } + }); + } + throw error; + } + }); + + const results = await Promise.allSettled(uploadPromises); + const failedUploads = results.filter((result) => result.status === "rejected"); + const fulfilledResults = results.filter((result) => result.status === "fulfilled"); + + // Separate successful uploads from skipped certificates + const successfulUploads = fulfilledResults.filter( + (result) => result.status === "fulfilled" && result.value.success + ); + const skippedUploads = fulfilledResults.filter((result) => result.status === "fulfilled" && result.value.skipped); + + // Remove expired/removed certificates from Azure Key Vault + let removedCertificates = 0; + let failedRemovals = 0; + + if (certificatesToRemove.length > 0) { + logger.info( + { + syncId: pkiSync.id, + certificatesToRemove: certificatesToRemove.length + }, + "Removing expired/removed certificates from Azure Key Vault" + ); + + const removePromises = certificatesToRemove.map(async (certName) => { + try { + await request.delete( + `${pkiSync.destinationConfig.vaultBaseUrl}/certificates/${encodeURIComponent(certName)}?api-version=7.4`, + { + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); + + logger.info( + { syncId: pkiSync.id, certificateName: certName }, + "Successfully removed expired/removed certificate from Azure Key Vault" + ); + + return { key: certName, success: true }; + } catch (error) { + // If certificate doesn't exist (404), consider it as successfully removed + if (error instanceof AxiosError && error.response?.status === 404) { + logger.info( + { syncId: pkiSync.id, certificateName: certName }, + "Certificate not found in Azure Key Vault during sync cleanup - considering removal successful" + ); + return { key: certName, success: true, alreadyRemoved: true }; + } + + logger.error( + { error, syncId: pkiSync.id, certificateName: certName }, + "Failed to remove expired/removed certificate from Azure Key Vault" + ); + + // Don't throw here - we want to continue with other operations + return { key: certName, success: false, error: error as Error }; + } + }); + + const removeResults = await Promise.allSettled(removePromises); + const successfulRemovals = removeResults.filter( + (result) => result.status === "fulfilled" && result.value.success + ); + removedCertificates = successfulRemovals.length; + failedRemovals = removeResults.length - removedCertificates; + + if (failedRemovals > 0) { + logger.warn( + { + syncId: pkiSync.id, + failedRemovals, + successfulRemovals: removedCertificates + }, + "Some expired/removed certificates could not be removed from Azure Key Vault" + ); + } + } + + // Log skipped certificates for transparency + if (skippedUploads.length > 0) { + const skippedNames = skippedUploads.map((result) => + result.status === "fulfilled" ? result.value.key : "unknown" + ); + logger.info( + { + syncId: pkiSync.id, + skippedCertificates: skippedNames, + skippedCount: skippedUploads.length + }, + "Some certificates were skipped due to Azure Key Vault constraints" + ); + } + + logger.info( + { + syncId: pkiSync.id, + successfulUploads: successfulUploads.length, + failedUploads: failedUploads.length, + skippedUploads: skippedUploads.length, + removedCertificates, + failedRemovals, + skippedCertificates: Object.keys(certificateMap).length - setCertificates.length + }, + "Azure Key Vault certificate sync completed" + ); + + if (failedUploads.length > 0) { + const failedReasons = failedUploads.map((failure) => { + if (failure.status === "rejected") { + return (failure.reason as Error)?.message || "Unknown error"; + } + return "Unknown error"; + }); + + logger.error( + { + syncId: pkiSync.id, + failedReasons, + failedCount: failedUploads.length + }, + "Some certificates failed to upload to Azure Key Vault" + ); + + throw new PkiSyncError({ + message: `Failed to upload ${failedUploads.length} certificate(s) to Azure Key Vault`, + context: { + failedReasons, + totalCertificates: setCertificates.length, + failedCount: failedUploads.length + } + }); + } + + return { + uploaded: setCertificates.length, + removed: removedCertificates, + failedRemovals, + skipped: Object.keys(certificateMap).length - setCertificates.length + }; + }; + + const importCertificates = async (pkiSync: TAzureKeyVaultPkiSyncWithCredentials): Promise => { + const { accessToken } = await getAzureConnectionAccessToken(pkiSync.connection.id, appConnectionDAL, kmsService); + + const { vaultCertificates } = await $getAzureKeyVaultCertificates( + accessToken, + pkiSync.destinationConfig.vaultBaseUrl + ); + + return vaultCertificates; + }; + + const removeCertificates = async (pkiSync: TAzureKeyVaultPkiSyncWithCredentials, certificateNames: string[]) => { + const { accessToken } = await getAzureConnectionAccessToken(pkiSync.connection.id, appConnectionDAL, kmsService); + + // Only remove certificates that are managed by Infisical (start with 'Infisical-' prefix) + const infisicalManagedCertNames = certificateNames.filter((certName) => certName.startsWith("Infisical-")); + + if (infisicalManagedCertNames.length < certificateNames.length) { + logger.debug( + { + syncId: pkiSync.id, + totalRequested: certificateNames.length, + infisicalManaged: infisicalManagedCertNames.length, + skipped: certificateNames.length - infisicalManagedCertNames.length + }, + "Filtered out non-Infisical certificates from removal request" + ); + } + + const removePromises = infisicalManagedCertNames.map(async (certName) => { + try { + const response = await request.delete( + `${pkiSync.destinationConfig.vaultBaseUrl}/certificates/${encodeURIComponent(certName)}?api-version=7.4`, + { + headers: { + Authorization: `Bearer ${accessToken}` + } + } + ); + + return { key: certName, success: true, response: response.data as unknown }; + } catch (error) { + if (error instanceof AxiosError) { + // If certificate doesn't exist (404), consider it as successfully removed + if (error.response?.status === 404) { + logger.info( + { syncId: pkiSync.id, certificateName: certName }, + "Certificate not found in Azure Key Vault - considering removal successful" + ); + return { key: certName, success: true, alreadyRemoved: true }; + } + + throw new PkiSyncError({ + message: `Failed to remove certificate ${certName} from Azure Key Vault`, + cause: error, + context: { + certificateKey: certName, + statusCode: error.response?.status, + responseData: error.response?.data + } + }); + } + throw error; + } + }); + + const results = await Promise.allSettled(removePromises); + const failedRemovals = results.filter((result) => result.status === "rejected"); + + if (failedRemovals.length > 0) { + const failedReasons = failedRemovals.map((failure) => { + if (failure.status === "rejected") { + return (failure.reason as Error)?.message || "Unknown error"; + } + return "Unknown error"; + }); + + throw new PkiSyncError({ + message: `Failed to remove ${failedRemovals.length} certificate(s) from Azure Key Vault`, + context: { + failedReasons, + totalCertificates: infisicalManagedCertNames.length, + failedCount: failedRemovals.length + } + }); + } + + return { + removed: infisicalManagedCertNames.length - failedRemovals.length, + failed: failedRemovals.length, + skipped: certificateNames.length - infisicalManagedCertNames.length + }; + }; + + return { + syncCertificates, + importCertificates, + removeCertificates + }; +}; diff --git a/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-types.ts b/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-types.ts new file mode 100644 index 000000000..4549896f3 --- /dev/null +++ b/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-types.ts @@ -0,0 +1,29 @@ +import { z } from "zod"; + +import { TPkiSyncWithCredentials } from "../pki-sync-types"; + +export type GetAzureKeyVaultCertificate = { + id: string; + value: string; + attributes: { + enabled: boolean; + created: number; + updated: number; + recoveryLevel: string; + tags?: Record; + }; + x5t?: string; + contentType?: string; + key?: string; + cer?: string; +}; + +export const AzureKeyVaultPkiSyncConfigSchema = z.object({ + vaultBaseUrl: z.string().url() +}); + +export type TAzureKeyVaultPkiSyncConfig = z.infer; + +export type TAzureKeyVaultPkiSyncWithCredentials = TPkiSyncWithCredentials & { + destinationConfig: TAzureKeyVaultPkiSyncConfig; +}; diff --git a/backend/src/services/pki-sync/pki-sync-dal.ts b/backend/src/services/pki-sync/pki-sync-dal.ts new file mode 100644 index 000000000..151d93f77 --- /dev/null +++ b/backend/src/services/pki-sync/pki-sync-dal.ts @@ -0,0 +1,181 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName, TPkiSyncs } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { buildFindFilter, ormify, prependTableNameToFindFilter, selectAllTableCols } from "@app/lib/knex"; + +import { PkiSync } from "./pki-sync-enums"; + +export type TPkiSyncDALFactory = ReturnType; + +type PkiSyncFindFilter = Parameters>[0]; + +const basePkiSyncQuery = ({ filter, db, tx }: { db: TDbClient; filter?: PkiSyncFindFilter; tx?: Knex }) => { + const query = (tx || db.replicaNode())(TableName.PkiSync) + .leftJoin(TableName.AppConnection, `${TableName.PkiSync}.connectionId`, `${TableName.AppConnection}.id`) + .select(selectAllTableCols(TableName.PkiSync)) + .select( + // app connection fields + db.ref("name").withSchema(TableName.AppConnection).as("appConnectionName"), + db.ref("app").withSchema(TableName.AppConnection).as("appConnectionApp"), + db.ref("encryptedCredentials").withSchema(TableName.AppConnection).as("appConnectionEncryptedCredentials"), + db.ref("orgId").withSchema(TableName.AppConnection).as("appConnectionOrgId"), + db.ref("method").withSchema(TableName.AppConnection).as("appConnectionMethod"), + db.ref("description").withSchema(TableName.AppConnection).as("appConnectionDescription"), + db.ref("version").withSchema(TableName.AppConnection).as("appConnectionVersion"), + db.ref("gatewayId").withSchema(TableName.AppConnection).as("appConnectionGatewayId"), + db.ref("createdAt").withSchema(TableName.AppConnection).as("appConnectionCreatedAt"), + db.ref("updatedAt").withSchema(TableName.AppConnection).as("appConnectionUpdatedAt"), + db + .ref("isPlatformManagedCredentials") + .withSchema(TableName.AppConnection) + .as("appConnectionIsPlatformManagedCredentials"), + db.ref("encryptedCredentials").withSchema(TableName.AppConnection).as("appConnectionEncryptedCredentials") + ); + + if (filter) { + // eslint-disable-next-line @typescript-eslint/no-misused-promises + void query.where(buildFindFilter(prependTableNameToFindFilter(TableName.PkiSync, filter))); + } + + return query; +}; + +const expandPkiSync = (pkiSync: Awaited>[number]) => { + const { + appConnectionName, + appConnectionApp, + appConnectionEncryptedCredentials, + appConnectionOrgId, + appConnectionMethod, + appConnectionDescription, + appConnectionVersion, + appConnectionGatewayId, + appConnectionCreatedAt, + appConnectionUpdatedAt, + appConnectionIsPlatformManagedCredentials, + ...el + } = pkiSync; + + return { + ...el, + destination: el.destination as PkiSync, + destinationConfig: el.destinationConfig as Record, + syncOptions: el.syncOptions as Record, + appConnectionName, + appConnectionApp, + connection: { + id: el.connectionId, + name: appConnectionName, + app: appConnectionApp, + encryptedCredentials: appConnectionEncryptedCredentials, + orgId: appConnectionOrgId, + method: appConnectionMethod, + description: appConnectionDescription, + version: appConnectionVersion, + gatewayId: appConnectionGatewayId, + createdAt: appConnectionCreatedAt, + updatedAt: appConnectionUpdatedAt, + isPlatformManagedCredentials: appConnectionIsPlatformManagedCredentials + } + }; +}; + +export const pkiSyncDALFactory = (db: TDbClient) => { + const pkiSyncOrm = ormify(db, TableName.PkiSync); + + const findByProjectId = async (projectId: string, tx?: Knex) => { + try { + const pkiSyncs = await basePkiSyncQuery({ filter: { projectId }, db, tx }); + return pkiSyncs.map(expandPkiSync); + } catch (error) { + throw new DatabaseError({ error, name: "Find By Project ID - PKI Sync" }); + } + }; + + const findBySubscriberId = async (subscriberId: string, tx?: Knex) => { + try { + const pkiSyncs = await basePkiSyncQuery({ filter: { subscriberId }, db, tx }); + return pkiSyncs.map(expandPkiSync); + } catch (error) { + throw new DatabaseError({ error, name: "Find By Subscriber ID - PKI Sync" }); + } + }; + + const findByIdAndProjectId = async (id: string, projectId: string, tx?: Knex) => { + try { + const pkiSync = await basePkiSyncQuery({ filter: { id, projectId }, db, tx }).first(); + return pkiSync ? expandPkiSync(pkiSync) : undefined; + } catch (error) { + throw new DatabaseError({ error, name: "Find By ID and Project ID - PKI Sync" }); + } + }; + + const findByNameAndProjectId = async (name: string, projectId: string, tx?: Knex) => { + try { + const pkiSync = await basePkiSyncQuery({ filter: { name, projectId }, db, tx }).first(); + return pkiSync ? expandPkiSync(pkiSync) : undefined; + } catch (error) { + throw new DatabaseError({ error, name: "Find By Name and Project ID - PKI Sync" }); + } + }; + + const findById = async (id: string, tx?: Knex) => { + try { + const pkiSync = await basePkiSyncQuery({ filter: { id }, db, tx }).first(); + return pkiSync ? expandPkiSync(pkiSync) : undefined; + } catch (error) { + throw new DatabaseError({ error, name: "Find By ID - PKI Sync" }); + } + }; + + const findOne = async (filter: Parameters<(typeof pkiSyncOrm)["findOne"]>[0], tx?: Knex) => { + try { + const pkiSync = await basePkiSyncQuery({ filter, db, tx }).first(); + return pkiSync ? expandPkiSync(pkiSync) : undefined; + } catch (error) { + throw new DatabaseError({ error, name: "Find One - PKI Sync" }); + } + }; + + const find = async (filter: Parameters<(typeof pkiSyncOrm)["find"]>[0], tx?: Knex) => { + try { + const pkiSyncs = await basePkiSyncQuery({ filter, db, tx }); + return pkiSyncs.map(expandPkiSync); + } catch (error) { + throw new DatabaseError({ error, name: "Find - PKI Sync" }); + } + }; + + const create = async (data: Parameters<(typeof pkiSyncOrm)["create"]>[0]) => { + const pkiSync = (await pkiSyncOrm.transaction(async (tx) => { + const sync = await pkiSyncOrm.create(data, tx); + return basePkiSyncQuery({ filter: { id: sync.id }, db, tx }).first(); + }))!; + + return expandPkiSync(pkiSync); + }; + + const updateById = async (syncId: string, data: Parameters<(typeof pkiSyncOrm)["updateById"]>[1]) => { + const pkiSync = (await pkiSyncOrm.transaction(async (tx) => { + const sync = await pkiSyncOrm.updateById(syncId, data, tx); + return basePkiSyncQuery({ filter: { id: sync.id }, db, tx }).first(); + }))!; + + return expandPkiSync(pkiSync); + }; + + return { + ...pkiSyncOrm, + findByProjectId, + findBySubscriberId, + findByIdAndProjectId, + findByNameAndProjectId, + findById, + findOne, + find, + create, + updateById + }; +}; diff --git a/backend/src/services/pki-sync/pki-sync-enums.ts b/backend/src/services/pki-sync/pki-sync-enums.ts new file mode 100644 index 000000000..6382d4784 --- /dev/null +++ b/backend/src/services/pki-sync/pki-sync-enums.ts @@ -0,0 +1,22 @@ +export enum PkiSync { + AzureKeyVault = "azure-key-vault" +} + +export enum PkiSyncStatus { + Pending = "PENDING", + Running = "RUNNING", + Success = "SUCCESS", + Failed = "FAILED" +} + +export enum PkiSyncImportBehavior { + ImportAllSecrets = "IMPORT_ALL_SECRETS", + PreferInfisicalSecrets = "PREFER_INFISICAL_SECRETS", + PreferExternalSecrets = "PREFER_EXTERNAL_SECRETS" +} + +export enum PkiSyncAction { + SyncCertificates = "sync-certificates", + ImportCertificates = "import-certificates", + RemoveCertificates = "remove-certificates" +} diff --git a/backend/src/services/pki-sync/pki-sync-errors.ts b/backend/src/services/pki-sync/pki-sync-errors.ts new file mode 100644 index 000000000..80877d13b --- /dev/null +++ b/backend/src/services/pki-sync/pki-sync-errors.ts @@ -0,0 +1,25 @@ +export class PkiSyncError extends Error { + public context?: Record; + + public cause?: Error; + + public shouldRetry: boolean; + + constructor({ + message, + cause, + context, + shouldRetry = true + }: { + message: string; + cause?: Error; + context?: Record; + shouldRetry?: boolean; + }) { + super(message); + this.name = "PkiSyncError"; + this.cause = cause; + this.context = context; + this.shouldRetry = shouldRetry; + } +} diff --git a/backend/src/services/pki-sync/pki-sync-fns.ts b/backend/src/services/pki-sync/pki-sync-fns.ts new file mode 100644 index 000000000..a6027177b --- /dev/null +++ b/backend/src/services/pki-sync/pki-sync-fns.ts @@ -0,0 +1,121 @@ +import { z, ZodSchema } from "zod"; + +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { BadRequestError } from "@app/lib/errors"; +import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; + +import { PkiSync } from "./pki-sync-enums"; +import { TCertificateMap, TPkiSyncWithCredentials } from "./pki-sync-types"; + +const ENTERPRISE_PKI_SYNCS: PkiSync[] = []; + +export const enterprisePkiSyncCheck = async ( + licenseService: Pick, + orgId: string, + pkiSyncDestination: PkiSync, + errorMessage?: string +) => { + const plan = await licenseService.getPlan(orgId); + + if (!plan.enterpriseSecretSyncs && ENTERPRISE_PKI_SYNCS.includes(pkiSyncDestination)) { + throw new BadRequestError({ + message: errorMessage || "Failed to create PKI sync due to plan restriction. Upgrade plan to create PKI sync." + }); + } +}; + +export const listPkiSyncOptions = () => { + return Object.values(PkiSync); +}; + +export const matchesSchema = (schema: T, data: unknown): data is z.infer => { + return schema.safeParse(data).success; +}; + +export const parsePkiSyncErrorMessage = (error: unknown): string => { + if (error instanceof Error) { + return error.message; + } + + if (typeof error === "string") { + return error; + } + + return "An unknown error occurred during PKI sync operation"; +}; + +export const PkiSyncFns = { + getCertificates: async ( + pkiSync: TPkiSyncWithCredentials, + dependencies: { + appConnectionDAL: Pick; + kmsService: Pick; + } + ): Promise => { + switch (pkiSync.destination) { + case PkiSync.AzureKeyVault: { + const { azureKeyVaultPkiSyncFactory } = await import("./azure-key-vault/azure-key-vault-pki-sync-fns"); + const azureKeyVaultPkiSync = azureKeyVaultPkiSyncFactory(dependencies); + // Type assertion needed due to destinationConfig type differences + return azureKeyVaultPkiSync.importCertificates( + pkiSync as unknown as import("./azure-key-vault/azure-key-vault-pki-sync-types").TAzureKeyVaultPkiSyncWithCredentials + ); + } + default: + throw new Error(`Unsupported PKI sync destination: ${String(pkiSync.destination)}`); + } + }, + + syncCertificates: async ( + pkiSync: TPkiSyncWithCredentials, + certificateMap: TCertificateMap, + dependencies: { + appConnectionDAL: Pick; + kmsService: Pick; + } + ): Promise<{ + uploaded: number; + removed?: number; + failedRemovals?: number; + skipped: number; + }> => { + switch (pkiSync.destination) { + case PkiSync.AzureKeyVault: { + const { azureKeyVaultPkiSyncFactory } = await import("./azure-key-vault/azure-key-vault-pki-sync-fns"); + const azureKeyVaultPkiSync = azureKeyVaultPkiSyncFactory(dependencies); + // Type assertion needed due to destinationConfig type differences + return azureKeyVaultPkiSync.syncCertificates( + pkiSync as unknown as import("./azure-key-vault/azure-key-vault-pki-sync-types").TAzureKeyVaultPkiSyncWithCredentials, + certificateMap + ); + } + default: + throw new Error(`Unsupported PKI sync destination: ${String(pkiSync.destination)}`); + } + }, + + removeCertificates: async ( + pkiSync: TPkiSyncWithCredentials, + certificateNames: string[], + dependencies: { + appConnectionDAL: Pick; + kmsService: Pick; + } + ): Promise => { + switch (pkiSync.destination) { + case PkiSync.AzureKeyVault: { + const { azureKeyVaultPkiSyncFactory } = await import("./azure-key-vault/azure-key-vault-pki-sync-fns"); + const azureKeyVaultPkiSync = azureKeyVaultPkiSyncFactory(dependencies); + // Type assertion needed due to destinationConfig type differences + await azureKeyVaultPkiSync.removeCertificates( + pkiSync as unknown as import("./azure-key-vault/azure-key-vault-pki-sync-types").TAzureKeyVaultPkiSyncWithCredentials, + certificateNames + ); + break; + } + default: + throw new Error(`Unsupported PKI sync destination: ${String(pkiSync.destination)}`); + } + } +}; diff --git a/backend/src/services/pki-sync/pki-sync-maps.ts b/backend/src/services/pki-sync/pki-sync-maps.ts new file mode 100644 index 000000000..b667416c6 --- /dev/null +++ b/backend/src/services/pki-sync/pki-sync-maps.ts @@ -0,0 +1,11 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +import { PkiSync } from "./pki-sync-enums"; + +export const PKI_SYNC_NAME_MAP: Record = { + [PkiSync.AzureKeyVault]: "Azure Key Vault" +}; + +export const PKI_SYNC_CONNECTION_MAP: Record = { + [PkiSync.AzureKeyVault]: AppConnection.AzureKeyVault +}; diff --git a/backend/src/services/pki-sync/pki-sync-queue.ts b/backend/src/services/pki-sync/pki-sync-queue.ts new file mode 100644 index 000000000..caec59f48 --- /dev/null +++ b/backend/src/services/pki-sync/pki-sync-queue.ts @@ -0,0 +1,1032 @@ +/* eslint-disable no-await-in-loop */ +import opentelemetry from "@opentelemetry/api"; +import * as x509 from "@peculiar/x509"; +import { AxiosError } from "axios"; +import { Job } from "bullmq"; + +import { ProjectMembershipRole } from "@app/db/schemas"; +import { EventType, TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-types"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; +import { getConfig } from "@app/lib/config/env"; +import { logger } from "@app/lib/logger"; +import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; +import { decryptAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns"; +import { ActorType } from "@app/services/auth/auth-type"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; +import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; + +import { TAppConnectionDALFactory } from "../app-connection/app-connection-dal"; +import { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal"; +import { TCertificateDALFactory } from "../certificate/certificate-dal"; +import { getCertificateCredentials } from "../certificate/certificate-fns"; +import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal"; +import { CertStatus } from "../certificate/certificate-types"; +import { TPkiSyncDALFactory } from "./pki-sync-dal"; +import { PkiSyncAction } from "./pki-sync-enums"; +import { PkiSyncError } from "./pki-sync-errors"; +import { enterprisePkiSyncCheck, parsePkiSyncErrorMessage, PkiSyncFns } from "./pki-sync-fns"; +import { + PkiSyncStatus, + TCertificateMap, + TPkiSyncImportCertificatesDTO, + TPkiSyncRaw, + TPkiSyncRemoveCertificatesDTO, + TPkiSyncSyncCertificatesDTO, + TPkiSyncWithCredentials, + TQueuePkiSyncImportCertificatesByIdDTO, + TQueuePkiSyncRemoveCertificatesByIdDTO, + TQueuePkiSyncSyncCertificatesByIdDTO, + TQueueSendPkiSyncActionFailedNotificationsDTO, + TSendPkiSyncFailedNotificationsJobDTO +} from "./pki-sync-types"; + +export type TPkiSyncQueueFactory = ReturnType; + +type TPkiSyncQueueFactoryDep = { + queueService: Pick; + kmsService: Pick< + TKmsServiceFactory, + "createCipherPairWithDataKey" | "decryptWithKmsKey" | "generateKmsKey" | "encryptWithKmsKey" + >; + appConnectionDAL: Pick; + keyStore: Pick; + pkiSyncDAL: Pick; + auditLogService: Pick; + projectMembershipDAL: Pick; + projectDAL: TProjectDALFactory; + licenseService: Pick; + certificateDAL: Pick< + TCertificateDALFactory, + "findLatestActiveCertForSubscriber" | "findAllActiveCertsForSubscriber" | "create" + >; + certificateBodyDAL: Pick; + certificateSecretDAL: Pick; +}; + +type PkiSyncActionJob = Job< + TQueuePkiSyncSyncCertificatesByIdDTO | TQueuePkiSyncImportCertificatesByIdDTO | TQueuePkiSyncRemoveCertificatesByIdDTO +>; + +const JITTER_MS = 10 * 1000; +const REQUEUE_MS = 30 * 1000; +const REQUEUE_LIMIT = 30; +const CONNECTION_CONCURRENCY_LIMIT = 3; + +const getRequeueDelay = (failureCount?: number) => { + const jitter = Math.random() * JITTER_MS; + if (!failureCount) return jitter; + return REQUEUE_MS + jitter; +}; + +export const pkiSyncQueueFactory = ({ + queueService, + kmsService, + appConnectionDAL, + keyStore, + pkiSyncDAL, + auditLogService, + projectMembershipDAL, + projectDAL, + licenseService, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL +}: TPkiSyncQueueFactoryDep) => { + const appCfg = getConfig(); + + const integrationMeter = opentelemetry.metrics.getMeter("PkiSyncs"); + const syncCertificatesErrorHistogram = integrationMeter.createHistogram("pki_sync_sync_certificates_errors", { + description: "PKI Sync - sync certificates errors", + unit: "1" + }); + const importCertificatesErrorHistogram = integrationMeter.createHistogram("pki_sync_import_certificates_errors", { + description: "PKI Sync - import certificates errors", + unit: "1" + }); + const removeCertificatesErrorHistogram = integrationMeter.createHistogram("pki_sync_remove_certificates_errors", { + description: "PKI Sync - remove certificates errors", + unit: "1" + }); + + const $isConnectionConcurrencyLimitReached = async (connectionId: string) => { + const concurrencyCount = await keyStore.getItem(KeyStorePrefixes.AppConnectionConcurrentJobs(connectionId)); + + if (!concurrencyCount) return false; + + const count = Number.parseInt(concurrencyCount, 10); + + if (Number.isNaN(count)) return false; + + return count >= CONNECTION_CONCURRENCY_LIMIT; + }; + + const $incrementConnectionConcurrencyCount = async (connectionId: string) => { + const concurrencyCount = await keyStore.getItem(KeyStorePrefixes.AppConnectionConcurrentJobs(connectionId)); + + const currentCount = Number.parseInt(concurrencyCount || "0", 10); + + const incrementedCount = Number.isNaN(currentCount) ? 1 : currentCount + 1; + + await keyStore.setItemWithExpiry( + KeyStorePrefixes.AppConnectionConcurrentJobs(connectionId), + (REQUEUE_MS * REQUEUE_LIMIT) / 1000, // in seconds + incrementedCount + ); + }; + + const $decrementConnectionConcurrencyCount = async (connectionId: string) => { + const concurrencyCount = await keyStore.getItem(KeyStorePrefixes.AppConnectionConcurrentJobs(connectionId)); + + const currentCount = Number.parseInt(concurrencyCount || "0", 10); + + const decrementedCount = Math.max(0, Number.isNaN(currentCount) ? 0 : currentCount - 1); + + await keyStore.setItemWithExpiry( + KeyStorePrefixes.AppConnectionConcurrentJobs(connectionId), + (REQUEUE_MS * REQUEUE_LIMIT) / 1000, // in seconds + decrementedCount + ); + }; + + const $createCertificatesInSubscriber = async ( + pkiSync: TPkiSyncWithCredentials, + certificatesToCreate: Array<{ + name: string; + certificate: string; + privateKey?: string; + }> + ) => { + const { projectId, subscriberId } = pkiSync; + + if (!subscriberId) { + throw new Error("PKI Sync subscriber ID is required for certificate creation"); + } + + logger.info(`Creating ${certificatesToCreate.length} certificates in PKI subscriber ${subscriberId}`); + + for (const certData of certificatesToCreate) { + try { + // Validate certificate data + if (!certData.certificate || certData.certificate.trim() === "") { + logger.error(`Skipping certificate ${certData.name}: empty certificate data`); + return; + } + + // Parse certificate to extract metadata + const cert = new x509.X509Certificate(certData.certificate); + const { serialNumber } = cert; + const { notBefore } = cert; + const { notAfter } = cert; + const commonName = + cert.subject + .split(",") + .find((part) => part.trim().startsWith("CN=")) + ?.split("=")[1] + ?.trim() || certData.name; + + // Get KMS key for encryption + const kmsKeyId = await getProjectKmsCertificateKeyId({ projectId, projectDAL, kmsService }); + const kmsEncryptor = await kmsService.encryptWithKmsKey({ + kmsId: kmsKeyId + }); + + // Create certificate record + const createdCert = await certificateDAL.create({ + pkiSubscriberId: subscriberId, + status: CertStatus.ACTIVE, + serialNumber, + notBefore, + notAfter, + commonName, + friendlyName: certData.name, + projectId + }); + + // Create certificate body record with encrypted certificate + const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ + plainText: Buffer.from(certData.certificate) + }); + + await certificateBodyDAL.create({ + certId: createdCert.id, + encryptedCertificate + }); + + // Create certificate secret record with encrypted private key (if available) + if (certData.privateKey) { + const { cipherTextBlob: encryptedPrivateKey } = await kmsEncryptor({ + plainText: Buffer.from(certData.privateKey) + }); + + await certificateSecretDAL.create({ + certId: createdCert.id, + encryptedPrivateKey + }); + } + + logger.info(`Successfully created certificate ${certData.name} with ID ${createdCert.id}`); + } catch (error) { + logger.error(`Failed to create certificate ${certData.name}: ${String(error)}`); + // Continue with other certificates even if one fails + } + } + }; + + const $getInfisicalCertificates = async ( + pkiSync: TPkiSyncRaw | TPkiSyncWithCredentials + ): Promise => { + const { projectId, subscriberId } = pkiSync; + + if (!subscriberId) { + throw new PkiSyncError({ + message: "Invalid PKI Sync source configuration: subscriber no longer exists. Please update source subscriber.", + shouldRetry: false + }); + } + + const certificateMap: TCertificateMap = {}; + + try { + // Get all active certificates for the subscriber (not just the latest) + const certificates = await certificateDAL.findAllActiveCertsForSubscriber({ + subscriberId + }); + + logger.info( + { subscriberId, certificateCount: certificates.length }, + "Found active certificates for PKI sync subscriber" + ); + + for (const certificate of certificates) { + try { + // Only sync certificates issued by Infisical (not imported ones) + // Imported certificates don't have caId and certificateTemplateId + if (!certificate.caId) { + logger.debug( + { certificateId: certificate.id, subscriberId }, + "Skipping imported certificate - not syncing to destination" + ); + // eslint-disable-next-line no-continue + continue; + } + + // Check if certificate is expired + const now = new Date(); + if (certificate.notAfter < now) { + logger.debug( + { certificateId: certificate.id, subscriberId, expiredAt: certificate.notAfter }, + "Skipping expired certificate" + ); + // eslint-disable-next-line no-continue + continue; + } + + // Get the certificate body and decrypt the certificate data + const certBody = await certificateBodyDAL.findOne({ certId: certificate.id }); + + if (certBody) { + const certificateManagerKeyId = await getProjectKmsCertificateKeyId({ + projectId: certificate.projectId, + projectDAL, + kmsService + }); + + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKeyId + }); + + const decryptedCert = await kmsDecryptor({ + cipherTextBlob: certBody.encryptedCertificate + }); + + const certObj = new x509.X509Certificate(decryptedCert); + const certificatePem = certObj.toString("pem"); + + // Get private key using getCertificateCredentials - handle cases where private key doesn't exist + let certPrivateKey: string | undefined; + try { + const credentials = await getCertificateCredentials({ + certId: certificate.id, + projectId: certificate.projectId, + certificateSecretDAL, + projectDAL, + kmsService + }); + certPrivateKey = credentials.certPrivateKey; + } catch (credError) { + logger.warn( + { certificateId: certificate.id, subscriberId, error: credError }, + "Certificate private key not found - certificate may be imported or key was not stored" + ); + // Continue without private key - some providers may only need the certificate + certPrivateKey = undefined; + } + + // Use Infisical-prefixed ID for clear identification in destination + // Azure Key Vault doesn't allow underscores, so use hyphens and remove UUID hyphens + const certificateName = `Infisical-${certificate.id.replace(/-/g, "")}`; + + certificateMap[certificateName] = { + cert: certificatePem, + privateKey: certPrivateKey || "" + }; + + logger.info( + { certificateId: certificate.id, certificateName, subscriberId }, + "Successfully prepared certificate for PKI sync" + ); + } else { + logger.warn({ certificateId: certificate.id, subscriberId }, "Certificate body not found for certificate"); + } + } catch (error) { + logger.error( + { error, subscriberId, certificateId: certificate.id }, + "Failed to decrypt certificate for PKI sync" + ); + // Continue with other certificates + } + } + } catch (error) { + logger.error( + error, + `Failed to fetch certificate for subscriber [subscriberId=${subscriberId}] [projectId=${projectId}]` + ); + throw new PkiSyncError({ + message: `Failed to fetch certificate for PKI subscriber: ${error instanceof Error ? error.message : String(error)}`, + shouldRetry: true + }); + } + + return certificateMap; + }; + + const queuePkiSyncSyncCertificatesById = async (payload: TQueuePkiSyncSyncCertificatesByIdDTO) => + queueService.queue(QueueName.PkiSync, QueueJobs.PkiSyncSyncCertificates, payload, { + delay: getRequeueDelay(payload.failedToAcquireLockCount), // this is for delaying re-queued jobs if sync is locked + attempts: 5, + backoff: { + type: "exponential", + delay: 3000 + }, + removeOnComplete: true, + removeOnFail: true + }); + + const queuePkiSyncImportCertificatesById = async (payload: TQueuePkiSyncImportCertificatesByIdDTO) => + queueService.queue(QueueName.PkiSync, QueueJobs.PkiSyncImportCertificates, payload, { + attempts: 5, + backoff: { + type: "exponential", + delay: 3000 + }, + removeOnComplete: true, + removeOnFail: true + }); + + const queuePkiSyncRemoveCertificatesById = async (payload: TQueuePkiSyncRemoveCertificatesByIdDTO) => + queueService.queue(QueueName.PkiSync, QueueJobs.PkiSyncRemoveCertificates, payload, { + attempts: 5, + backoff: { + type: "exponential", + delay: 3000 + }, + removeOnComplete: true, + removeOnFail: true + }); + + const $queueSendPkiSyncFailedNotifications = async (payload: TQueueSendPkiSyncActionFailedNotificationsDTO) => { + if (!appCfg.isSmtpConfigured) return; + + await queueService.queue(QueueName.PkiSync, QueueJobs.PkiSyncSendActionFailedNotifications, payload, { + jobId: `pki-sync-${payload.pkiSync.id}-failed-notifications`, + attempts: 5, + delay: 1000 * 60, + backoff: { + type: "exponential", + delay: 3000 + }, + removeOnFail: true, + removeOnComplete: true + }); + }; + + const $importCertificates = async (pkiSync: TPkiSyncWithCredentials): Promise => { + const { + projectId, + destination, + connection: { orgId } + } = pkiSync; + + await enterprisePkiSyncCheck( + licenseService, + orgId, + destination, + "Failed to import certificates due to plan restriction. Upgrade plan to access enterprise PKI syncs." + ); + + if (!projectId) { + throw new Error("Invalid PKI Sync source configuration: project no longer exists."); + } + + const importedCertificates = await PkiSyncFns.getCertificates(pkiSync, { + appConnectionDAL, + kmsService + }); + + if (!Object.keys(importedCertificates).length) return {}; + + const importedCertificateMap: TCertificateMap = {}; + + const certificateMap = await $getInfisicalCertificates(pkiSync); + + // Compare existing certificates with imported ones and determine which need to be created/updated + const certificatesToCreate: Array<{ + name: string; + certificate: string; + privateKey?: string; + }> = []; + + Object.entries(importedCertificates).forEach(([name, certificateData]) => { + const { cert: certificate, privateKey } = certificateData; + + if (!Object.prototype.hasOwnProperty.call(certificateMap, name)) { + // Certificate doesn't exist in Infisical, create it + certificatesToCreate.push({ + name, + certificate, + privateKey + }); + importedCertificateMap[name] = certificateData; + } else { + // Certificate exists - could compare and update if needed + // For now, we'll skip updating existing certificates to avoid conflicts + importedCertificateMap[name] = certificateData; + } + }); + + // Create new certificates in Infisical + if (certificatesToCreate.length > 0) { + logger.info(`PKI Sync Import: Creating ${certificatesToCreate.length} new certificates`); + await $createCertificatesInSubscriber(pkiSync, certificatesToCreate); + } + + return importedCertificateMap; + }; + + const $handleSyncCertificatesJob = async (job: TPkiSyncSyncCertificatesDTO, pkiSync: TPkiSyncRaw) => { + const { + data: { syncId, auditLogInfo } + } = job; + + await enterprisePkiSyncCheck( + licenseService, + pkiSync.connection.orgId, + pkiSync.destination, + "Failed to sync certificates due to plan restriction. Upgrade plan to access enterprise PKI syncs." + ); + + await pkiSyncDAL.updateById(syncId, { + syncStatus: PkiSyncStatus.Running + }); + + logger.info( + `PkiSync Sync [syncId=${pkiSync.id}] [destination=${pkiSync.destination}] [projectId=${pkiSync.projectId}] [subscriberId=${pkiSync.subscriberId}] [connectionId=${pkiSync.connectionId}]` + ); + + let isSynced = false; + let syncMessage: string | null = null; + let isFinalAttempt = job.attemptsStarted === job.opts.attempts; + + try { + const { + connection: { orgId, encryptedCredentials } + } = pkiSync; + + const credentials = await decryptAppConnectionCredentials({ + orgId, + encryptedCredentials, + kmsService + }); + + const pkiSyncWithCredentials = { + ...pkiSync, + connection: { + ...pkiSync.connection, + credentials + } + } as TPkiSyncWithCredentials; + + const certificateMap = await $getInfisicalCertificates(pkiSync); + + const syncResult = await PkiSyncFns.syncCertificates(pkiSyncWithCredentials, certificateMap, { + appConnectionDAL, + kmsService + }); + + logger.info( + { + syncId: pkiSync.id, + uploaded: syncResult.uploaded || 0, + removed: syncResult.removed || 0, + failedRemovals: syncResult.failedRemovals || 0, + skipped: syncResult.skipped || 0 + }, + "PKI sync operation completed with certificate cleanup" + ); + + isSynced = true; + } catch (err) { + logger.error( + err, + `PkiSync Sync Error [syncId=${pkiSync.id}] [destination=${pkiSync.destination}] [projectId=${pkiSync.projectId}] [subscriberId=${pkiSync.subscriberId}] [connectionId=${pkiSync.connectionId}]` + ); + + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + syncCertificatesErrorHistogram.record(1, { + version: 1, + destination: pkiSync.destination, + syncId: pkiSync.id, + projectId: pkiSync.projectId, + type: err instanceof AxiosError ? "AxiosError" : err?.constructor?.name || "UnknownError", + status: err instanceof AxiosError ? err.response?.status : undefined, + name: err instanceof Error ? err.name : undefined + }); + } + + syncMessage = parsePkiSyncErrorMessage(err); + + if (err instanceof PkiSyncError && !err.shouldRetry) { + isFinalAttempt = true; + } else { + // re-throw so job fails + throw err; + } + } finally { + const ranAt = new Date(); + const syncStatus = isSynced ? PkiSyncStatus.Succeeded : PkiSyncStatus.Failed; + + await auditLogService.createAuditLog({ + projectId: pkiSync.projectId, + ...(auditLogInfo ?? { + actor: { + type: ActorType.PLATFORM, + metadata: {} + } + }), + event: { + type: EventType.PKI_SYNC_SYNC_CERTIFICATES, + metadata: { + syncId: pkiSync.id, + syncMessage, + jobId: job.id!, + jobRanAt: ranAt + } + } + }); + + if (isSynced || isFinalAttempt) { + const updatedPkiSync = await pkiSyncDAL.updateById(pkiSync.id, { + syncStatus, + lastSyncJobId: job.id, + lastSyncMessage: syncMessage, + lastSyncedAt: isSynced ? ranAt : undefined + }); + + if (!isSynced) { + await $queueSendPkiSyncFailedNotifications({ + pkiSync: updatedPkiSync, + action: PkiSyncAction.SyncCertificates, + auditLogInfo + }); + } + } + } + + logger.info("PkiSync Sync Job with ID %s Completed", job.id); + }; + + const $handleImportCertificatesJob = async (job: TPkiSyncImportCertificatesDTO, pkiSync: TPkiSyncRaw) => { + const { + data: { syncId, auditLogInfo } + } = job; + + await pkiSyncDAL.updateById(syncId, { + importStatus: PkiSyncStatus.Running + }); + + logger.info( + `PkiSync Import [syncId=${pkiSync.id}] [destination=${pkiSync.destination}] [projectId=${pkiSync.projectId}] [subscriberId=${pkiSync.subscriberId}] [connectionId=${pkiSync.connectionId}]` + ); + + let isSuccess = false; + let importMessage: string | null = null; + let isFinalAttempt = job.attemptsStarted === job.opts.attempts; + + try { + const { + connection: { orgId, encryptedCredentials } + } = pkiSync; + + const credentials = await decryptAppConnectionCredentials({ + orgId, + encryptedCredentials, + kmsService + }); + + await $importCertificates({ + ...pkiSync, + connection: { + ...pkiSync.connection, + credentials + } + } as TPkiSyncWithCredentials); + + isSuccess = true; + } catch (err) { + logger.error( + err, + `PkiSync Import Error [syncId=${pkiSync.id}] [destination=${pkiSync.destination}] [projectId=${pkiSync.projectId}] [subscriberId=${pkiSync.subscriberId}] [connectionId=${pkiSync.connectionId}]` + ); + + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + importCertificatesErrorHistogram.record(1, { + version: 1, + destination: pkiSync.destination, + syncId: pkiSync.id, + projectId: pkiSync.projectId, + type: err instanceof AxiosError ? "AxiosError" : err?.constructor?.name || "UnknownError", + status: err instanceof AxiosError ? err.response?.status : undefined, + name: err instanceof Error ? err.name : undefined + }); + } + + importMessage = parsePkiSyncErrorMessage(err); + + if (err instanceof PkiSyncError && !err.shouldRetry) { + isFinalAttempt = true; + } else { + // re-throw so job fails + throw err; + } + } finally { + const ranAt = new Date(); + const importStatus = isSuccess ? PkiSyncStatus.Succeeded : PkiSyncStatus.Failed; + + await auditLogService.createAuditLog({ + projectId: pkiSync.projectId, + ...(auditLogInfo ?? { + actor: { + type: ActorType.PLATFORM, + metadata: {} + } + }), + event: { + type: EventType.PKI_SYNC_IMPORT_CERTIFICATES, + metadata: { + syncId: pkiSync.id, + importMessage, + jobId: job.id!, + jobRanAt: ranAt + } + } + }); + + if (isSuccess || isFinalAttempt) { + const updatedPkiSync = await pkiSyncDAL.updateById(pkiSync.id, { + importStatus, + lastImportJobId: job.id, + lastImportMessage: importMessage, + lastImportedAt: isSuccess ? ranAt : undefined + }); + + if (!isSuccess) { + await $queueSendPkiSyncFailedNotifications({ + pkiSync: updatedPkiSync, + action: PkiSyncAction.ImportCertificates, + auditLogInfo + }); + } + } + } + + logger.info("PkiSync Import Job with ID %s Completed", job.id); + }; + + const $handleRemoveCertificatesJob = async (job: TPkiSyncRemoveCertificatesDTO, pkiSync: TPkiSyncRaw) => { + const { + data: { syncId, auditLogInfo, deleteSyncOnComplete } + } = job; + + await enterprisePkiSyncCheck( + licenseService, + pkiSync.connection.orgId, + pkiSync.destination, + "Failed to remove certificates due to plan restriction. Upgrade plan to access enterprise PKI syncs." + ); + + await pkiSyncDAL.updateById(syncId, { + removeStatus: PkiSyncStatus.Running + }); + + logger.info( + `PkiSync Remove [syncId=${pkiSync.id}] [destination=${pkiSync.destination}] [projectId=${pkiSync.projectId}] [subscriberId=${pkiSync.subscriberId}] [connectionId=${pkiSync.connectionId}]` + ); + + let isSuccess = false; + let removeMessage: string | null = null; + let isFinalAttempt = job.attemptsStarted === job.opts.attempts; + + try { + const { + connection: { orgId, encryptedCredentials } + } = pkiSync; + + const credentials = await decryptAppConnectionCredentials({ + orgId, + encryptedCredentials, + kmsService + }); + + const certificateMap = await $getInfisicalCertificates(pkiSync); + + await PkiSyncFns.removeCertificates( + { + ...pkiSync, + connection: { + ...pkiSync.connection, + credentials + } + } as TPkiSyncWithCredentials, + Object.keys(certificateMap), + { + appConnectionDAL, + kmsService + } + ); + + isSuccess = true; + } catch (err) { + logger.error( + err, + `PkiSync Remove Error [syncId=${pkiSync.id}] [destination=${pkiSync.destination}] [projectId=${pkiSync.projectId}] [subscriberId=${pkiSync.subscriberId}] [connectionId=${pkiSync.connectionId}]` + ); + + if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) { + removeCertificatesErrorHistogram.record(1, { + version: 1, + destination: pkiSync.destination, + syncId: pkiSync.id, + projectId: pkiSync.projectId, + type: err instanceof AxiosError ? "AxiosError" : err?.constructor?.name || "UnknownError", + status: err instanceof AxiosError ? err.response?.status : undefined, + name: err instanceof Error ? err.name : undefined + }); + } + + removeMessage = parsePkiSyncErrorMessage(err); + + if (err instanceof PkiSyncError && !err.shouldRetry) { + isFinalAttempt = true; + } else { + // re-throw so job fails + throw err; + } + } finally { + const ranAt = new Date(); + const removeStatus = isSuccess ? PkiSyncStatus.Succeeded : PkiSyncStatus.Failed; + + await auditLogService.createAuditLog({ + projectId: pkiSync.projectId, + ...(auditLogInfo ?? { + actor: { + type: ActorType.PLATFORM, + metadata: {} + } + }), + event: { + type: EventType.PKI_SYNC_REMOVE_CERTIFICATES, + metadata: { + syncId: pkiSync.id, + removeMessage, + jobId: job.id!, + jobRanAt: ranAt + } + } + }); + + if (isSuccess || isFinalAttempt) { + if (isSuccess && deleteSyncOnComplete) { + await pkiSyncDAL.deleteById(pkiSync.id); + } else { + const updatedPkiSync = await pkiSyncDAL.updateById(pkiSync.id, { + removeStatus, + lastRemoveJobId: job.id, + lastRemoveMessage: removeMessage, + lastRemovedAt: isSuccess ? ranAt : undefined + }); + + if (!isSuccess) { + await $queueSendPkiSyncFailedNotifications({ + pkiSync: updatedPkiSync, + action: PkiSyncAction.RemoveCertificates, + auditLogInfo + }); + } + } + } + } + + logger.info("PkiSync Remove Job with ID %s Completed", job.id); + }; + + const $sendPkiSyncFailedNotifications = async (job: TSendPkiSyncFailedNotificationsJobDTO) => { + const { + data: { pkiSync, auditLogInfo, action } + } = job; + + const { projectId, name, lastSyncMessage, lastRemoveMessage, lastImportMessage } = pkiSync; + + const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId); + const project = await projectDAL.findById(projectId); + + // Filter for project admins similar to secret sync + let projectAdmins = projectMembers.filter((member) => + member.roles.some((role) => role.role === ProjectMembershipRole.Admin) + ); + + const triggeredByUserId = auditLogInfo?.actor?.type === ActorType.USER ? auditLogInfo.actor.metadata?.userId : null; + + if (triggeredByUserId) { + // Don't send notification to the user who triggered the action + projectAdmins = projectAdmins.filter((member) => member.user.id !== triggeredByUserId); + } + + // Get appropriate error message based on action type + let errorMessage: string | null = null; + if (action === PkiSyncAction.SyncCertificates) { + errorMessage = lastSyncMessage || null; + } else if (action === PkiSyncAction.ImportCertificates) { + errorMessage = lastImportMessage || null; + } else { + errorMessage = lastRemoveMessage || null; + } + + // Log notification for now - actual email sending would require SMTP configuration + if (projectAdmins.length > 0) { + logger.info( + `PKI Sync ${action} failure notification would be sent to ${projectAdmins.length} admin(s) for sync "${name}" in project "${project.name}". Error: ${errorMessage}` + ); + } else { + logger.info( + `PKI Sync ${action} failure occurred for sync "${name}" in project "${project.name}" but no admins to notify. Error: ${errorMessage}` + ); + } + }; + + const $handleAcquireLockFailure = async (job: PkiSyncActionJob) => { + const { syncId, auditLogInfo } = job.data; + + switch (job.name) { + case QueueJobs.PkiSyncSyncCertificates: { + const { failedToAcquireLockCount = 0, ...rest } = job.data as TQueuePkiSyncSyncCertificatesByIdDTO; + + if (failedToAcquireLockCount < REQUEUE_LIMIT) { + await queuePkiSyncSyncCertificatesById({ ...rest, failedToAcquireLockCount: failedToAcquireLockCount + 1 }); + return; + } + + const pkiSync = await pkiSyncDAL.updateById(syncId, { + syncStatus: PkiSyncStatus.Failed, + lastSyncMessage: + "Failed to run job. This typically happens when a sync is already in progress. Please try again.", + lastSyncJobId: job.id + }); + + await $queueSendPkiSyncFailedNotifications({ + pkiSync, + action: PkiSyncAction.SyncCertificates, + auditLogInfo + }); + + break; + } + case QueueJobs.PkiSyncImportCertificates: { + const pkiSync = await pkiSyncDAL.updateById(syncId, { + importStatus: PkiSyncStatus.Failed, + lastImportMessage: + "Failed to run job. This typically happens when a sync is already in progress. Please try again.", + lastImportJobId: job.id + }); + + await $queueSendPkiSyncFailedNotifications({ + pkiSync, + action: PkiSyncAction.ImportCertificates, + auditLogInfo + }); + + break; + } + case QueueJobs.PkiSyncRemoveCertificates: { + const pkiSync = await pkiSyncDAL.updateById(syncId, { + removeStatus: PkiSyncStatus.Failed, + lastRemoveMessage: + "Failed to run job. This typically happens when a sync is already in progress. Please try again.", + lastRemoveJobId: job.id + }); + + await $queueSendPkiSyncFailedNotifications({ + pkiSync, + action: PkiSyncAction.RemoveCertificates, + auditLogInfo + }); + + break; + } + default: + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + throw new Error(`Unhandled PKI Sync Job ${job.name}`); + } + }; + + queueService.start(QueueName.PkiSync, async (job) => { + if (job.name === QueueJobs.PkiSyncSendActionFailedNotifications) { + await $sendPkiSyncFailedNotifications(job as TSendPkiSyncFailedNotificationsJobDTO); + return; + } + + const { syncId } = job.data as + | TQueuePkiSyncSyncCertificatesByIdDTO + | TQueuePkiSyncImportCertificatesByIdDTO + | TQueuePkiSyncRemoveCertificatesByIdDTO; + + const pkiSync = await pkiSyncDAL.findById(syncId); + + if (!pkiSync) throw new Error(`Cannot find PKI sync with ID ${syncId}`); + + const { connectionId } = pkiSync; + + if (job.name === QueueJobs.PkiSyncSyncCertificates) { + const isConcurrentLimitReached = await $isConnectionConcurrencyLimitReached(connectionId); + + if (isConcurrentLimitReached) { + logger.info( + `PkiSync Concurrency limit reached [syncId=${syncId}] [job=${job.name}] [connectionId=${connectionId}]` + ); + + await $handleAcquireLockFailure(job as PkiSyncActionJob); + + return; + } + } + + let lock: Awaited>; + + try { + lock = await keyStore.acquireLock( + [KeyStorePrefixes.PkiSyncLock(syncId)], + // PKI syncs can take excessive amounts of time so we need to keep it locked + 5 * 60 * 1000 + ); + } catch (e) { + logger.info(`PkiSync Failed to acquire lock [syncId=${syncId}] [job=${job.name}]`); + + await $handleAcquireLockFailure(job as PkiSyncActionJob); + + return; + } + + try { + switch (job.name) { + case QueueJobs.PkiSyncSyncCertificates: { + await $incrementConnectionConcurrencyCount(connectionId); + await $handleSyncCertificatesJob(job as TPkiSyncSyncCertificatesDTO, pkiSync); + break; + } + case QueueJobs.PkiSyncImportCertificates: + await $handleImportCertificatesJob(job as TPkiSyncImportCertificatesDTO, pkiSync); + break; + case QueueJobs.PkiSyncRemoveCertificates: + await $handleRemoveCertificatesJob(job as TPkiSyncRemoveCertificatesDTO, pkiSync); + break; + default: + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + throw new Error(`Unhandled PKI Sync Job ${job.name}`); + } + } finally { + if (job.name === QueueJobs.PkiSyncSyncCertificates) { + await $decrementConnectionConcurrencyCount(connectionId); + } + + await lock.release(); + } + }); + + return { + queuePkiSyncSyncCertificatesById, + queuePkiSyncImportCertificatesById, + queuePkiSyncRemoveCertificatesById + }; +}; diff --git a/backend/src/services/pki-sync/pki-sync-schemas.ts b/backend/src/services/pki-sync/pki-sync-schemas.ts new file mode 100644 index 000000000..c7b7656b6 --- /dev/null +++ b/backend/src/services/pki-sync/pki-sync-schemas.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; + +import { PkiSync } from "./pki-sync-enums"; + +// Base PKI sync schema for API responses +export const PkiSyncSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string().nullable().optional(), + destination: z.nativeEnum(PkiSync), + isAutoSyncEnabled: z.boolean(), + destinationConfig: z.record(z.unknown()), + syncOptions: z.record(z.unknown()), + projectId: z.string(), + subscriberId: z.string().nullable().optional(), + connectionId: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + syncStatus: z.string().nullable().optional(), + lastSyncedAt: z.date().nullable().optional() +}); + +// Schema for PKI sync list items (includes app connection info) +export const PkiSyncListItemSchema = PkiSyncSchema.extend({ + appConnectionName: z.string(), + appConnectionApp: z.string() +}); + +// Schema for PKI sync details (includes app connection info) +export const PkiSyncDetailsSchema = PkiSyncSchema.extend({ + appConnectionName: z.string(), + appConnectionApp: z.string() +}); + +export type TPkiSyncSchema = z.infer; +export type TPkiSyncListItemSchema = z.infer; +export type TPkiSyncDetailsSchema = z.infer; diff --git a/backend/src/services/pki-sync/pki-sync-service.ts b/backend/src/services/pki-sync/pki-sync-service.ts new file mode 100644 index 000000000..fdb1e2361 --- /dev/null +++ b/backend/src/services/pki-sync/pki-sync-service.ts @@ -0,0 +1,384 @@ +import { ForbiddenError, subject } from "@casl/ability"; + +import { ActionProjectType } from "@app/db/schemas"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ProjectPermissionPkiSyncActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; +import { OrgServiceActor } from "@app/lib/types"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; +import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal"; + +import { TPkiSyncDALFactory } from "./pki-sync-dal"; +import { PkiSync } from "./pki-sync-enums"; +import { enterprisePkiSyncCheck, listPkiSyncOptions } from "./pki-sync-fns"; +import { TPkiSyncQueueFactory } from "./pki-sync-queue"; +import { + PkiSyncStatus, + TCreatePkiSyncDTO, + TDeletePkiSyncDTO, + TFindPkiSyncByIdDTO, + TFindPkiSyncByNameDTO, + TListPkiSyncsByProjectId, + TListPkiSyncsBySubscriberId, + TPkiSync, + TTriggerPkiSyncImportCertificatesByIdDTO, + TTriggerPkiSyncRemoveCertificatesByIdDTO, + TTriggerPkiSyncSyncCertificatesByIdDTO, + TUpdatePkiSyncDTO +} from "./pki-sync-types"; + +type TPkiSyncServiceFactoryDep = { + pkiSyncDAL: TPkiSyncDALFactory; + pkiSubscriberDAL: Pick; + appConnectionService: Pick; + permissionService: Pick; + licenseService: Pick; + pkiSyncQueue: Pick< + TPkiSyncQueueFactory, + "queuePkiSyncSyncCertificatesById" | "queuePkiSyncImportCertificatesById" | "queuePkiSyncRemoveCertificatesById" + >; +}; + +export type TPkiSyncServiceFactory = ReturnType; + +export const pkiSyncServiceFactory = ({ + pkiSyncDAL, + pkiSubscriberDAL, + appConnectionService, + permissionService, + licenseService, + pkiSyncQueue +}: TPkiSyncServiceFactoryDep) => { + const createPkiSync = async ( + { + name, + description, + destination, + isAutoSyncEnabled = true, + destinationConfig, + syncOptions = {}, + subscriberId, + connectionId, + projectId + }: Omit, + actor: OrgServiceActor + ): Promise => { + await enterprisePkiSyncCheck(licenseService, actor.orgId, destination); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.CertificateManager, + projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSyncActions.Create, + subject(ProjectPermissionSub.PkiSyncs, { projectId }) + ); + + if (subscriberId) { + const subscriber = await pkiSubscriberDAL.findById(subscriberId); + if (!subscriber || subscriber.projectId !== projectId) { + throw new NotFoundError({ message: "PKI subscriber not found" }); + } + } + + // Get the destination app type based on PKI sync destination + const destinationApp = destination === PkiSync.AzureKeyVault ? AppConnection.AzureKeyVault : destination; + + // Validates permission to connect and app is valid for sync destination + await appConnectionService.connectAppConnectionById(destinationApp, connectionId, actor); + + try { + const pkiSync = await pkiSyncDAL.create({ + name, + description, + destination, + isAutoSyncEnabled, + destinationConfig, + syncOptions, + subscriberId, + connectionId, + projectId, + ...(isAutoSyncEnabled && { syncStatus: PkiSyncStatus.Pending }) + }); + + if (pkiSync.isAutoSyncEnabled) { + await pkiSyncQueue.queuePkiSyncSyncCertificatesById({ syncId: pkiSync.id }); + } + + return pkiSync as TPkiSync; + } catch (err) { + if (err instanceof DatabaseError && (err.error as { code: string })?.code === "23505") { + throw new BadRequestError({ + message: `A PKI Sync with the name "${name}" already exists for the project with ID "${projectId}"` + }); + } + throw err; + } + }; + + const updatePkiSync = async ( + { + id, + projectId, + name, + description, + isAutoSyncEnabled, + destinationConfig, + syncOptions, + subscriberId, + connectionId + }: Omit, + actor: OrgServiceActor + ): Promise => { + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.CertificateManager, + projectId + }); + + const pkiSync = await pkiSyncDAL.findByIdAndProjectId(id, projectId); + if (!pkiSync) throw new NotFoundError({ message: "PKI sync not found" }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSyncActions.Edit, + subject(ProjectPermissionSub.PkiSyncs, { + projectId, + subscriberId: pkiSync.subscriberId + }) + ); + + if (name && name !== pkiSync.name) { + const existingPkiSync = await pkiSyncDAL.findByNameAndProjectId(name, projectId); + if (existingPkiSync) { + throw new BadRequestError({ message: "PKI sync with this name already exists" }); + } + } + + if (subscriberId) { + const subscriber = await pkiSubscriberDAL.findById(subscriberId); + if (!subscriber || subscriber.projectId !== projectId) { + throw new NotFoundError({ message: "PKI subscriber not found" }); + } + } + + if (connectionId && connectionId !== pkiSync.connectionId) { + const destinationApp = + pkiSync.destination === PkiSync.AzureKeyVault + ? AppConnection.AzureKeyVault + : (pkiSync.destination as AppConnection); + await appConnectionService.connectAppConnectionById(destinationApp, connectionId, actor); + } + + const updatedPkiSync = await pkiSyncDAL.updateById(id, { + name, + description, + isAutoSyncEnabled, + destinationConfig, + syncOptions, + subscriberId, + connectionId + }); + + return updatedPkiSync as TPkiSync; + }; + + const deletePkiSync = async ( + { id, projectId }: Omit, + actor: OrgServiceActor + ): Promise => { + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.CertificateManager, + projectId + }); + + const pkiSync = await pkiSyncDAL.findByIdAndProjectId(id, projectId); + if (!pkiSync) throw new NotFoundError({ message: "PKI sync not found" }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSyncActions.Delete, + subject(ProjectPermissionSub.PkiSyncs, { + projectId, + subscriberId: pkiSync.subscriberId + }) + ); + + const deletedPkiSync = await pkiSyncDAL.deleteById(id); + return deletedPkiSync as TPkiSync; + }; + + const listPkiSyncsByProjectId = async ({ projectId }: TListPkiSyncsByProjectId, actor: OrgServiceActor) => { + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.CertificateManager, + projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSyncActions.Read, + subject(ProjectPermissionSub.PkiSyncs, { projectId }) + ); + + const pkiSyncs = await pkiSyncDAL.findByProjectId(projectId); + return pkiSyncs; + }; + + const listPkiSyncsBySubscriberId = async ({ subscriberId }: TListPkiSyncsBySubscriberId) => { + const pkiSyncs = await pkiSyncDAL.findBySubscriberId(subscriberId); + return pkiSyncs; + }; + + const findPkiSyncById = async ({ id, projectId }: TFindPkiSyncByIdDTO, actor: OrgServiceActor) => { + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.CertificateManager, + projectId + }); + + const pkiSync = await pkiSyncDAL.findByIdAndProjectId(id, projectId); + if (!pkiSync) + throw new NotFoundError({ + message: `Could not find PKI Sync with ID "${id}"` + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSyncActions.Read, + subject(ProjectPermissionSub.PkiSyncs, { + projectId, + subscriberId: pkiSync.subscriberId + }) + ); + + return pkiSync; + }; + + const findPkiSyncByName = async ({ name, projectId }: TFindPkiSyncByNameDTO) => { + const pkiSync = await pkiSyncDAL.findByNameAndProjectId(name, projectId); + if (!pkiSync) throw new NotFoundError({ message: "PKI sync not found" }); + return pkiSync; + }; + + const triggerPkiSyncSyncCertificatesById = async ( + { id, projectId }: Omit, + actor: OrgServiceActor + ) => { + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.CertificateManager, + projectId + }); + + const pkiSync = await pkiSyncDAL.findByIdAndProjectId(id, projectId); + if (!pkiSync) throw new NotFoundError({ message: "PKI sync not found" }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSyncActions.SyncCertificates, + subject(ProjectPermissionSub.PkiSyncs, { + projectId, + subscriberId: pkiSync.subscriberId + }) + ); + + await pkiSyncQueue.queuePkiSyncSyncCertificatesById({ syncId: id }); + + return { message: "PKI sync job added to queue successfully" }; + }; + + const triggerPkiSyncImportCertificatesById = async ( + { id, projectId }: Omit, + actor: OrgServiceActor + ) => { + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.CertificateManager, + projectId + }); + + const pkiSync = await pkiSyncDAL.findByIdAndProjectId(id, projectId); + if (!pkiSync) throw new NotFoundError({ message: "PKI sync not found" }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSyncActions.ImportCertificates, + subject(ProjectPermissionSub.PkiSyncs, { + projectId, + subscriberId: pkiSync.subscriberId + }) + ); + + await pkiSyncQueue.queuePkiSyncImportCertificatesById({ syncId: id }); + + return { message: "PKI sync import job added to queue successfully" }; + }; + + const triggerPkiSyncRemoveCertificatesById = async ( + { id, projectId }: Omit, + actor: OrgServiceActor + ) => { + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.CertificateManager, + projectId + }); + + const pkiSync = await pkiSyncDAL.findByIdAndProjectId(id, projectId); + if (!pkiSync) throw new NotFoundError({ message: "PKI sync not found" }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiSyncActions.RemoveCertificates, + subject(ProjectPermissionSub.PkiSyncs, { + projectId, + subscriberId: pkiSync.subscriberId + }) + ); + + await pkiSyncQueue.queuePkiSyncRemoveCertificatesById({ syncId: id }); + + return { message: "PKI sync remove job added to queue successfully" }; + }; + + const getPkiSyncOptions = () => { + return listPkiSyncOptions(); + }; + + return { + createPkiSync, + updatePkiSync, + deletePkiSync, + listPkiSyncsByProjectId, + listPkiSyncsBySubscriberId, + findPkiSyncById, + findPkiSyncByName, + triggerPkiSyncSyncCertificatesById, + triggerPkiSyncImportCertificatesById, + triggerPkiSyncRemoveCertificatesById, + getPkiSyncOptions + }; +}; diff --git a/backend/src/services/pki-sync/pki-sync-types.ts b/backend/src/services/pki-sync/pki-sync-types.ts new file mode 100644 index 000000000..676ea441f --- /dev/null +++ b/backend/src/services/pki-sync/pki-sync-types.ts @@ -0,0 +1,183 @@ +import { Job } from "bullmq"; + +import { AuditLogInfo } from "@app/ee/services/audit-log/audit-log-types"; +import { QueueJobs } from "@app/queue"; +import { ResourceMetadataDTO } from "@app/services/resource-metadata/resource-metadata-schema"; + +import { TPkiSyncDALFactory } from "./pki-sync-dal"; +import { PkiSync } from "./pki-sync-enums"; + +export type TPkiSync = { + id: string; + name: string; + description?: string; + destination: PkiSync; + isAutoSyncEnabled: boolean; + version: number; + destinationConfig: Record; + syncOptions: Record; + projectId: string; + subscriberId?: string; + connectionId: string; + createdAt: Date; + updatedAt: Date; + syncStatus?: string; + lastSyncJobId?: string; + lastSyncMessage?: string; + lastSyncedAt?: Date; + importStatus?: string; + lastImportJobId?: string; + lastImportMessage?: string; + lastImportedAt?: Date; + removeStatus?: string; + lastRemoveJobId?: string; + lastRemoveMessage?: string; + lastRemovedAt?: Date; +}; + +export type TPkiSyncListItem = TPkiSync & { + appConnectionName: string; + appConnectionApp: string; +}; + +export type TPkiSyncWithCredentials = TPkiSync & { + connection: { + id: string; + name: string; + app: string; + credentials: Record; + orgId: string; + }; +}; + +export type TCertificateMap = Record; + +export type TCreatePkiSyncDTO = { + name: string; + description?: string; + destination: PkiSync; + isAutoSyncEnabled?: boolean; + destinationConfig: Record; + syncOptions?: Record; + subscriberId?: string; + connectionId: string; + projectId: string; + auditLogInfo: AuditLogInfo; + resourceMetadata?: ResourceMetadataDTO; +}; + +export type TUpdatePkiSyncDTO = { + id: string; + projectId: string; + name?: string; + description?: string; + isAutoSyncEnabled?: boolean; + destinationConfig?: Record; + syncOptions?: Record; + subscriberId?: string; + connectionId?: string; + auditLogInfo: AuditLogInfo; + resourceMetadata?: ResourceMetadataDTO; +}; + +export type TDeletePkiSyncDTO = { + id: string; + projectId: string; + auditLogInfo: AuditLogInfo; +}; + +export type TListPkiSyncsByProjectId = { + projectId: string; +}; + +export type TListPkiSyncsBySubscriberId = { + subscriberId: string; +}; + +export type TFindPkiSyncByIdDTO = { + id: string; + projectId: string; +}; + +export type TFindPkiSyncByNameDTO = { + name: string; + projectId: string; +}; + +export type TTriggerPkiSyncSyncCertificatesByIdDTO = { + id: string; + projectId: string; + auditLogInfo: AuditLogInfo; +}; + +export type TTriggerPkiSyncImportCertificatesByIdDTO = { + id: string; + projectId: string; + auditLogInfo: AuditLogInfo; +}; + +export type TTriggerPkiSyncRemoveCertificatesByIdDTO = { + id: string; + projectId: string; + auditLogInfo: AuditLogInfo; +}; + +export enum PkiSyncStatus { + Pending = "pending", + Running = "running", + Succeeded = "succeeded", + Failed = "failed" +} + +export enum PkiSyncAction { + SyncCertificates = "sync-certificates", + ImportCertificates = "import-certificates", + RemoveCertificates = "remove-certificates" +} + +export type TPkiSyncRaw = NonNullable>>; + +export type TQueuePkiSyncSyncCertificatesByIdDTO = { + syncId: string; + failedToAcquireLockCount?: number; + auditLogInfo?: AuditLogInfo; +}; + +export type TQueuePkiSyncImportCertificatesByIdDTO = { + syncId: string; + auditLogInfo?: AuditLogInfo; +}; + +export type TQueuePkiSyncRemoveCertificatesByIdDTO = { + syncId: string; + auditLogInfo?: AuditLogInfo; + deleteSyncOnComplete?: boolean; +}; + +export type TQueueSendPkiSyncActionFailedNotificationsDTO = { + pkiSync: TPkiSyncRaw; + auditLogInfo?: AuditLogInfo; + action: PkiSyncAction; +}; + +export type TPkiSyncSyncCertificatesDTO = Job< + TQueuePkiSyncSyncCertificatesByIdDTO, + void, + QueueJobs.PkiSyncSyncCertificates +>; +export type TPkiSyncImportCertificatesDTO = Job< + TQueuePkiSyncImportCertificatesByIdDTO, + void, + QueueJobs.PkiSyncImportCertificates +>; +export type TPkiSyncRemoveCertificatesDTO = Job< + TQueuePkiSyncRemoveCertificatesByIdDTO, + void, + QueueJobs.PkiSyncRemoveCertificates +>; + +export type TSendPkiSyncFailedNotificationsJobDTO = Job< + TQueueSendPkiSyncActionFailedNotificationsDTO, + void, + QueueJobs.PkiSyncSendActionFailedNotifications +>; diff --git a/frontend/src/components/pki-syncs/CreatePkiSyncModal.tsx b/frontend/src/components/pki-syncs/CreatePkiSyncModal.tsx new file mode 100644 index 000000000..5b5e81ba3 --- /dev/null +++ b/frontend/src/components/pki-syncs/CreatePkiSyncModal.tsx @@ -0,0 +1,76 @@ +import { useEffect, useState } from "react"; + +import { Modal, ModalContent } from "@app/components/v2"; +import { PkiSync, TPkiSync } from "@app/hooks/api/pkiSyncs"; + +import { CreatePkiSyncForm } from "./forms"; +import { PkiSyncModalHeader } from "./PkiSyncModalHeader"; +import { PkiSyncSelect } from "./PkiSyncSelect"; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + selectSync?: PkiSync | null; +}; + +type ContentProps = { + onComplete: (pkiSync: TPkiSync) => void; + selectedSync: PkiSync | null; + setSelectedSync: (selectedSync: PkiSync | null) => void; +}; + +const Content = ({ onComplete, setSelectedSync, selectedSync }: ContentProps) => { + if (selectedSync) { + return ( + setSelectedSync(null)} + destination={selectedSync} + /> + ); + } + + return ; +}; + +export const CreatePkiSyncModal = ({ onOpenChange, selectSync = null, ...props }: Props) => { + const [selectedSync, setSelectedSync] = useState(selectSync); + + useEffect(() => { + setSelectedSync(selectSync); + }, [selectSync]); + + return ( + { + if (!isOpen) setSelectedSync(null); + onOpenChange(isOpen); + }} + > + + ) : ( + "Add Sync" + ) + } + className="max-w-2xl" + bodyClassName="overflow-visible" + subTitle={ + selectedSync ? undefined : "Select a third-party service to sync certificates to." + } + > + { + setSelectedSync(null); + onOpenChange(false); + }} + selectedSync={selectedSync} + setSelectedSync={setSelectedSync} + /> + + + ); +}; diff --git a/frontend/src/components/pki-syncs/DeletePkiSyncModal.tsx b/frontend/src/components/pki-syncs/DeletePkiSyncModal.tsx new file mode 100644 index 000000000..4a71b4dee --- /dev/null +++ b/frontend/src/components/pki-syncs/DeletePkiSyncModal.tsx @@ -0,0 +1,60 @@ +import { createNotification } from "@app/components/notifications"; +import { DeleteActionModal } from "@app/components/v2"; +import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs"; +import { TPkiSync, useDeletePkiSync } from "@app/hooks/api/pkiSyncs"; + +type Props = { + pkiSync?: TPkiSync; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + onComplete?: () => void; +}; + +export const DeletePkiSyncModal = ({ isOpen, onOpenChange, pkiSync, onComplete }: Props) => { + const deleteSync = useDeletePkiSync(); + + if (!pkiSync) return null; + + const { id: syncId, name, destination, projectId } = pkiSync; + + const handleDeletePkiSync = async () => { + const destinationName = PKI_SYNC_MAP[destination].name; + + try { + await deleteSync.mutateAsync({ + syncId, + projectId + }); + + createNotification({ + text: `Successfully deleted ${destinationName} PKI Sync`, + type: "success" + }); + + if (onComplete) onComplete(); + onOpenChange(false); + } catch (err) { + console.error(err); + + createNotification({ + text: `Failed to delete ${destinationName} PKI Sync`, + type: "error" + }); + } + }; + + return ( + +

    + This action will also remove all certificates that were synced by this configuration from + the {PKI_SYNC_MAP[destination].name} destination. +

    +
    + ); +}; diff --git a/frontend/src/components/pki-syncs/EditPkiSyncModal.tsx b/frontend/src/components/pki-syncs/EditPkiSyncModal.tsx new file mode 100644 index 000000000..db5745aad --- /dev/null +++ b/frontend/src/components/pki-syncs/EditPkiSyncModal.tsx @@ -0,0 +1,29 @@ +import { PkiSyncEditFields } from "@app/components/pki-syncs/types"; +import { Modal, ModalContent } from "@app/components/v2"; +import { TPkiSync } from "@app/hooks/api/pkiSyncs"; + +import { EditPkiSyncForm } from "./forms"; +import { PkiSyncModalHeader } from "./PkiSyncModalHeader"; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + pkiSync?: TPkiSync; + fields: PkiSyncEditFields; +}; + +export const EditPkiSyncModal = ({ pkiSync, onOpenChange, fields, ...props }: Props) => { + if (!pkiSync) return null; + + return ( + + } + className="max-w-2xl" + bodyClassName="overflow-visible" + > + onOpenChange(false)} fields={fields} pkiSync={pkiSync} /> + + + ); +}; diff --git a/frontend/src/components/pki-syncs/PkiSyncImportCertificatesModal.tsx b/frontend/src/components/pki-syncs/PkiSyncImportCertificatesModal.tsx new file mode 100644 index 000000000..39895c6ca --- /dev/null +++ b/frontend/src/components/pki-syncs/PkiSyncImportCertificatesModal.tsx @@ -0,0 +1,94 @@ +import { createNotification } from "@app/components/notifications"; +import { Button, Modal, ModalClose, ModalContent } from "@app/components/v2"; +import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs"; +import { TPkiSync, useTriggerPkiSyncImportCertificates } from "@app/hooks/api/pkiSyncs"; + +type Props = { + pkiSync?: TPkiSync; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +}; + +type ContentProps = { + pkiSync: TPkiSync; + onComplete: () => void; +}; + +const Content = ({ pkiSync, onComplete }: ContentProps) => { + const { id: syncId, destination, projectId } = pkiSync; + const destinationName = PKI_SYNC_MAP[destination].name; + + const triggerImportCertificates = useTriggerPkiSyncImportCertificates(); + + const handleTriggerImportCertificates = async () => { + try { + await triggerImportCertificates.mutateAsync({ + syncId, + projectId + }); + + createNotification({ + text: `Successfully triggered certificate import for ${destinationName} Sync`, + type: "success" + }); + + onComplete(); + } catch (err) { + console.error(err); + + createNotification({ + text: `Failed to trigger certificate import for ${destinationName} Sync`, + type: "error" + }); + } + }; + + return ( +
    { + e.preventDefault(); + handleTriggerImportCertificates(); + }} + > +

    + Are you sure you want to import certificates from this {destinationName} destination into + Infisical? +

    +

    + This operation will retrieve certificates from {destinationName} and make them available in + your PKI collection. Only certificates that are not already imported will be processed. +

    +
    + + + + +
    +
    + ); +}; + +export const PkiSyncImportCertificatesModal = ({ isOpen, onOpenChange, pkiSync }: Props) => { + if (!pkiSync) return null; + + const destinationName = PKI_SYNC_MAP[pkiSync.destination].name; + + return ( + + + onOpenChange(false)} /> + + + ); +}; diff --git a/frontend/src/components/pki-syncs/PkiSyncImportStatusBadge.tsx b/frontend/src/components/pki-syncs/PkiSyncImportStatusBadge.tsx new file mode 100644 index 000000000..fbdc91c36 --- /dev/null +++ b/frontend/src/components/pki-syncs/PkiSyncImportStatusBadge.tsx @@ -0,0 +1,112 @@ +import { ReactNode, useEffect, useMemo, useState } from "react"; +import { + faCheck, + faDownload, + faTriangleExclamation, + faXmark, + IconDefinition +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { differenceInSeconds } from "date-fns"; +import { twMerge } from "tailwind-merge"; + +import { Badge, Tooltip } from "@app/components/v2"; +import { BadgeProps } from "@app/components/v2/Badge/Badge"; +import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs"; +import { PkiSyncData, PkiSyncStatus } from "@app/hooks/api/pkiSyncs"; + +type Props = { + pkiSync: PkiSyncData; + className?: string; + mini?: boolean; +}; + +export const PkiSyncImportStatusBadge = ({ pkiSync, className, mini }: Props) => { + const { importStatus, lastImportMessage, lastImportedAt, destination } = pkiSync; + const [hide, setHide] = useState(importStatus === PkiSyncStatus.Succeeded); + const destinationName = PKI_SYNC_MAP[destination].name; + + useEffect(() => { + if (importStatus === PkiSyncStatus.Succeeded) { + setTimeout(() => setHide(true), 3000); + } else { + setHide(false); + } + }, [importStatus]); + + const failureMessage = useMemo(() => { + if (importStatus === PkiSyncStatus.Failed) { + if (lastImportMessage) + try { + return JSON.stringify(JSON.parse(lastImportMessage), null, 2); + } catch { + return lastImportMessage; + } + + return "An Unknown Error Occurred."; + } + return null; + }, [importStatus, lastImportMessage]); + + if (!importStatus || hide) return null; + + let variant: BadgeProps["variant"]; + let label: string; + let icon: IconDefinition; + let tooltipContent: ReactNode; + + switch (importStatus) { + case PkiSyncStatus.Pending: + case PkiSyncStatus.Running: + variant = "primary"; + label = "Importing Certificates..."; + tooltipContent = `Importing certificates from ${destinationName}. This may take a moment.`; + icon = faDownload; + + break; + case PkiSyncStatus.Failed: + variant = "danger"; + label = "Failed to Import Certificates"; + icon = faTriangleExclamation; + tooltipContent = ( +
    + {failureMessage && ( +
    +
    + +
    + {mini ? "Failed to Import Certificates" : "Failure Reason"} +
    +
    +
    {failureMessage}
    +
    + )} +
    + ); + + break; + case PkiSyncStatus.Succeeded: + default: + // only show success for a bit... + if (lastImportedAt && differenceInSeconds(new Date(), lastImportedAt) > 15) return null; + + tooltipContent = "Successfully imported certificates."; + variant = "success"; + label = "Certificates Imported"; + icon = faCheck; + } + + return ( + +
    + + + {!mini && {label}} + +
    +
    + ); +}; diff --git a/frontend/src/components/pki-syncs/PkiSyncModalHeader.tsx b/frontend/src/components/pki-syncs/PkiSyncModalHeader.tsx new file mode 100644 index 000000000..917d221f6 --- /dev/null +++ b/frontend/src/components/pki-syncs/PkiSyncModalHeader.tsx @@ -0,0 +1,49 @@ +import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs"; +import { PkiSync } from "@app/hooks/api/pkiSyncs"; + +type Props = { + destination: PkiSync; + isConfigured: boolean; +}; + +export const PkiSyncModalHeader = ({ destination, isConfigured }: Props) => { + const destinationDetails = PKI_SYNC_MAP[destination]; + + return ( +
    + {`${destinationDetails.name} +
    +
    + {destinationDetails.name} Certificate Sync + +
    + + Docs + +
    +
    +
    +

    + {isConfigured + ? `Edit ${destinationDetails.name} Certificate Sync` + : `Sync certificates to ${destinationDetails.name}`} +

    +
    +
    + ); +}; diff --git a/frontend/src/components/pki-syncs/PkiSyncRemoveCertificatesModal.tsx b/frontend/src/components/pki-syncs/PkiSyncRemoveCertificatesModal.tsx new file mode 100644 index 000000000..2a96bd902 --- /dev/null +++ b/frontend/src/components/pki-syncs/PkiSyncRemoveCertificatesModal.tsx @@ -0,0 +1,86 @@ +import { createNotification } from "@app/components/notifications"; +import { Button, Modal, ModalClose, ModalContent } from "@app/components/v2"; +import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs"; +import { TPkiSync, useTriggerPkiSyncRemoveCertificates } from "@app/hooks/api/pkiSyncs"; + +type Props = { + pkiSync?: TPkiSync; + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +}; + +type ContentProps = { + pkiSync: TPkiSync; + onComplete: () => void; +}; + +const Content = ({ pkiSync, onComplete }: ContentProps) => { + const { id: syncId, destination, projectId } = pkiSync; + const destinationName = PKI_SYNC_MAP[destination].name; + + const triggerRemoveCertificates = useTriggerPkiSyncRemoveCertificates(); + + const handleTriggerRemoveCertificates = async () => { + try { + await triggerRemoveCertificates.mutateAsync({ + syncId, + projectId + }); + + createNotification({ + text: `Successfully triggered certificate removal for ${destinationName} Sync`, + type: "success" + }); + + onComplete(); + } catch (err) { + console.error(err); + + createNotification({ + text: `Failed to trigger certificate removal for ${destinationName} Sync`, + type: "error" + }); + } + }; + + return ( +
    { + e.preventDefault(); + handleTriggerRemoveCertificates(); + }} + > +

    + Are you sure you want to remove certificates synced by Infisical from this {destinationName}{" "} + destination? +

    +
    + + + + +
    +
    + ); +}; + +export const PkiSyncRemoveCertificatesModal = ({ isOpen, onOpenChange, pkiSync }: Props) => { + if (!pkiSync) return null; + + const destinationName = PKI_SYNC_MAP[pkiSync.destination].name; + + return ( + + + onOpenChange(false)} /> + + + ); +}; diff --git a/frontend/src/components/pki-syncs/PkiSyncRemoveStatusBadge.tsx b/frontend/src/components/pki-syncs/PkiSyncRemoveStatusBadge.tsx new file mode 100644 index 000000000..ed92f70f0 --- /dev/null +++ b/frontend/src/components/pki-syncs/PkiSyncRemoveStatusBadge.tsx @@ -0,0 +1,112 @@ +import { ReactNode, useEffect, useMemo, useState } from "react"; +import { + faCheck, + faEraser, + faTriangleExclamation, + faXmark, + IconDefinition +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { differenceInSeconds } from "date-fns"; +import { twMerge } from "tailwind-merge"; + +import { Badge, Tooltip } from "@app/components/v2"; +import { BadgeProps } from "@app/components/v2/Badge/Badge"; +import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs"; +import { PkiSyncData, PkiSyncStatus } from "@app/hooks/api/pkiSyncs"; + +type Props = { + pkiSync: PkiSyncData; + className?: string; + mini?: boolean; +}; + +export const PkiSyncRemoveStatusBadge = ({ pkiSync, className, mini }: Props) => { + const { removeStatus, lastRemoveMessage, lastRemovedAt, destination } = pkiSync; + const [hide, setHide] = useState(removeStatus === PkiSyncStatus.Succeeded); + const destinationName = PKI_SYNC_MAP[destination].name; + + useEffect(() => { + if (removeStatus === PkiSyncStatus.Succeeded) { + setTimeout(() => setHide(true), 3000); + } else { + setHide(false); + } + }, [removeStatus]); + + const failureMessage = useMemo(() => { + if (removeStatus === PkiSyncStatus.Failed) { + if (lastRemoveMessage) + try { + return JSON.stringify(JSON.parse(lastRemoveMessage), null, 2); + } catch { + return lastRemoveMessage; + } + + return "An Unknown Error Occurred."; + } + return null; + }, [removeStatus, lastRemoveMessage]); + + if (!removeStatus || hide) return null; + + let variant: BadgeProps["variant"]; + let label: string; + let icon: IconDefinition; + let tooltipContent: ReactNode; + + switch (removeStatus) { + case PkiSyncStatus.Pending: + case PkiSyncStatus.Running: + variant = "primary"; + label = "Removing Certificates..."; + tooltipContent = `Removing certificates from ${destinationName}. This may take a moment.`; + icon = faEraser; + + break; + case PkiSyncStatus.Failed: + variant = "danger"; + label = "Failed to Remove Certificates"; + icon = faTriangleExclamation; + tooltipContent = ( +
    + {failureMessage && ( +
    +
    + +
    + {mini ? "Failed to Remove Certificates" : "Failure Reason"} +
    +
    +
    {failureMessage}
    +
    + )} +
    + ); + + break; + case PkiSyncStatus.Succeeded: + default: + // only show success for a bit... + if (lastRemovedAt && differenceInSeconds(new Date(), lastRemovedAt) > 15) return null; + + tooltipContent = "Successfully removed certificates."; + variant = "success"; + label = "Certificates Removed"; + icon = faCheck; + } + + return ( + +
    + + + {!mini && {label}} + +
    +
    + ); +}; diff --git a/frontend/src/components/pki-syncs/PkiSyncSelect.tsx b/frontend/src/components/pki-syncs/PkiSyncSelect.tsx new file mode 100644 index 000000000..5a004823d --- /dev/null +++ b/frontend/src/components/pki-syncs/PkiSyncSelect.tsx @@ -0,0 +1,155 @@ +import { useMemo } from "react"; +import { faInfoCircle, faMagnifyingGlass, faSearch } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { EmptyState, Input, Pagination, Spinner, Tooltip } from "@app/components/v2"; +import { useSubscription } from "@app/context"; +import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs"; +import { usePagination, usePopUp, useResetPageHelper } from "@app/hooks"; +import { PkiSync, usePkiSyncOptions } from "@app/hooks/api/pkiSyncs"; + +import { UpgradePlanModal } from "../license/UpgradePlanModal"; + +type Props = { + onSelect: (destination: PkiSync) => void; +}; + +export const PkiSyncSelect = ({ onSelect }: Props) => { + const { subscription } = useSubscription(); + const { isPending, data: pkiSyncOptions } = usePkiSyncOptions(); + + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"] as const); + + const { search, setSearch, setPage, page, perPage, setPerPage, offset } = usePagination("", { + initPerPage: 16 + }); + + const filteredOptions = useMemo( + () => + pkiSyncOptions?.filter(({ destination }) => { + const { name } = PKI_SYNC_MAP[destination]; + return ( + name?.toLowerCase().includes(search.trim().toLowerCase()) || + destination.toLowerCase().includes(search.toLowerCase()) + ); + }) ?? [], + [pkiSyncOptions, search] + ); + + useResetPageHelper({ + totalCount: filteredOptions.length, + offset, + setPage + }); + + if (isPending) { + return ( +
    + +

    Loading options...

    +
    + ); + } + + return ( +
    + setSearch(e.target.value)} + leftIcon={} + placeholder="Search options..." + className="bg-mineshaft-800 placeholder:text-mineshaft-400" + /> +
    + {filteredOptions.slice(offset, perPage * page)?.map(({ destination, enterprise }) => { + const { image, name } = PKI_SYNC_MAP[destination]; + return ( + + ); + })} + {!filteredOptions?.length && ( + + )} +
    + {Boolean(filteredOptions.length) && ( + +

    Infisical is constantly adding support for more services.

    +

    + {`If you don't see the third-party + service you're looking for,`}{" "} + + let us know on Slack + {" "} + or{" "} + + make a request on GitHub + + . +

    + + } + > +
    + + Don't see the third-party service you're looking for? + + +
    + + } + count={filteredOptions.length} + page={page} + perPage={perPage} + onChangePage={setPage} + onChangePerPage={setPerPage} + perPageList={[16]} + /> + )} + handlePopUpToggle("upgradePlan", isOpen)} + text="You can use every Certificate Sync if you switch to Infisical's Enterprise plan." + /> +
    + ); +}; diff --git a/frontend/src/components/pki-syncs/PkiSyncStatusBadge.tsx b/frontend/src/components/pki-syncs/PkiSyncStatusBadge.tsx new file mode 100644 index 000000000..a177227fa --- /dev/null +++ b/frontend/src/components/pki-syncs/PkiSyncStatusBadge.tsx @@ -0,0 +1,55 @@ +import { + faCheck, + faExclamationTriangle, + faHourglass, + faRotate, + IconDefinition +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Badge, BadgeProps } from "@app/components/v2/Badge/Badge"; +import { PkiSyncStatus } from "@app/hooks/api/pkiSyncs"; + +type Props = { + status: PkiSyncStatus; +} & Omit; + +export const PkiSyncStatusBadge = ({ status }: Props) => { + let variant: BadgeProps["variant"]; + let text: string; + let icon: IconDefinition; + + switch (status) { + case PkiSyncStatus.Failed: + variant = "danger"; + text = "Failed to Sync"; + icon = faExclamationTriangle; + break; + case PkiSyncStatus.Succeeded: + variant = "success"; + text = "Synced"; + icon = faCheck; + break; + case PkiSyncStatus.Pending: + variant = "primary"; + text = "Queued"; + icon = faHourglass; + break; + case PkiSyncStatus.Running: + default: + variant = "primary"; + text = "Syncing"; + icon = faRotate; + break; + } + + return ( + + + {text} + + ); +}; diff --git a/frontend/src/components/pki-syncs/PkiSyncTable.tsx b/frontend/src/components/pki-syncs/PkiSyncTable.tsx new file mode 100644 index 000000000..0be92028e --- /dev/null +++ b/frontend/src/components/pki-syncs/PkiSyncTable.tsx @@ -0,0 +1,121 @@ +import { faPlug, faRefresh, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { + Badge, + EmptyState, + IconButton, + Table, + TableContainer, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { PkiSyncData } from "@app/hooks/api/pkiSyncs"; + +type Props = { + pkiSyncs: PkiSyncData[]; + onEdit: (pkiSync: PkiSyncData) => void; + onDelete: (pkiSync: PkiSyncData) => void; + onTrigger: (pkiSync: PkiSyncData) => void; +}; + +const getSyncStatusBadge = (status?: string) => { + switch (status) { + case "SUCCESS": + return Success; + case "FAILED": + return Failed; + case "RUNNING": + return Running; + case "PENDING": + default: + return Pending; + } +}; + +export const PkiSyncTable = ({ pkiSyncs, onEdit, onDelete, onTrigger }: Props) => { + if (!pkiSyncs.length) { + return ( +
    + + Start by creating a PKI sync to synchronize certificates with external services + +
    + ); + } + + return ( + + + + + + + + + + + + + + {pkiSyncs.map((pkiSync) => ( + onEdit(pkiSync)} + > + + + + + + + + + ))} + +
    NameDestinationConnectionAuto SyncStatusLast Sync +
    {pkiSync.name} +
    + {pkiSync.destination} +
    +
    {pkiSync.appConnectionName || "Unknown"} + + {pkiSync.isAutoSyncEnabled ? "Enabled" : "Disabled"} + + {getSyncStatusBadge(pkiSync.syncStatus ?? undefined)} + {pkiSync.lastSyncedAt + ? new Date(pkiSync.lastSyncedAt).toLocaleDateString() + : "Never"} + +
    + { + e.stopPropagation(); + onTrigger(pkiSync); + }} + > + + + { + e.stopPropagation(); + onDelete(pkiSync); + }} + > + + +
    +
    +
    + ); +}; diff --git a/frontend/src/components/pki-syncs/forms/AzureKeyVaultPkiSyncFields.tsx b/frontend/src/components/pki-syncs/forms/AzureKeyVaultPkiSyncFields.tsx new file mode 100644 index 000000000..87feebff7 --- /dev/null +++ b/frontend/src/components/pki-syncs/forms/AzureKeyVaultPkiSyncFields.tsx @@ -0,0 +1,37 @@ +import { Controller, useFormContext } from "react-hook-form"; + +import { FormControl, Input } from "@app/components/v2"; +import { PkiSync } from "@app/hooks/api/pkiSyncs"; + +import { PkiSyncConnectionField } from "./PkiSyncConnectionField"; +import { TPkiSyncForm } from "./schemas"; + +export const AzureKeyVaultPkiSyncFields = () => { + const { control, setValue } = useFormContext< + TPkiSyncForm & { destination: PkiSync.AzureKeyVault } + >(); + + return ( + <> + { + setValue("destinationConfig.vaultBaseUrl", ""); + }} + /> + ( + + + + )} + /> + + ); +}; diff --git a/frontend/src/components/pki-syncs/forms/CreatePkiSyncForm.tsx b/frontend/src/components/pki-syncs/forms/CreatePkiSyncForm.tsx new file mode 100644 index 000000000..d30927978 --- /dev/null +++ b/frontend/src/components/pki-syncs/forms/CreatePkiSyncForm.tsx @@ -0,0 +1,243 @@ +import { useState } from "react"; +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Tab } from "@headlessui/react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { twMerge } from "tailwind-merge"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Switch } from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs"; +import { PkiSync, TPkiSync, useCreatePkiSync, usePkiSyncOption } from "@app/hooks/api/pkiSyncs"; + +import { PkiSyncDestinationFields } from "./PkiSyncDestinationFields"; +import { PkiSyncDetailsFields } from "./PkiSyncDetailsFields"; +import { PkiSyncOptionsFields } from "./PkiSyncOptionsFields"; +import { PkiSyncReviewFields } from "./PkiSyncReviewFields"; +import { PkiSyncSourceFields } from "./PkiSyncSourceFields"; +import { PkiSyncFormSchema, TPkiSyncForm } from "./schemas"; + +type Props = { + onComplete: (pkiSync: TPkiSync) => void; + destination: PkiSync; + onCancel: () => void; +}; + +const FORM_TABS: { name: string; key: string; fields: (keyof TPkiSyncForm)[] }[] = [ + { name: "Source", key: "source", fields: ["subscriberId"] }, + { name: "Destination", key: "destination", fields: ["connection", "destinationConfig"] }, + { name: "Sync Options", key: "options", fields: ["syncOptions"] }, + { name: "Details", key: "details", fields: ["name", "description"] }, + { name: "Review", key: "review", fields: [] } +]; + +export const CreatePkiSyncForm = ({ destination, onComplete, onCancel }: Props) => { + const createPkiSync = useCreatePkiSync(); + const { currentWorkspace } = useWorkspace(); + const { name: destinationName } = PKI_SYNC_MAP[destination]; + + const [showConfirmation, setShowConfirmation] = useState(false); + + const [selectedTabIndex, setSelectedTabIndex] = useState(0); + + const { syncOption } = usePkiSyncOption(destination); + + const formMethods = useForm({ + resolver: zodResolver(PkiSyncFormSchema), + defaultValues: { + destination, + isAutoSyncEnabled: true, + syncOptions: { + canImportCertificates: syncOption?.canImportCertificates ?? true, + canRemoveCertificates: syncOption?.canRemoveCertificates ?? true + } + } as Partial, + reValidateMode: "onChange" + }); + + const onSubmit = async ({ connection, ...formData }: TPkiSyncForm) => { + try { + const pkiSync = await createPkiSync.mutateAsync({ + ...formData, + connectionId: connection.id, + projectId: currentWorkspace.id + }); + + createNotification({ + text: `Successfully added ${destinationName} Certificate Sync`, + type: "success" + }); + onComplete(pkiSync); + } catch (err: any) { + console.error(err); + setShowConfirmation(false); + createNotification({ + title: `Failed to add ${destinationName} Certificate Sync`, + text: err.message, + type: "error" + }); + } + }; + + const handlePrev = () => { + if (selectedTabIndex === 0) { + onCancel(); + return; + } + + setSelectedTabIndex((prev) => prev - 1); + }; + + const { handleSubmit, trigger, control } = formMethods; + + const isStepValid = async (index: number) => trigger(FORM_TABS[index].fields); + + const isFinalStep = selectedTabIndex === FORM_TABS.length - 1; + + const handleNext = async () => { + if (isFinalStep) { + setShowConfirmation(true); + return; + } + + const isValid = await isStepValid(selectedTabIndex); + + if (!isValid) return; + + setSelectedTabIndex((prev) => prev + 1); + }; + + const isTabEnabled = async (index: number) => { + let isEnabled = true; + for (let i = index - 1; i >= 0; i -= 1) { + // eslint-disable-next-line no-await-in-loop + isEnabled = isEnabled && (await isStepValid(i)); + } + + return isEnabled; + }; + + if (showConfirmation) + return ( + <> +
    +
    + + Certificate Sync Behavior +
    +

    + Certificate Syncs are the source of truth for connected third-party services. Any + certificate, including associated data, not present or imported in Infisical before + syncing will be overwritten, and changes made directly in the connected service outside + of infisical may also be overwritten by future syncs. +

    +
    +
    + + + +
    + + ); + + return ( +
    + + + + {FORM_TABS.map((tab, index) => ( + { + e.preventDefault(); + const isEnabled = await isTabEnabled(index); + setSelectedTabIndex((prev) => (isEnabled ? index : prev)); + }} + className={({ selected }) => + `w-30 -mb-[0.14rem] ${index > selectedTabIndex ? "opacity-30" : ""} px-4 py-2 text-sm font-medium outline-none disabled:opacity-60 ${ + selected + ? "border-b-2 border-mineshaft-300 text-mineshaft-200" + : "text-bunker-300" + }` + } + key={tab.key} + > + {index + 1}. {tab.name} + + ))} + + + + + + + + + + + { + return ( + + +

    Auto-Sync {value ? "Enabled" : "Disabled"}

    +
    +
    + ); + }} + /> +
    + + + + + + +
    +
    +
    + +
    + + {selectedTabIndex > 0 && ( + + )} +
    +
    + ); +}; diff --git a/frontend/src/components/pki-syncs/forms/EditPkiSyncForm.tsx b/frontend/src/components/pki-syncs/forms/EditPkiSyncForm.tsx new file mode 100644 index 000000000..58fcc512c --- /dev/null +++ b/frontend/src/components/pki-syncs/forms/EditPkiSyncForm.tsx @@ -0,0 +1,107 @@ +import { ReactNode } from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; + +import { createNotification } from "@app/components/notifications"; +import { PkiSyncEditFields } from "@app/components/pki-syncs/types"; +import { Button, ModalClose } from "@app/components/v2"; +import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs"; +import { TPkiSync, useUpdatePkiSync } from "@app/hooks/api/pkiSyncs"; + +import { PkiSyncDestinationFields } from "./PkiSyncDestinationFields"; +import { PkiSyncDetailsFields } from "./PkiSyncDetailsFields"; +import { PkiSyncOptionsFields } from "./PkiSyncOptionsFields"; +import { PkiSyncSourceFields } from "./PkiSyncSourceFields"; +import { TPkiSyncForm, UpdatePkiSyncFormSchema } from "./schemas"; + +type Props = { + onComplete: (pkiSync: TPkiSync) => void; + pkiSync: TPkiSync; + fields: PkiSyncEditFields; +}; + +export const EditPkiSyncForm = ({ pkiSync, fields, onComplete }: Props) => { + const updatePkiSync = useUpdatePkiSync(); + const { name: destinationName } = PKI_SYNC_MAP[pkiSync.destination]; + + const formMethods = useForm({ + resolver: zodResolver(UpdatePkiSyncFormSchema), + defaultValues: { + ...pkiSync, + description: pkiSync.description ?? "", + connection: { + id: pkiSync.connectionId, + name: pkiSync.appConnectionName + } + } as Partial, + reValidateMode: "onChange" + }); + + const onSubmit = async ({ connection, ...formData }: TPkiSyncForm) => { + try { + const updatedPkiSync = await updatePkiSync.mutateAsync({ + syncId: pkiSync.id, + ...formData, + connectionId: connection.id, + projectId: pkiSync.projectId + }); + + createNotification({ + text: `Successfully updated ${destinationName} PKI Sync`, + type: "success" + }); + onComplete(updatedPkiSync); + } catch (err: any) { + console.error(err); + createNotification({ + title: `Failed to update ${destinationName} PKI Sync`, + text: err.message, + type: "error" + }); + } + }; + + let Component: ReactNode; + + switch (fields) { + case PkiSyncEditFields.Destination: + Component = ; + break; + case PkiSyncEditFields.Options: + Component = ; + break; + case PkiSyncEditFields.Source: + Component = ; + break; + case PkiSyncEditFields.Details: + default: + Component = ; + break; + } + + const { + handleSubmit, + formState: { isSubmitting, isDirty } + } = formMethods; + + return ( +
    + {Component} +
    + + + + +
    +
    + ); +}; diff --git a/frontend/src/components/pki-syncs/forms/PkiSyncConnectionField.tsx b/frontend/src/components/pki-syncs/forms/PkiSyncConnectionField.tsx new file mode 100644 index 000000000..ffcf6ca7d --- /dev/null +++ b/frontend/src/components/pki-syncs/forms/PkiSyncConnectionField.tsx @@ -0,0 +1,86 @@ +import { Controller, useFormContext } from "react-hook-form"; +import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Link } from "@tanstack/react-router"; + +import { FilterableSelect, FormControl } from "@app/components/v2"; +import { OrgPermissionSubjects, useOrgPermission } from "@app/context"; +import { OrgPermissionAppConnectionActions } from "@app/context/OrgPermissionContext/types"; +import { APP_CONNECTION_MAP } from "@app/helpers/appConnections"; +import { PKI_SYNC_CONNECTION_MAP } from "@app/helpers/pkiSyncs"; +import { useListAvailableAppConnections } from "@app/hooks/api/appConnections"; + +import { TPkiSyncForm } from "./schemas"; + +type Props = { + onChange?: VoidFunction; +}; + +export const PkiSyncConnectionField = ({ onChange: callback }: Props) => { + const { permission } = useOrgPermission(); + const { control, watch } = useFormContext(); + + const destination = watch("destination"); + const app = PKI_SYNC_CONNECTION_MAP[destination]; + + const { data: availableConnections, isPending } = useListAvailableAppConnections(app); + + const connectionName = APP_CONNECTION_MAP[app].name; + + const canCreateConnection = permission.can( + OrgPermissionAppConnectionActions.Create, + OrgPermissionSubjects.AppConnections + ); + + const appName = APP_CONNECTION_MAP[PKI_SYNC_CONNECTION_MAP[destination]].name; + + return ( + <> +

    + Specify the {appName} Connection to use to connect to {connectionName} and configure + destination parameters. +

    + ( + + { + onChange(newValue); + if (callback) callback(); + }} + isLoading={isPending} + options={availableConnections} + placeholder="Select connection..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> + + )} + control={control} + name="connection" + /> + {availableConnections?.length === 0 && ( +

    + + {canCreateConnection ? ( + <> + You do not have access to any {appName} Connections. Create one from the{" "} + + App Connections + {" "} + page. + + ) : ( + `You do not have access to any ${appName} Connections. Contact an admin to create one.` + )} +

    + )} + + ); +}; diff --git a/frontend/src/components/pki-syncs/forms/PkiSyncDestinationFields.tsx b/frontend/src/components/pki-syncs/forms/PkiSyncDestinationFields.tsx new file mode 100644 index 000000000..5b89d23e4 --- /dev/null +++ b/frontend/src/components/pki-syncs/forms/PkiSyncDestinationFields.tsx @@ -0,0 +1,19 @@ +import { useFormContext } from "react-hook-form"; + +import { PkiSync } from "@app/hooks/api/pkiSyncs"; + +import { AzureKeyVaultPkiSyncFields } from "./AzureKeyVaultPkiSyncFields"; +import { TPkiSyncForm } from "./schemas"; + +export const PkiSyncDestinationFields = () => { + const { watch } = useFormContext(); + + const destination = watch("destination"); + + switch (destination) { + case PkiSync.AzureKeyVault: + return ; + default: + throw new Error(`Unhandled Destination Config Field: ${destination}`); + } +}; diff --git a/frontend/src/components/pki-syncs/forms/PkiSyncDetailsFields.tsx b/frontend/src/components/pki-syncs/forms/PkiSyncDetailsFields.tsx new file mode 100644 index 000000000..d7fc282dd --- /dev/null +++ b/frontend/src/components/pki-syncs/forms/PkiSyncDetailsFields.tsx @@ -0,0 +1,51 @@ +import { Controller, useFormContext } from "react-hook-form"; + +import { FormControl, Input, TextArea } from "@app/components/v2"; + +import { TPkiSyncForm } from "./schemas"; + +export const PkiSyncDetailsFields = () => { + const { control } = useFormContext(); + + return ( + <> +

    + Provide a name and description for this Certificate Sync. +

    + ( + + + + )} + control={control} + name="name" + /> + ( + +