From 9a17cd6af097ac26bc3a4bf1f7a45d300b8da23b Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Tue, 9 Sep 2025 10:34:00 -0300 Subject: [PATCH 001/135] 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 002/135] 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 003/135] 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 004/135] 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 005/135] 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 006/135] 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: Mon, 15 Sep 2025 12:15:59 -0300 Subject: [PATCH 007/135] Infisical Version Upgrade Tool --- backend/package-lock.json | 10 + backend/package.json | 2 + backend/src/@types/fastify.d.ts | 2 + backend/src/server/routes/index.ts | 6 +- backend/src/server/routes/v1/index.ts | 2 + .../server/routes/v1/upgrade-path-router.ts | 143 ++++ .../services/upgrade-path/github-client.ts | 187 +++++ backend/src/services/upgrade-path/index.ts | 2 + backend/src/services/upgrade-path/types.ts | 66 ++ .../upgrade-path/upgrade-path-service.ts | 270 +++++++ backend/upgrade-path.yaml | 78 ++ .../self-hosting/helper/upgrade-path-tool.png | Bin 0 -> 210513 bytes .../guides/upgrading-infisical.mdx | 13 +- frontend/src/hooks/api/upgradePath/index.ts | 7 + .../src/hooks/api/upgradePath/queries.tsx | 83 +++ .../UpgradePathPage/UpgradePathPage.tsx | 671 ++++++++++++++++++ .../pages/public/UpgradePathPage/route.tsx | 7 + frontend/src/routeTree.gen.ts | 28 + frontend/src/routes.ts | 1 + package-lock.json | 7 +- package.json | 1 + 21 files changed, 1577 insertions(+), 9 deletions(-) create mode 100644 backend/src/server/routes/v1/upgrade-path-router.ts create mode 100644 backend/src/services/upgrade-path/github-client.ts create mode 100644 backend/src/services/upgrade-path/index.ts create mode 100644 backend/src/services/upgrade-path/types.ts create mode 100644 backend/src/services/upgrade-path/upgrade-path-service.ts create mode 100644 backend/upgrade-path.yaml create mode 100644 docs/images/self-hosting/helper/upgrade-path-tool.png create mode 100644 frontend/src/hooks/api/upgradePath/index.ts create mode 100644 frontend/src/hooks/api/upgradePath/queries.tsx create mode 100644 frontend/src/pages/public/UpgradePathPage/UpgradePathPage.tsx create mode 100644 frontend/src/pages/public/UpgradePathPage/route.tsx diff --git a/backend/package-lock.json b/backend/package-lock.json index c6ac0b147..db4e9e7dd 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -83,6 +83,7 @@ "ioredis": "^5.3.2", "isomorphic-dompurify": "^2.22.0", "jmespath": "^0.16.0", + "js-yaml": "^4.1.0", "jsonwebtoken": "^9.0.2", "jsrp": "^0.2.4", "jwks-rsa": "^3.1.0", @@ -143,6 +144,7 @@ "@smithy/types": "^4.3.1", "@types/bcrypt": "^5.0.2", "@types/jmespath": "^0.15.2", + "@types/js-yaml": "^4.0.9", "@types/jsonwebtoken": "^9.0.5", "@types/jsrp": "^0.2.6", "@types/libsodium-wrappers": "^0.7.13", @@ -13160,6 +13162,13 @@ "integrity": "sha512-pegh49FtNsC389Flyo9y8AfkVIZn9MMPE9yJrO9svhq6Fks2MwymULWjZqySuxmctd3ZH4/n7Mr98D+1Qo5vGA==", "dev": true }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -20452,6 +20461,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, diff --git a/backend/package.json b/backend/package.json index 0c8464eaf..8fece4ebe 100644 --- a/backend/package.json +++ b/backend/package.json @@ -87,6 +87,7 @@ "@smithy/types": "^4.3.1", "@types/bcrypt": "^5.0.2", "@types/jmespath": "^0.15.2", + "@types/js-yaml": "^4.0.9", "@types/jsonwebtoken": "^9.0.5", "@types/jsrp": "^0.2.6", "@types/libsodium-wrappers": "^0.7.13", @@ -203,6 +204,7 @@ "ioredis": "^5.3.2", "isomorphic-dompurify": "^2.22.0", "jmespath": "^0.16.0", + "js-yaml": "^4.1.0", "jsonwebtoken": "^9.0.2", "jsrp": "^0.2.4", "jwks-rsa": "^3.1.0", diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 8ca4288b5..d075d21a8 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -114,6 +114,7 @@ import { TSlackServiceFactory } from "@app/services/slack/slack-service"; import { TSuperAdminServiceFactory } from "@app/services/super-admin/super-admin-service"; import { TTelemetryServiceFactory } from "@app/services/telemetry/telemetry-service"; import { TTotpServiceFactory } from "@app/services/totp/totp-service"; +import { TUpgradePathService } from "@app/services/upgrade-path/upgrade-path-service"; import { TUserDALFactory } from "@app/services/user/user-dal"; import { TUserServiceFactory } from "@app/services/user/user-service"; import { TUserEngagementServiceFactory } from "@app/services/user-engagement/user-engagement-service"; @@ -312,6 +313,7 @@ declare module "fastify" { identityAuthTemplate: TIdentityAuthTemplateServiceFactory; notification: TNotificationServiceFactory; offlineUsageReport: TOfflineUsageReportServiceFactory; + upgradePath: TUpgradePathService; }; // this is exclusive use for middlewares in which we need to inject data // everywhere else access using service layer diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index eccad2956..6a4c1d995 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -309,6 +309,7 @@ import { telemetryQueueServiceFactory } from "@app/services/telemetry/telemetry- import { telemetryServiceFactory } from "@app/services/telemetry/telemetry-service"; import { totpConfigDALFactory } from "@app/services/totp/totp-config-dal"; import { totpServiceFactory } from "@app/services/totp/totp-service"; +import { upgradePathServiceFactory } from "@app/services/upgrade-path/upgrade-path-service"; import { userDALFactory } from "@app/services/user/user-dal"; import { userServiceFactory } from "@app/services/user/user-service"; import { userAliasDALFactory } from "@app/services/user-alias/user-alias-dal"; @@ -759,6 +760,8 @@ export const registerRoutes = async ( userAliasDAL }); + const upgradePathService = upgradePathServiceFactory({ keyStore }); + const totpService = totpServiceFactory({ totpConfigDAL, userDAL, @@ -2174,7 +2177,8 @@ export const registerRoutes = async ( reminder: reminderService, bus: eventBusService, sse: sseService, - notification: notificationService + notification: notificationService, + upgradePath: upgradePathService }); const cronJobs: CronJob[] = []; diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 6108be32b..78ed50a76 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -51,6 +51,7 @@ import { registerSecretRequestsRouter } from "./secret-requests-router"; import { registerSecretSharingRouter } from "./secret-sharing-router"; import { registerSecretTagRouter } from "./secret-tag-router"; import { registerSlackRouter } from "./slack-router"; +import { registerUpgradePathRouter } from "./upgrade-path-router"; import { registerSsoRouter } from "./sso-router"; import { registerUserActionRouter } from "./user-action-router"; import { registerUserEngagementRouter } from "./user-engagement-router"; @@ -188,4 +189,5 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { ); await server.register(registerEventRouter, { prefix: "/events" }); + await server.register(registerUpgradePathRouter, { prefix: "/upgrade-path" }); }; diff --git a/backend/src/server/routes/v1/upgrade-path-router.ts b/backend/src/server/routes/v1/upgrade-path-router.ts new file mode 100644 index 000000000..30b5e9561 --- /dev/null +++ b/backend/src/server/routes/v1/upgrade-path-router.ts @@ -0,0 +1,143 @@ +import RE2 from "re2"; +import { z } from "zod"; + +import { BadRequestError } from "@app/lib/errors"; +import { publicEndpointLimit } from "@app/server/config/rateLimiter"; + +const versionSchema = z + .string() + .min(1) + .max(50) + .regex(new RE2(/^[a-zA-Z0-9._/-]+$/), "Invalid version format"); +const booleanSchema = z.boolean().default(false); +const queryBooleanSchema = z + .union([z.boolean(), z.string()]) + .transform((val) => { + if (typeof val === "string") { + return val === "true" || val === "1"; + } + return val; + }) + .default(false); + +export const registerUpgradePathRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/versions", + config: { + rateLimit: publicEndpointLimit + }, + schema: { + querystring: z.object({ + includePrerelease: queryBooleanSchema + }), + response: { + 200: z.object({ + versions: z.array( + z.object({ + tagName: z.string(), + name: z.string(), + publishedAt: z.string(), + prerelease: z.boolean(), + draft: z.boolean() + }) + ) + }) + } + }, + handler: async (req) => { + try { + const { includePrerelease } = req.query; + const versions = await req.server.services.upgradePath.getGitHubReleases(includePrerelease); + + return { + versions + }; + } catch (error) { + req.log.error(error, "Failed to fetch versions"); + if (error instanceof z.ZodError) { + throw new BadRequestError({ message: "Invalid query parameters" }); + } + throw new BadRequestError({ message: "Failed to fetch GitHub releases" }); + } + } + }); + + server.route({ + method: "POST", + url: "/calculate", + config: { + rateLimit: publicEndpointLimit + }, + schema: { + body: z.object({ + fromVersion: versionSchema, + toVersion: versionSchema, + includePrerelease: booleanSchema + }), + response: { + 200: z.object({ + path: z.array( + z.object({ + version: z.string(), + name: z.string(), + publishedAt: z.string(), + prerelease: z.boolean() + }) + ), + breakingChanges: z.array( + z.object({ + version: z.string(), + changes: z.array( + z.object({ + title: z.string(), + description: z.string(), + action: z.string() + }) + ) + }) + ), + features: z.array( + z.object({ + version: z.string(), + name: z.string(), + body: z.string(), + publishedAt: z.string() + }) + ), + hasDbMigration: z.boolean(), + config: z.record(z.unknown()) + }) + } + }, + handler: async (req) => { + try { + const { fromVersion, toVersion, includePrerelease } = req.body; + + req.log.info({ fromVersion, toVersion, includePrerelease }, "Calculating upgrade path"); + + const result = await req.server.services.upgradePath.calculateUpgradePath( + fromVersion, + toVersion, + includePrerelease + ); + + req.log.info( + { pathLength: result.path.length, hasBreaking: result.breakingChanges.length > 0 }, + "Upgrade path calculated" + ); + + return result; + } catch (error) { + req.log.error(error, "Failed to calculate upgrade path"); + if (error instanceof z.ZodError) { + throw new BadRequestError({ message: `Invalid input: ${error.errors.map((e) => e.message).join(", ")}` }); + } + if (error instanceof Error) { + throw new BadRequestError({ message: error.message }); + } + throw new BadRequestError({ message: "Failed to calculate upgrade path" }); + } + } + }); +}; diff --git a/backend/src/services/upgrade-path/github-client.ts b/backend/src/services/upgrade-path/github-client.ts new file mode 100644 index 000000000..2982e297a --- /dev/null +++ b/backend/src/services/upgrade-path/github-client.ts @@ -0,0 +1,187 @@ +/* eslint-disable no-await-in-loop */ +import RE2 from "re2"; + +import { FormattedRelease, GitHubApiError, GitHubRelease } from "./types"; + +interface GitHubClientConfig { + token?: string; + timeout: number; + maxRetries: number; + retryDelay: number; + maxPagesPerRequest: number; + perPage: number; +} + +interface RateLimitInfo { + remaining: number; + reset: Date; + used: number; + limit: number; +} + +const getDefaultConfig = (): GitHubClientConfig => ({ + token: process.env.GITHUB_TOKEN, + timeout: 30000, + maxRetries: 3, + retryDelay: 1000, + maxPagesPerRequest: 10, + perPage: 100 +}); + +const getHeaders = (token?: string): Record => { + const headers: Record = { + Accept: "application/vnd.github.v3+json", + "User-Agent": "Infisical-Upgrade-Path-Tool/1.0", + "X-GitHub-Api-Version": "2022-11-28" + }; + + if (token) { + headers.Authorization = `token ${token}`; + } + + return headers; +}; + +const delay = (ms: number): Promise => { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +}; + +const isMainInfisicalRelease = (tagName: string): boolean => { + if ( + tagName.startsWith("infisical-cli/") || + tagName.startsWith("infisical-k8-operator/") || + tagName.startsWith("infisical-k8s-operator/") + ) { + return false; + } + return tagName.startsWith("v") || tagName.startsWith("infisical/v") || new RE2(/^\d+\.\d+\.\d+/).test(tagName); +}; + +const normalizeVersion = (tagName: string): string => { + const versionMatch = tagName.match(new RE2(/(\d+\.\d+\.\d+(?:\.\d+)?)/)); + if (versionMatch) { + return `v${versionMatch[1]}`; + } + + if (tagName.startsWith("infisical/")) { + const withoutPrefix = tagName.replace(new RE2(/^infisical\//), ""); + return withoutPrefix.replace(new RE2(/-[a-zA-Z]+$/), ""); + } + return tagName.replace(new RE2(/-[a-zA-Z]+$/), ""); +}; + +const makeRequest = async ( + url: string, + config: GitHubClientConfig, + retryCount = 0 +): Promise<{ data: T; rateLimit: RateLimitInfo }> => { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), config.timeout); + + try { + const response = await fetch(url, { + headers: getHeaders(config.token), + signal: controller.signal + }); + + clearTimeout(timeout); + + const rateLimit: RateLimitInfo = { + remaining: parseInt(response.headers.get("X-RateLimit-Remaining") || "0", 10), + reset: new Date(parseInt(response.headers.get("X-RateLimit-Reset") || "0", 10) * 1000), + used: parseInt(response.headers.get("X-RateLimit-Used") || "0", 10), + limit: parseInt(response.headers.get("X-RateLimit-Limit") || "5000", 10) + }; + + if (!response.ok) { + const error: GitHubApiError = new Error(`GitHub API error: ${response.status}`); + error.status = response.status; + error.headers = response.headers; + + if (response.status === 403) { + const resetTime = rateLimit.reset.toISOString(); + error.message = `GitHub API rate limit exceeded. Remaining: ${rateLimit.remaining}, Reset at: ${resetTime}. ${ + !config.token ? "Consider setting GITHUB_TOKEN environment variable." : "" + }`; + } + + if (retryCount < config.maxRetries && (response.status >= 500 || response.status === 403)) { + await delay(config.retryDelay * 2 ** retryCount); + return await makeRequest(url, config, retryCount + 1); + } + + throw error; + } + + const data = (await response.json()) as T; + return { data, rateLimit }; + } catch (error) { + clearTimeout(timeout); + + if (error instanceof Error && error.name === "AbortError") { + throw new Error(`Request timeout after ${config.timeout}ms`); + } + + if (retryCount < config.maxRetries && !(error as GitHubApiError).status) { + await delay(config.retryDelay * 2 ** retryCount); + return await makeRequest(url, config, retryCount + 1); + } + + throw error; + } +}; + +export const fetchReleases = async (includePrerelease = false): Promise => { + const config = getDefaultConfig(); + const allReleases: GitHubRelease[] = []; + let page = 1; + let hasMorePages = true; + + const maxConcurrentRequests = Math.min(3, config.maxPagesPerRequest); + + while (hasMorePages && page <= config.maxPagesPerRequest) { + const requests: Promise<{ data: GitHubRelease[]; rateLimit: RateLimitInfo }>[] = []; + + for (let i = 0; i < maxConcurrentRequests && page <= config.maxPagesPerRequest; i += 1, page += 1) { + const url = `https://api.github.com/repos/Infisical/infisical/releases?page=${page}&per_page=${config.perPage}`; + requests.push(makeRequest(url, config)); + } + + const results = await Promise.allSettled(requests); + let hasData = false; + + for (const result of results) { + if (result.status === "fulfilled") { + const { data } = result.value; + if (data.length > 0) { + allReleases.push(...data); + hasData = true; + } + } + } + + if (!hasData || results.every((r) => r.status === "fulfilled" && r.value.data.length < config.perPage)) { + hasMorePages = false; + } + } + + const formattedReleases = allReleases + .filter((release) => !release.draft) + .filter((release) => isMainInfisicalRelease(release.tag_name)) + .map( + (release): FormattedRelease => ({ + tagName: release.tag_name, + normalizedTagName: normalizeVersion(release.tag_name), + name: release.name, + body: release.body, + publishedAt: release.published_at, + prerelease: release.prerelease, + draft: release.draft + }) + ) + .sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime()); + + return formattedReleases.filter((release) => includePrerelease || !release.prerelease); +}; diff --git a/backend/src/services/upgrade-path/index.ts b/backend/src/services/upgrade-path/index.ts new file mode 100644 index 000000000..1000e2bfa --- /dev/null +++ b/backend/src/services/upgrade-path/index.ts @@ -0,0 +1,2 @@ +export type { TUpgradePathService, TUpgradePathServiceFactory } from "./upgrade-path-service"; +export { upgradePathServiceFactory } from "./upgrade-path-service"; diff --git a/backend/src/services/upgrade-path/types.ts b/backend/src/services/upgrade-path/types.ts new file mode 100644 index 000000000..83d3d1546 --- /dev/null +++ b/backend/src/services/upgrade-path/types.ts @@ -0,0 +1,66 @@ +export interface GitHubRelease { + tag_name: string; + name: string; + body: string; + published_at: string; + prerelease: boolean; + draft: boolean; +} + +export interface FormattedRelease { + tagName: string; + normalizedTagName: string; + name: string; + body: string; + publishedAt: string; + prerelease: boolean; + draft: boolean; +} + +export interface BreakingChange { + title: string; + description: string; + action: string; +} + +export interface VersionConfig { + breaking_changes?: BreakingChange[]; + db_schema_changes?: string; + notes?: string; +} + +export interface UpgradePathConfig { + versions?: Record; +} + +export interface UpgradePathResult { + path: Array<{ + version: string; + name: string; + publishedAt: string; + prerelease: boolean; + }>; + breakingChanges: Array<{ + version: string; + changes: BreakingChange[]; + }>; + features: Array<{ + version: string; + name: string; + body: string; + publishedAt: string; + }>; + hasDbMigration: boolean; + config: Record; +} + +export interface GitHubApiError extends Error { + status?: number; + headers?: Headers; +} + +export interface CacheEntry { + data: T; + timestamp: number; + ttl: number; +} diff --git a/backend/src/services/upgrade-path/upgrade-path-service.ts b/backend/src/services/upgrade-path/upgrade-path-service.ts new file mode 100644 index 000000000..05bd44e25 --- /dev/null +++ b/backend/src/services/upgrade-path/upgrade-path-service.ts @@ -0,0 +1,270 @@ +import { readFile } from "fs/promises"; +import * as yaml from "js-yaml"; +import * as path from "path"; +import RE2 from "re2"; +import { z } from "zod"; + +import { TKeyStoreFactory } from "@app/keystore/keystore"; + +import { fetchReleases } from "./github-client"; +import { BreakingChange, FormattedRelease, UpgradePathConfig, UpgradePathResult, VersionConfig } from "./types"; + +export type TUpgradePathServiceFactory = { + keyStore: TKeyStoreFactory; +}; +export type TUpgradePathService = ReturnType; + +const versionSchema = z + .string() + .min(1) + .max(50) + .regex(new RE2(/^[a-zA-Z0-9._/-]+$/), "Invalid version format"); +const booleanSchema = z.boolean().default(false); + +interface CalculateUpgradePathParams { + fromVersion: string; + toVersion: string; + includePrerelease?: boolean; +} + +export const upgradePathServiceFactory = ({ keyStore }: TUpgradePathServiceFactory) => { + const getGitHubReleases = async (includePrerelease = false): Promise => { + const cacheKey = `upgrade-path:releases:${includePrerelease}`; + + try { + const cached = await keyStore.getItem(cacheKey); + if (cached) return JSON.parse(cached) as FormattedRelease[]; + } catch (error) { + // Cache miss, continue to fetch from source + } + + try { + const releases = await fetchReleases(booleanSchema.parse(includePrerelease)); + + const filteredReleases = releases.filter((v) => !v.tagName.includes("nightly")); + + await keyStore.setItemWithExpiry(cacheKey, 24 * 60 * 60, JSON.stringify(filteredReleases)); + return filteredReleases; + } catch (error) { + throw new Error(`GitHub releases unavailable: ${error instanceof Error ? error.message : "Unknown error"}`); + } + }; + + const getUpgradePathConfig = async (): Promise> => { + const cacheKey = "upgrade-path:config"; + + try { + const cached = await keyStore.getItem(cacheKey); + if (cached) return JSON.parse(cached) as Record; + } catch (error) { + // Cache miss, continue to fetch from source + } + + try { + const yamlPath = path.join(__dirname, "..", "..", "..", "upgrade-path.yaml"); + const yamlContent = await readFile(yamlPath, "utf8"); + + if (yamlContent.length > 1024 * 1024) { + throw new Error("Config file too large"); + } + + const config = yaml.load(yamlContent) as UpgradePathConfig; + const versionConfig = config?.versions || {}; + + await keyStore.setItemWithExpiry(cacheKey, 24 * 60 * 60, JSON.stringify(versionConfig)); + return versionConfig; + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + const empty = {}; + await keyStore.setItemWithExpiry(cacheKey, 24 * 60 * 60, JSON.stringify(empty)); + return empty; + } + throw new Error(`Config load failed: ${error instanceof Error ? error.message : "Unknown error"}`); + } + }; + + const normalizeVersion = (version: string): string => { + // Extract just the X.X.X.X part from any version format + const versionMatch = version.match(/(\d+\.\d+\.\d+(?:\.\d+)?)/); + if (versionMatch) { + return versionMatch[1]; + } + + // Handle legacy version formats + if (version.startsWith("infisical/")) { + return version.replace(new RE2(/^infisical\/v?/), "").replace(new RE2(/-[a-zA-Z]+$/), ""); + } + return version.replace(new RE2(/^v/), "").replace(new RE2(/-[a-zA-Z]+$/), ""); + }; + + const findBreakingChangesForVersion = ( + version: FormattedRelease, + config: Record + ): BreakingChange[] => { + // Check multiple key variations for breaking changes configuration + const versionNumber = normalizeVersion(version.tagName); + const possibleKeys = [ + version.tagName, + version.normalizedTagName, + versionNumber, + `v${versionNumber}`, + version.tagName.replace(new RE2(/^infisical\//), ""), + version.tagName.replace(new RE2(/^infisical\/v?/), "").replace(new RE2(/-[a-zA-Z]+$/), "") + ]; + + for (const key of possibleKeys) { + const versionConfig = config[key]; + if (versionConfig?.breaking_changes?.length) { + return versionConfig.breaking_changes; + } + } + return []; + }; + + const validateParams = (params: CalculateUpgradePathParams) => { + const { fromVersion, toVersion, includePrerelease = false } = params; + + versionSchema.parse(fromVersion); + versionSchema.parse(toVersion); + + if (fromVersion === toVersion) { + throw new Error("Versions cannot be identical"); + } + + if (fromVersion.includes("nightly") || toVersion.includes("nightly")) { + throw new Error("Nightly releases are not supported for upgrade path calculation"); + } + + return { fromVersion, toVersion, includePrerelease: booleanSchema.parse(includePrerelease) }; + }; + + const calculateUpgradePath = async (params: CalculateUpgradePathParams): Promise => { + const { fromVersion, toVersion, includePrerelease } = validateParams(params); + const cacheKey = `upgrade-path:${fromVersion}:${toVersion}:${includePrerelease}`; + + try { + const cached = await keyStore.getItem(cacheKey); + if (cached) return JSON.parse(cached) as UpgradePathResult; + } catch (error) { + // Cache miss, continue to fetch from source + } + + const [releases, config] = await Promise.all([getGitHubReleases(includePrerelease), getUpgradePathConfig()]); + + const cleanFrom = normalizeVersion(fromVersion); + const cleanTo = normalizeVersion(toVersion); + + const fromIdx = releases.findIndex((r) => normalizeVersion(r.normalizedTagName) === cleanFrom); + const toIdx = releases.findIndex((r) => normalizeVersion(r.normalizedTagName) === cleanTo); + + if (fromIdx === -1) throw new Error(`Version ${fromVersion} not found`); + if (toIdx === -1) throw new Error(`Version ${toVersion} not found`); + if (fromIdx <= toIdx) throw new Error("Invalid version order"); + + const upgradePath = releases.slice(toIdx, fromIdx + 1).reverse(); + const [first, last] = [upgradePath[0], upgradePath[upgradePath.length - 1]]; + + // Find all versions with breaking changes in the upgrade path + const withBreakingChanges = upgradePath.filter((version) => { + const breakingChanges = findBreakingChangesForVersion(version, config); + return breakingChanges.length > 0; + }); + + // Build the filtered path with breaking change versions + const filteredPath = [first]; + + // Get intermediate versions with breaking changes (excluding first and last) + const allIntermediateWithBreaking = withBreakingChanges + .filter((v) => v !== first && v !== last) + .sort((a, b) => new Date(a.publishedAt).getTime() - new Date(b.publishedAt).getTime()); + + // Limit intermediate steps to avoid overly complex upgrade paths + const maxIntermediateSteps = 8; + const intermediate = + allIntermediateWithBreaking.length > maxIntermediateSteps + ? allIntermediateWithBreaking.slice(-maxIntermediateSteps) + : allIntermediateWithBreaking; + + filteredPath.push(...intermediate); + if (last !== first) filteredPath.push(last); + + const breakingChanges: Array<{ version: string; changes: BreakingChange[] }> = []; + const features: Array<{ version: string; name: string; body: string; publishedAt: string }> = []; + let hasDbMigration = false; + + // Process versions in upgrade path, excluding starting version + for (let i = 1; i < upgradePath.length; i += 1) { + const version = upgradePath[i]; + const isFromVersion = normalizeVersion(version.normalizedTagName) === cleanFrom; + + // Process breaking changes for intermediate versions only + if (!isFromVersion) { + const versionBreakingChanges = findBreakingChangesForVersion(version, config); + if (versionBreakingChanges.length > 0) { + breakingChanges.push({ + version: version.tagName, + changes: versionBreakingChanges + }); + } + } + + // Process database migrations for intermediate versions only + if (!isFromVersion) { + const versionNumber = normalizeVersion(version.tagName); + const possibleKeys = [ + version.tagName, + version.normalizedTagName, + versionNumber, + `v${versionNumber}`, + version.tagName.replace(new RE2(/^infisical\//), ""), + version.tagName.replace(new RE2(/^infisical\/v?/), "").replace(new RE2(/-[a-zA-Z]+$/), "") + ]; + + for (const key of possibleKeys) { + const versionConfig = config[key]; + if ( + versionConfig?.db_schema_changes && + typeof versionConfig.db_schema_changes === "string" && + versionConfig.db_schema_changes.trim() + ) { + hasDbMigration = true; + break; + } + } + } + + // Collect release notes and features + if (version.body) { + features.push({ + version: version.tagName, + name: version.name, + body: version.body, + publishedAt: version.publishedAt + }); + } + } + + const result: UpgradePathResult = { + path: filteredPath.map((r) => ({ + version: r.tagName, + name: r.name, + publishedAt: r.publishedAt, + prerelease: r.prerelease + })), + breakingChanges, + features, + hasDbMigration, + config + }; + + await keyStore.setItemWithExpiry(cacheKey, 60 * 60, JSON.stringify(result)); + return result; + }; + + return { + getGitHubReleases, + getUpgradePathConfig, + calculateUpgradePath: (fromVersion: string, toVersion: string, includePrerelease = false) => + calculateUpgradePath({ fromVersion, toVersion, includePrerelease }) + }; +}; diff --git a/backend/upgrade-path.yaml b/backend/upgrade-path.yaml new file mode 100644 index 000000000..87aa5a53f --- /dev/null +++ b/backend/upgrade-path.yaml @@ -0,0 +1,78 @@ +versions: + "infisical/v0.130.0-postgres": + breaking_changes: + - title: "API Key Authentication Deprecation" + description: "Legacy API key authentication method has been deprecated and will be removed in future versions" + action: "Migrate all integrations to use Machine Identity authentication with JWT tokens. Update your CI/CD pipelines and automation scripts" + impact: "high" + - title: "Environment Variable Structure Changes" + description: "Environment variable naming convention has changed from INFISICAL_ prefix to INF_ for better compatibility" + action: "Update all environment variable references in your deployment configurations, Docker files, and Kubernetes manifests" + impact: "medium" + - title: "RBAC Permission Model Updates" + description: "Role-based access control has been restructured with new permission granularity affecting existing role assignments" + action: "Review and reassign user roles and permissions. Test access to sensitive resources before production deployment" + impact: "high" + db_schema_changes: "Extensive database schema restructuring for authentication and RBAC systems. Requires table reorganization and reindexing which may cause extended downtime." + notes: "Critical authentication and permission system overhaul. Database migration is extensive and may cause extended downtime. Plan maintenance window accordingly and ensure health checks are adjusted for longer migration time." + + + "infisical/v0.131.0-postgres": + breaking_changes: + - title: "Webhook Payload Format Changes" + description: "Webhook event payloads now use a new standardized format that is incompatible with previous versions" + action: "Update all webhook consumers to handle the new payload structure. Test webhook integrations with Slack, Discord, and custom endpoints" + impact: "high" + - title: "Secret Versioning API Breaking Changes" + description: "Secret versioning endpoints have changed from /api/v2/secrets to /api/v3/secrets with modified request/response schemas" + action: "Update all API clients and SDKs to use the new v3 endpoints. Modify any custom integrations or scripts" + impact: "medium" + - title: "CLI Authentication Method Changes" + description: "Infisical CLI now requires explicit authentication method specification and no longer supports legacy token formats" + action: "Update CLI installation in all environments. Re-authenticate CLI instances using 'infisical login' command" + impact: "medium" + db_schema_changes: "Major database schema changes for API restructuring. Includes reindexing large tables and webhook payload modifications which significantly impact performance during migration." + notes: "Major API restructure with extensive database changes. Migration involves reindexing large tables and may significantly impact instance performance. Health checks will likely fail during migration. Schedule during lowest traffic period." + + "v0.147.0": + breaking_changes: + - title: "Docker Tag Format Changes" + description: "Docker tags no longer contain the -postgres suffix. This affects deployment configurations" + action: "Update all deployment scripts, Docker Compose files, and Kubernetes manifests to use new tag format without -postgres suffix" + impact: "high" + - title: "Release Channel System Introduction" + description: "Formal release channels introduced with breaking changes to update mechanisms" + action: "Review release channel documentation and update your deployment strategy to align with new release channels" + impact: "medium" + db_schema_changes: "Database schema updates for release channel system implementation. Adds new tables for channel tracking and version management." + notes: "Docker tag format change requires deployment configuration updates. Review release channel documentation." + + "0.147.0": + breaking_changes: + - title: "Docker Tag Format Changes" + description: "Docker tags no longer contain the -postgres suffix. This affects deployment configurations" + action: "Update all deployment scripts, Docker Compose files, and Kubernetes manifests to use new tag format without -postgres suffix" + impact: "high" + - title: "Release Channel System Introduction" + description: "Formal release channels introduced with breaking changes to update mechanisms" + action: "Review release channel documentation and update your deployment strategy to align with new release channels" + impact: "medium" + db_schema_changes: "Database schema updates for release channel system implementation. Adds new tables for channel tracking and version management." + notes: "Docker tag format change requires deployment configuration updates. Review release channel documentation." + + "v0.148.0": + breaking_changes: + - title: "Secret Overview Page Removal" + description: "Secret overview page has been removed and replaced with revamped secret dashboard" + action: "Update any bookmarks, documentation, or automation that references the old overview page URLs" + impact: "medium" + - title: "Universal Auth Login Lockout" + description: "New lockout mechanism for Universal Auth that may affect existing authentication flows" + action: "Review and test authentication flows. Update monitoring and alerting for lockout scenarios" + impact: "high" + - title: "SAML Duplicate Account Handling Changes" + description: "Changes to how duplicate SAML accounts are handled during first-time sign-in" + action: "Test SAML authentication flows and ensure proper account linking procedures are in place" + impact: "medium" + db_schema_changes: "Database schema changes for authentication system improvements and UI restructuring. Includes new lockout mechanism tables and SAML account handling modifications." + notes: "UI changes and authentication flow updates. Test all authentication methods thoroughly." diff --git a/docs/images/self-hosting/helper/upgrade-path-tool.png b/docs/images/self-hosting/helper/upgrade-path-tool.png new file mode 100644 index 0000000000000000000000000000000000000000..a8a538aaf40e35895efbb7b4531455ef57c3f8ba GIT binary patch literal 210513 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

    WKd@ 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%0DH!>)CaFAx-D$)z2R)X6wy{R*a^NXXxwwa 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

  • t=R3r|$E;cEtvRHn@$TMYnaxQ2 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 SF+daCB^3ot`O>H6Z~q@Bx=)Y* literal 0 HcmV?d00001 diff --git a/docs/self-hosting/guides/upgrading-infisical.mdx b/docs/self-hosting/guides/upgrading-infisical.mdx index 60c6edbff..f91b904b5 100644 --- a/docs/self-hosting/guides/upgrading-infisical.mdx +++ b/docs/self-hosting/guides/upgrading-infisical.mdx @@ -41,9 +41,16 @@ Now, migrations run automatically during boot-up. This improvement streamlines t - Ensure you have a complete backup of your Postgres database. - Verify that your backup is current and accessible. -2. **Select the Upgrade Version:** - - Visit the [Infisical releases page](https://github.com/Infisical/infisical/releases) for a list of available versions. - - Look for releases with the prefix `infisical/` as there are other releases that are not related to the Infisical instance. +2. **Plan Your Upgrade Path:** + - Use our [Upgrade Path Tool](https://app.infisical.com/upgrade-path) to analyze your upgrade path between your current version and target version. + - The tool will show you: + - **Breaking changes** that require action before upgrading + - **Database migrations** that may require additional settings or precautions + - **Step-by-step upgrade path** with intermediate versions if needed + - Review any breaking changes and plan necessary configuration updates before proceeding. + - Visit the [Infisical releases page](https://github.com/Infisical/infisical/releases) for a complete list of available versions. + +![Upgrade Path Tool showing breaking changes and migration information](/images/self-hosting/helper/upgrade-path-tool.png) 3. **Start the Upgrade Process:** - Launch the new version of Infisical. During startup, the application will automatically compare the current database schema with the updated schema in the code. diff --git a/frontend/src/hooks/api/upgradePath/index.ts b/frontend/src/hooks/api/upgradePath/index.ts new file mode 100644 index 000000000..73c6d1254 --- /dev/null +++ b/frontend/src/hooks/api/upgradePath/index.ts @@ -0,0 +1,7 @@ +export type { + CalculateUpgradePathParams, + GetUpgradePathVersionsParams, + GitHubVersion, + UpgradePathResult +} from "./queries"; +export { useCalculateUpgradePath, useGetUpgradePathVersions } from "./queries"; diff --git a/frontend/src/hooks/api/upgradePath/queries.tsx b/frontend/src/hooks/api/upgradePath/queries.tsx new file mode 100644 index 000000000..aa4475c74 --- /dev/null +++ b/frontend/src/hooks/api/upgradePath/queries.tsx @@ -0,0 +1,83 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +export interface GitHubVersion { + tagName: string; + name: string; + publishedAt: string; + prerelease: boolean; + draft: boolean; +} + +export interface UpgradePathResult { + path: Array<{ + version: string; + name: string; + publishedAt: string; + prerelease: boolean; + }>; + breakingChanges: Array<{ + version: string; + changes: Array<{ + title: string; + description: string; + action: string; + }>; + }>; + features: Array<{ + version: string; + name: string; + body: string; + publishedAt: string; + }>; + hasDbMigration: boolean; + config: Record; +} + +export interface GetUpgradePathVersionsParams { + includePrerelease?: boolean; +} + +export interface CalculateUpgradePathParams { + fromVersion: string; + toVersion: string; + includePrerelease?: boolean; +} + +const upgradePathKeys = { + all: ["upgrade-path"] as const, + versions: (params: GetUpgradePathVersionsParams) => + [...upgradePathKeys.all, "versions", params] as const, + calculate: (params: CalculateUpgradePathParams) => + [...upgradePathKeys.all, "calculate", params] as const +}; + +export const useGetUpgradePathVersions = ( + params: GetUpgradePathVersionsParams, + options?: Omit, "queryKey" | "queryFn"> +) => { + return useQuery({ + queryKey: upgradePathKeys.versions(params), + queryFn: async () => { + const { data } = await apiRequest.get<{ versions: GitHubVersion[] }>( + "/api/v1/upgrade-path/versions", + { + params + } + ); + return data; + }, + ...options + }); +}; + +export const useCalculateUpgradePath = () => { + return async (params: CalculateUpgradePathParams): Promise => { + const { data } = await apiRequest.post( + "/api/v1/upgrade-path/calculate", + params + ); + return data; + }; +}; diff --git a/frontend/src/pages/public/UpgradePathPage/UpgradePathPage.tsx b/frontend/src/pages/public/UpgradePathPage/UpgradePathPage.tsx new file mode 100644 index 000000000..a8a8551f2 --- /dev/null +++ b/frontend/src/pages/public/UpgradePathPage/UpgradePathPage.tsx @@ -0,0 +1,671 @@ +/* eslint-disable no-nested-ternary */ +import { useEffect, useMemo, useRef, useState } from "react"; +import { Helmet } from "react-helmet"; +import { faExternalLink, faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useMutation } from "@tanstack/react-query"; + +import { createNotification } from "@app/components/notifications"; +import { Button, FormControl, Input } from "@app/components/v2"; + +import { + useCalculateUpgradePath, + useGetUpgradePathVersions +} from "../../../hooks/api/upgradePath/queries"; + +interface UpgradeResult { + path: Array<{ + version: string; + name: string; + publishedAt: string; + prerelease: boolean; + }>; + breakingChanges: Array<{ + version: string; + changes: Array<{ + title: string; + description: string; + action: string; + }>; + }>; + features: Array<{ + version: string; + name: string; + body: string; + publishedAt: string; + }>; + hasDbMigration: boolean; + config: Record; +} + +export const UpgradePathPage = () => { + const [fromVersion, setFromVersion] = useState(""); + const [toVersion, setToVersion] = useState(""); + const [fromSearch, setFromSearch] = useState(""); + const [toSearch, setToSearch] = useState(""); + const [showFromDropdown, setShowFromDropdown] = useState(false); + const [showToDropdown, setShowToDropdown] = useState(false); + const [upgradeResult, setUpgradeResult] = useState(null); + const [debouncedFromSearch, setDebouncedFromSearch] = useState(""); + const [debouncedToSearch, setDebouncedToSearch] = useState(""); + + const fromDropdownRef = useRef(null); + const toDropdownRef = useRef(null); + const fromSearchTimeoutRef = useRef(); + const toSearchTimeoutRef = useRef(); + + useEffect(() => { + if (fromSearchTimeoutRef.current) { + clearTimeout(fromSearchTimeoutRef.current); + } + fromSearchTimeoutRef.current = setTimeout(() => { + setDebouncedFromSearch(fromSearch); + }, 300); + + return () => { + if (fromSearchTimeoutRef.current) { + clearTimeout(fromSearchTimeoutRef.current); + } + }; + }, [fromSearch]); + + useEffect(() => { + if (toSearchTimeoutRef.current) { + clearTimeout(toSearchTimeoutRef.current); + } + toSearchTimeoutRef.current = setTimeout(() => { + setDebouncedToSearch(toSearch); + }, 300); + + return () => { + if (toSearchTimeoutRef.current) { + clearTimeout(toSearchTimeoutRef.current); + } + }; + }, [toSearch]); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (fromDropdownRef.current && !fromDropdownRef.current.contains(event.target as Node)) { + setShowFromDropdown(false); + } + if (toDropdownRef.current && !toDropdownRef.current.contains(event.target as Node)) { + setShowToDropdown(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, []); + + const { + data: versions, + isLoading: versionsLoading, + isFetching: versionsFetching + } = useGetUpgradePathVersions( + { + includePrerelease: false + }, + { + enabled: true, + staleTime: 24 * 60 * 60 * 1000, + refetchOnWindowFocus: false + } + ); + + const calculateMutation = useMutation({ + mutationFn: useCalculateUpgradePath(), + onSuccess: (data) => { + setUpgradeResult(data); + }, + onError: (error: unknown) => { + createNotification({ + text: (error as any)?.response?.data?.message || "Failed to calculate upgrade path", + type: "error" + }); + } + }); + + const filteredFromVersions = useMemo(() => { + if (!versions?.versions) return []; + + const filtered = versions.versions + .filter((version) => !version.tagName.includes("nightly")) + .filter((version) => { + if (!debouncedFromSearch) return true; + const searchTerm = debouncedFromSearch.toLowerCase(); + return version.tagName.toLowerCase().includes(searchTerm); + }); + + return filtered.slice(0, 25); + }, [versions?.versions, debouncedFromSearch]); + + const filteredToVersions = useMemo(() => { + if (!versions?.versions) return []; + + const filtered = versions.versions + .filter((version) => !version.tagName.includes("nightly")) + .filter((version) => { + if (!debouncedToSearch) return true; + const searchTerm = debouncedToSearch.toLowerCase(); + return version.tagName.toLowerCase().includes(searchTerm); + }); + + return filtered.slice(0, 25); + }, [versions?.versions, debouncedToSearch]); + + const handleFromVersionSelect = (version: string) => { + setFromVersion(version); + setFromSearch(version); + setShowFromDropdown(false); + }; + + const handleToVersionSelect = (version: string) => { + setToVersion(version); + setToSearch(version); + setShowToDropdown(false); + }; + + const handleCalculate = () => { + if (!fromVersion || !toVersion) { + createNotification({ + text: "Please select both from and to versions", + type: "error" + }); + return; + } + + if (fromVersion === toVersion) { + createNotification({ + text: "From and To versions cannot be the same", + type: "error" + }); + return; + } + + calculateMutation.mutate({ + fromVersion, + toVersion, + includePrerelease: false + }); + }; + + return ( + <> + + Infisical Upgrade Path Tool | Infisical + + + + + +
    +
    +
    +
    + {/* Header */} +
    +
    + + Infisical logo + +
    +

    + Upgrade your Infisical Version +

    +
    + + {/* Calculator Card */} +
    +

    Calculate Upgrade Path

    +
    +
    + {/* From Version Selector */} +
    + +
    + { + setFromSearch(e.target.value); + setFromVersion(""); + setShowFromDropdown(true); + }} + onFocus={() => setShowFromDropdown(true)} + placeholder="Search or select version..." + className="border-mineshaft-600 bg-mineshaft-900" + leftIcon={} + isDisabled={versionsLoading || versionsFetching} + /> + {showFromDropdown && ( +
    + {(() => { + if (versionsLoading || versionsFetching) { + return ( +
    +
    + + Loading versions... + +
    + ); + } + if (filteredFromVersions.length > 0) { + return filteredFromVersions.slice(0, 8).map((version) => ( + + )); + } + return ( +
    + {debouncedFromSearch + ? `No versions found matching "${debouncedFromSearch}"` + : "No versions available"} +
    + ); + })()} +
    + )} +
    + +
    + + {/* To Version Selector */} +
    + +
    + { + setToSearch(e.target.value); + setToVersion(""); + setShowToDropdown(true); + }} + onFocus={() => setShowToDropdown(true)} + placeholder="Search or select version..." + className="border-mineshaft-600 bg-mineshaft-900" + leftIcon={} + isDisabled={versionsLoading || versionsFetching} + /> + {showToDropdown && ( +
    + {(() => { + if (versionsLoading || versionsFetching) { + return ( +
    +
    + + Loading versions... + +
    + ); + } + if (filteredToVersions.length > 0) { + return filteredToVersions.slice(0, 8).map((version) => ( + + )); + } + return ( +
    + {debouncedToSearch + ? `No versions found matching "${debouncedToSearch}"` + : "No versions available"} +
    + ); + })()} +
    + )} +
    + +
    +
    + + +
    +
    + + {/* Results Section */} + {upgradeResult && ( +
    + {/* Action Required Banner */} + {(() => { + const versionsWithBreakingChanges = upgradeResult.breakingChanges + .filter((bc) => bc.changes.length > 0) + .map((bc) => bc.version); + + const versionsWithDbMigrations = upgradeResult.path + .filter((step, index) => { + const isStartingVersion = index === 0; + if (isStartingVersion) return false; + + const versionConfig = upgradeResult.config as Record; + + const possibleKeys = [ + step.version, + step.version.replace(/^v/, ""), + step.version.replace(/^infisical\/v?/, ""), + step.version.replace(/^infisical\/v?/, "").replace(/-[a-zA-Z]+$/, "") + ]; + + const dbSchemaChanges = possibleKeys + .map((key) => versionConfig?.[key]?.db_schema_changes) + .find((changes) => changes); + + return ( + dbSchemaChanges && + (typeof dbSchemaChanges === "string" + ? dbSchemaChanges.trim() + : dbSchemaChanges) + ); + }) + .map((step) => step.version); + + const allConflictVersions = [ + ...new Set([...versionsWithBreakingChanges, ...versionsWithDbMigrations]) + ]; + const hasIssues = allConflictVersions.length > 0; + + return ( +
    +
    +
    + {hasIssues ? ( + + + + ) : ( + + + + )} +
    +
    +

    + {hasIssues ? "Action Required:" : "Ready to Upgrade:"} +

    +

    + {hasIssues + ? `Your upgrade path contains conflicts in the following versions: ${allConflictVersions.join(", ")}. Please review and resolve each item before proceeding to the next version.` + : "Your upgrade path is clear with no breaking changes or conflicts. You can proceed with the upgrade."} +

    +
    +
    +
    + ); + })()} + + {/* Upgrade Steps */} +
    +
    + + + +

    Upgrade Steps

    +
    + +
    + {upgradeResult.path.map((step, index) => { + const isFirst = index === 0; + const isLast = index === upgradeResult.path.length - 1; + + const versionChanges = upgradeResult.breakingChanges.find((bc) => { + if (bc.version === step.version) return true; + + const normalizeVersion = (v: string) => { + return v.replace(/^(infisical\/)?v?/, "").replace(/-[a-zA-Z]+$/, ""); + }; + + const normalizedStep = normalizeVersion(step.version); + const normalizedBC = normalizeVersion(bc.version); + + return normalizedStep === normalizedBC; + }); + + const versionConfig = upgradeResult.config as Record; + + const possibleKeys = [ + step.version, + step.version.replace(/^v/, ""), + step.version.replace(/^infisical\/v?/, ""), + step.version.replace(/^infisical\/v?/, "").replace(/-[a-zA-Z]+$/, "") + ]; + + const dbMigrationDescription = possibleKeys + .map((key) => versionConfig?.[key]?.db_schema_changes) + .find((changes) => changes); + + const hasDbMigration = + !isFirst && + dbMigrationDescription && + (typeof dbMigrationDescription === "string" + ? dbMigrationDescription.trim() + : dbMigrationDescription); + + const hasBreakingChanges = + versionChanges && versionChanges.changes.length > 0; + + return ( +
    + {/* Timeline Column */} +
    + {/* Timeline Circle */} +
    + {isFirst || isLast ? ( +
    + ) : hasBreakingChanges || hasDbMigration ? ( + + + + ) : ( +
    + )} +
    + + {/* Timeline Line */} + {!isLast &&
    } +
    + + {/* Content Column */} +
    + {/* Version Header */} +
    +

    {step.version}

    + {isFirst && ( + + Starting Version + + )} + {isLast && ( + + Target Version + + )} + + + View Changelog + +
    + + {/* Version Notes */} + {(() => { + if (isFirst) return null; + + const notes = possibleKeys + .map((key) => versionConfig?.[key]?.notes) + .find((note) => note); + + if (!notes) return null; + + return ( +
    +
    {notes}
    +
    + ); + })()} + + {/* Database Schema Changes */} + {hasDbMigration && ( +
    +
    + Database Schema Changes Required +
    +
    + {typeof dbMigrationDescription === "string" + ? dbMigrationDescription + : "This version includes database schema changes that require migrations."} +
    +
    + Action:{" "} + + Make sure to backup your database before proceeding + +
    +
    + )} + + {/* Breaking Changes */} + {hasBreakingChanges && ( +
    +
    + + + + + Breaking Changes ({versionChanges.changes.length}) + +
    + {versionChanges.changes.map((change) => ( +
    +
    + {change.title} +
    +
    + {change.description} +
    +
    + Action:{" "} + {change.action} +
    +
    + ))} +
    + )} +
    +
    + ); + })} +
    +
    +
    + )} +
    +
    +

    + Made with ❀️ by{" "} + + Infisical + +
    + 235 2nd st, San Francisco, California, 94105, United States. πŸ‡ΊπŸ‡Έ +

    +
    +
    +
    + + ); +}; diff --git a/frontend/src/pages/public/UpgradePathPage/route.tsx b/frontend/src/pages/public/UpgradePathPage/route.tsx new file mode 100644 index 000000000..cc91b74e4 --- /dev/null +++ b/frontend/src/pages/public/UpgradePathPage/route.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { UpgradePathPage } from "./UpgradePathPage"; + +export const Route = createFileRoute("/upgrade-path")({ + component: UpgradePathPage +}); diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 1f62ba7b9..d4ecc024d 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -15,6 +15,7 @@ import { createFileRoute } from '@tanstack/react-router' import { Route as rootRoute } from './pages/root' import { Route as middlewaresRestrictLoginSignupImport } from './pages/middlewares/restrict-login-signup' import { Route as middlewaresAuthenticateImport } from './pages/middlewares/authenticate' +import { Route as publicUpgradePathPageRouteImport } from './pages/public/UpgradePathPage/route' import { Route as publicShareSecretPageRouteImport } from './pages/public/ShareSecretPage/route' import { Route as authCliRedirectPageRouteImport } from './pages/auth/CliRedirectPage/route' import { Route as indexImport } from './pages/index' @@ -318,6 +319,14 @@ const middlewaresAuthenticateRoute = middlewaresAuthenticateImport.update({ getParentRoute: () => rootRoute, } as any) +const publicUpgradePathPageRouteRoute = publicUpgradePathPageRouteImport.update( + { + id: '/upgrade-path', + path: '/upgrade-path', + getParentRoute: () => rootRoute, + } as any, +) + const publicShareSecretPageRouteRoute = publicShareSecretPageRouteImport.update( { id: '/share-secret', @@ -2037,6 +2046,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof publicShareSecretPageRouteImport parentRoute: typeof rootRoute } + '/upgrade-path': { + id: '/upgrade-path' + path: '/upgrade-path' + fullPath: '/upgrade-path' + preLoaderRoute: typeof publicUpgradePathPageRouteImport + parentRoute: typeof rootRoute + } '/_authenticate': { id: '/_authenticate' path: '' @@ -4600,6 +4616,7 @@ export interface FileRoutesByFullPath { '/': typeof indexRoute '/cli-redirect': typeof authCliRedirectPageRouteRoute '/share-secret': typeof publicShareSecretPageRouteRoute + '/upgrade-path': typeof publicUpgradePathPageRouteRoute '': typeof organizationLayoutRouteWithChildren '/password-setup': typeof authPasswordSetupPageRouteRoute '/email-not-verified': typeof authEmailNotVerifiedPageRouteRoute @@ -4821,6 +4838,7 @@ export interface FileRoutesByTo { '/': typeof indexRoute '/cli-redirect': typeof authCliRedirectPageRouteRoute '/share-secret': typeof publicShareSecretPageRouteRoute + '/upgrade-path': typeof publicUpgradePathPageRouteRoute '': typeof organizationLayoutRouteWithChildren '/password-setup': typeof authPasswordSetupPageRouteRoute '/email-not-verified': typeof authEmailNotVerifiedPageRouteRoute @@ -5029,6 +5047,7 @@ export interface FileRoutesById { '/': typeof indexRoute '/cli-redirect': typeof authCliRedirectPageRouteRoute '/share-secret': typeof publicShareSecretPageRouteRoute + '/upgrade-path': typeof publicUpgradePathPageRouteRoute '/_authenticate': typeof middlewaresAuthenticateRouteWithChildren '/_restrict-login-signup': typeof middlewaresRestrictLoginSignupRouteWithChildren '/_authenticate/password-setup': typeof authPasswordSetupPageRouteRoute @@ -5262,6 +5281,7 @@ export interface FileRouteTypes { | '/' | '/cli-redirect' | '/share-secret' + | '/upgrade-path' | '' | '/password-setup' | '/email-not-verified' @@ -5482,6 +5502,7 @@ export interface FileRouteTypes { | '/' | '/cli-redirect' | '/share-secret' + | '/upgrade-path' | '' | '/password-setup' | '/email-not-verified' @@ -5688,6 +5709,7 @@ export interface FileRouteTypes { | '/' | '/cli-redirect' | '/share-secret' + | '/upgrade-path' | '/_authenticate' | '/_restrict-login-signup' | '/_authenticate/password-setup' @@ -5920,6 +5942,7 @@ export interface RootRouteChildren { indexRoute: typeof indexRoute authCliRedirectPageRouteRoute: typeof authCliRedirectPageRouteRoute publicShareSecretPageRouteRoute: typeof publicShareSecretPageRouteRoute + publicUpgradePathPageRouteRoute: typeof publicUpgradePathPageRouteRoute middlewaresAuthenticateRoute: typeof middlewaresAuthenticateRouteWithChildren middlewaresRestrictLoginSignupRoute: typeof middlewaresRestrictLoginSignupRouteWithChildren publicViewSecretRequestByIDPageRouteRoute: typeof publicViewSecretRequestByIDPageRouteRoute @@ -5930,6 +5953,7 @@ const rootRouteChildren: RootRouteChildren = { indexRoute: indexRoute, authCliRedirectPageRouteRoute: authCliRedirectPageRouteRoute, publicShareSecretPageRouteRoute: publicShareSecretPageRouteRoute, + publicUpgradePathPageRouteRoute: publicUpgradePathPageRouteRoute, middlewaresAuthenticateRoute: middlewaresAuthenticateRouteWithChildren, middlewaresRestrictLoginSignupRoute: middlewaresRestrictLoginSignupRouteWithChildren, @@ -5952,6 +5976,7 @@ export const routeTree = rootRoute "/", "/cli-redirect", "/share-secret", + "/upgrade-path", "/_authenticate", "/_restrict-login-signup", "/secret-request/secret/$secretRequestId", @@ -5967,6 +5992,9 @@ export const routeTree = rootRoute "/share-secret": { "filePath": "public/ShareSecretPage/route.tsx" }, + "/upgrade-path": { + "filePath": "public/UpgradePathPage/route.tsx" + }, "/_authenticate": { "filePath": "middlewares/authenticate.tsx", "children": [ diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index 70c34edc6..1db098b71 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -373,6 +373,7 @@ export const routes = rootRoute("root.tsx", [ route("/shared/secret/$secretId", "public/ViewSharedSecretByIDPage/route.tsx"), route("/secret-request/secret/$secretRequestId", "public/ViewSecretRequestByIDPage/route.tsx"), route("/share-secret", "public/ShareSecretPage/route.tsx"), + route("/upgrade-path", "public/UpgradePathPage/route.tsx"), route("/cli-redirect", "auth/CliRedirectPage/route.tsx"), middleware("restrict-login-signup.tsx", [ route("/admin/signup", "admin/SignUpPage/route.tsx"), diff --git a/package-lock.json b/package-lock.json index 4d72220af..6e37ffeab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "license": "ISC", "dependencies": { "@radix-ui/react-radio-group": "^1.1.3", + "js-yaml": "^4.1.0", "secrets.js-grempe": "^2.0.0" }, "devDependencies": { @@ -561,7 +562,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/balanced-match": { @@ -1104,7 +1104,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -1891,8 +1890,7 @@ "argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "balanced-match": { "version": "1.0.2", @@ -2284,7 +2282,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "requires": { "argparse": "^2.0.1" } diff --git a/package.json b/package.json index db15de3fb..2efd0def8 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ }, "dependencies": { "@radix-ui/react-radio-group": "^1.1.3", + "js-yaml": "^4.1.0", "secrets.js-grempe": "^2.0.0" } } From ecb9466617b755c5d1fe22021b5d1707c5c77d6a Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Mon, 15 Sep 2025 14:26:14 -0300 Subject: [PATCH 008/135] Fix upgrade-path.yaml, removed test data --- backend/upgrade-path.yaml | 102 ++++++++++---------------------------- 1 file changed, 25 insertions(+), 77 deletions(-) diff --git a/backend/upgrade-path.yaml b/backend/upgrade-path.yaml index 87aa5a53f..4ccb7e775 100644 --- a/backend/upgrade-path.yaml +++ b/backend/upgrade-path.yaml @@ -1,78 +1,26 @@ -versions: - "infisical/v0.130.0-postgres": - breaking_changes: - - title: "API Key Authentication Deprecation" - description: "Legacy API key authentication method has been deprecated and will be removed in future versions" - action: "Migrate all integrations to use Machine Identity authentication with JWT tokens. Update your CI/CD pipelines and automation scripts" - impact: "high" - - title: "Environment Variable Structure Changes" - description: "Environment variable naming convention has changed from INFISICAL_ prefix to INF_ for better compatibility" - action: "Update all environment variable references in your deployment configurations, Docker files, and Kubernetes manifests" - impact: "medium" - - title: "RBAC Permission Model Updates" - description: "Role-based access control has been restructured with new permission granularity affecting existing role assignments" - action: "Review and reassign user roles and permissions. Test access to sensitive resources before production deployment" - impact: "high" - db_schema_changes: "Extensive database schema restructuring for authentication and RBAC systems. Requires table reorganization and reindexing which may cause extended downtime." - notes: "Critical authentication and permission system overhaul. Database migration is extensive and may cause extended downtime. Plan maintenance window accordingly and ensure health checks are adjusted for longer migration time." +# Upgrade Path Configuration File +# +# This file defines breaking changes and database migration information for Infisical versions. +# Used by the upgrade path tool to help users understand what changes are required between versions. +# +# Expected format: +# versions: +# "version_key": # Can be "v1.2.3", "1.2.3", or "infisical/v1.2.3-postgres" +# breaking_changes: # Optional: list of breaking changes for this version +# - title: "Short descriptive title" +# description: "Detailed description of what changed" +# action: "Specific steps users need to take" +# db_schema_changes: "Optional: Description of database changes and migration details" +# notes: "Optional: Additional notes or important information about this version" +# +# Example: +# versions: +# "v1.2.3": +# breaking_changes: +# - title: "API Endpoint Changes" +# description: "Authentication endpoints have been restructured" +# action: "Update all API calls to use new /auth/v2/ endpoints" +# db_schema_changes: "Major schema restructuring with table reorganization. Extended migration time: 3 minutes." +# notes: "Critical update requiring maintenance window. Test thoroughly before production deployment." - - "infisical/v0.131.0-postgres": - breaking_changes: - - title: "Webhook Payload Format Changes" - description: "Webhook event payloads now use a new standardized format that is incompatible with previous versions" - action: "Update all webhook consumers to handle the new payload structure. Test webhook integrations with Slack, Discord, and custom endpoints" - impact: "high" - - title: "Secret Versioning API Breaking Changes" - description: "Secret versioning endpoints have changed from /api/v2/secrets to /api/v3/secrets with modified request/response schemas" - action: "Update all API clients and SDKs to use the new v3 endpoints. Modify any custom integrations or scripts" - impact: "medium" - - title: "CLI Authentication Method Changes" - description: "Infisical CLI now requires explicit authentication method specification and no longer supports legacy token formats" - action: "Update CLI installation in all environments. Re-authenticate CLI instances using 'infisical login' command" - impact: "medium" - db_schema_changes: "Major database schema changes for API restructuring. Includes reindexing large tables and webhook payload modifications which significantly impact performance during migration." - notes: "Major API restructure with extensive database changes. Migration involves reindexing large tables and may significantly impact instance performance. Health checks will likely fail during migration. Schedule during lowest traffic period." - - "v0.147.0": - breaking_changes: - - title: "Docker Tag Format Changes" - description: "Docker tags no longer contain the -postgres suffix. This affects deployment configurations" - action: "Update all deployment scripts, Docker Compose files, and Kubernetes manifests to use new tag format without -postgres suffix" - impact: "high" - - title: "Release Channel System Introduction" - description: "Formal release channels introduced with breaking changes to update mechanisms" - action: "Review release channel documentation and update your deployment strategy to align with new release channels" - impact: "medium" - db_schema_changes: "Database schema updates for release channel system implementation. Adds new tables for channel tracking and version management." - notes: "Docker tag format change requires deployment configuration updates. Review release channel documentation." - - "0.147.0": - breaking_changes: - - title: "Docker Tag Format Changes" - description: "Docker tags no longer contain the -postgres suffix. This affects deployment configurations" - action: "Update all deployment scripts, Docker Compose files, and Kubernetes manifests to use new tag format without -postgres suffix" - impact: "high" - - title: "Release Channel System Introduction" - description: "Formal release channels introduced with breaking changes to update mechanisms" - action: "Review release channel documentation and update your deployment strategy to align with new release channels" - impact: "medium" - db_schema_changes: "Database schema updates for release channel system implementation. Adds new tables for channel tracking and version management." - notes: "Docker tag format change requires deployment configuration updates. Review release channel documentation." - - "v0.148.0": - breaking_changes: - - title: "Secret Overview Page Removal" - description: "Secret overview page has been removed and replaced with revamped secret dashboard" - action: "Update any bookmarks, documentation, or automation that references the old overview page URLs" - impact: "medium" - - title: "Universal Auth Login Lockout" - description: "New lockout mechanism for Universal Auth that may affect existing authentication flows" - action: "Review and test authentication flows. Update monitoring and alerting for lockout scenarios" - impact: "high" - - title: "SAML Duplicate Account Handling Changes" - description: "Changes to how duplicate SAML accounts are handled during first-time sign-in" - action: "Test SAML authentication flows and ensure proper account linking procedures are in place" - impact: "medium" - db_schema_changes: "Database schema changes for authentication system improvements and UI restructuring. Includes new lockout mechanism tables and SAML account handling modifications." - notes: "UI changes and authentication flow updates. Test all authentication methods thoroughly." +versions: \ No newline at end of file From 5fc88610e4541b5611877413e87fd98c248e88ad Mon Sep 17 00:00:00 2001 From: Carlos Monastyrski Date: Mon, 15 Sep 2025 14:42:14 -0300 Subject: [PATCH 009/135] Improve UI dropdown --- .../UpgradePathPage/UpgradePathPage.tsx | 267 ++++-------------- 1 file changed, 60 insertions(+), 207 deletions(-) diff --git a/frontend/src/pages/public/UpgradePathPage/UpgradePathPage.tsx b/frontend/src/pages/public/UpgradePathPage/UpgradePathPage.tsx index a8a8551f2..74317d02f 100644 --- a/frontend/src/pages/public/UpgradePathPage/UpgradePathPage.tsx +++ b/frontend/src/pages/public/UpgradePathPage/UpgradePathPage.tsx @@ -1,18 +1,31 @@ /* eslint-disable no-nested-ternary */ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useMemo, useState } from "react"; import { Helmet } from "react-helmet"; -import { faExternalLink, faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons"; +import { SingleValue } from "react-select"; +import { faExternalLink } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useMutation } from "@tanstack/react-query"; import { createNotification } from "@app/components/notifications"; -import { Button, FormControl, Input } from "@app/components/v2"; +import { Button, FilterableSelect, FormControl } from "@app/components/v2"; import { useCalculateUpgradePath, useGetUpgradePathVersions } from "../../../hooks/api/upgradePath/queries"; +type VersionOption = { + label: string; + value: string; + isLatest: boolean; +}; + +const formatVersionOption = (option: VersionOption) => ( +
    + {option.label} + {option.isLatest && (Latest)} +
    +); interface UpgradeResult { path: Array<{ version: string; @@ -39,66 +52,9 @@ interface UpgradeResult { } export const UpgradePathPage = () => { - const [fromVersion, setFromVersion] = useState(""); - const [toVersion, setToVersion] = useState(""); - const [fromSearch, setFromSearch] = useState(""); - const [toSearch, setToSearch] = useState(""); - const [showFromDropdown, setShowFromDropdown] = useState(false); - const [showToDropdown, setShowToDropdown] = useState(false); + const [fromVersion, setFromVersion] = useState(null); + const [toVersion, setToVersion] = useState(null); const [upgradeResult, setUpgradeResult] = useState(null); - const [debouncedFromSearch, setDebouncedFromSearch] = useState(""); - const [debouncedToSearch, setDebouncedToSearch] = useState(""); - - const fromDropdownRef = useRef(null); - const toDropdownRef = useRef(null); - const fromSearchTimeoutRef = useRef(); - const toSearchTimeoutRef = useRef(); - - useEffect(() => { - if (fromSearchTimeoutRef.current) { - clearTimeout(fromSearchTimeoutRef.current); - } - fromSearchTimeoutRef.current = setTimeout(() => { - setDebouncedFromSearch(fromSearch); - }, 300); - - return () => { - if (fromSearchTimeoutRef.current) { - clearTimeout(fromSearchTimeoutRef.current); - } - }; - }, [fromSearch]); - - useEffect(() => { - if (toSearchTimeoutRef.current) { - clearTimeout(toSearchTimeoutRef.current); - } - toSearchTimeoutRef.current = setTimeout(() => { - setDebouncedToSearch(toSearch); - }, 300); - - return () => { - if (toSearchTimeoutRef.current) { - clearTimeout(toSearchTimeoutRef.current); - } - }; - }, [toSearch]); - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (fromDropdownRef.current && !fromDropdownRef.current.contains(event.target as Node)) { - setShowFromDropdown(false); - } - if (toDropdownRef.current && !toDropdownRef.current.contains(event.target as Node)) { - setShowToDropdown(false); - } - }; - - document.addEventListener("mousedown", handleClickOutside); - return () => { - document.removeEventListener("mousedown", handleClickOutside); - }; - }, []); const { data: versions, @@ -128,44 +84,26 @@ export const UpgradePathPage = () => { } }); - const filteredFromVersions = useMemo(() => { + const versionOptions = useMemo(() => { if (!versions?.versions) return []; - const filtered = versions.versions + return versions.versions .filter((version) => !version.tagName.includes("nightly")) - .filter((version) => { - if (!debouncedFromSearch) return true; - const searchTerm = debouncedFromSearch.toLowerCase(); - return version.tagName.toLowerCase().includes(searchTerm); - }); + .map((version) => ({ + label: version.tagName, + value: version.tagName, + isLatest: versions.versions[0]?.tagName === version.tagName + })); + }, [versions?.versions]); - return filtered.slice(0, 25); - }, [versions?.versions, debouncedFromSearch]); - - const filteredToVersions = useMemo(() => { - if (!versions?.versions) return []; - - const filtered = versions.versions - .filter((version) => !version.tagName.includes("nightly")) - .filter((version) => { - if (!debouncedToSearch) return true; - const searchTerm = debouncedToSearch.toLowerCase(); - return version.tagName.toLowerCase().includes(searchTerm); - }); - - return filtered.slice(0, 25); - }, [versions?.versions, debouncedToSearch]); - - const handleFromVersionSelect = (version: string) => { - setFromVersion(version); - setFromSearch(version); - setShowFromDropdown(false); + const handleFromVersionSelect = (value: unknown) => { + const selected = value as SingleValue; + setFromVersion(selected?.value || null); }; - const handleToVersionSelect = (version: string) => { - setToVersion(version); - setToSearch(version); - setShowToDropdown(false); + const handleToVersionSelect = (value: unknown) => { + const selected = value as SingleValue; + setToVersion(selected?.value || null); }; const handleCalculate = () => { @@ -232,121 +170,36 @@ export const UpgradePathPage = () => {
    {/* From Version Selector */} -
    - -
    - { - setFromSearch(e.target.value); - setFromVersion(""); - setShowFromDropdown(true); - }} - onFocus={() => setShowFromDropdown(true)} - placeholder="Search or select version..." - className="border-mineshaft-600 bg-mineshaft-900" - leftIcon={} - isDisabled={versionsLoading || versionsFetching} - /> - {showFromDropdown && ( -
    - {(() => { - if (versionsLoading || versionsFetching) { - return ( -
    -
    - - Loading versions... - -
    - ); - } - if (filteredFromVersions.length > 0) { - return filteredFromVersions.slice(0, 8).map((version) => ( - - )); - } - return ( -
    - {debouncedFromSearch - ? `No versions found matching "${debouncedFromSearch}"` - : "No versions available"} -
    - ); - })()} -
    - )} -
    - -
    + + opt.value === fromVersion) || null} + onChange={handleFromVersionSelect} + placeholder="Search or select version..." + isLoading={versionsLoading || versionsFetching} + isDisabled={versionsLoading || versionsFetching} + isSearchable + isClearable + menuPortalTarget={document.body} + formatOptionLabel={formatVersionOption} + /> + {/* To Version Selector */} -
    - -
    - { - setToSearch(e.target.value); - setToVersion(""); - setShowToDropdown(true); - }} - onFocus={() => setShowToDropdown(true)} - placeholder="Search or select version..." - className="border-mineshaft-600 bg-mineshaft-900" - leftIcon={} - isDisabled={versionsLoading || versionsFetching} - /> - {showToDropdown && ( -
    - {(() => { - if (versionsLoading || versionsFetching) { - return ( -
    -
    - - Loading versions... - -
    - ); - } - if (filteredToVersions.length > 0) { - return filteredToVersions.slice(0, 8).map((version) => ( - - )); - } - return ( -
    - {debouncedToSearch - ? `No versions found matching "${debouncedToSearch}"` - : "No versions available"} -
    - ); - })()} -
    - )} -
    - -
    + + opt.value === toVersion) || null} + onChange={handleToVersionSelect} + placeholder="Search or select version..." + isLoading={versionsLoading || versionsFetching} + isDisabled={versionsLoading || versionsFetching} + isSearchable + isClearable + menuPortalTarget={document.body} + formatOptionLabel={formatVersionOption} + /> +
    + + +
    + + ); +}; + +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" + /> + ( + +