diff --git a/.infisicalignore b/.infisicalignore index 02cdd4f0e..7c203945f 100644 --- a/.infisicalignore +++ b/.infisicalignore @@ -40,3 +40,4 @@ cli/detect/config/gitleaks.toml:gcp-api-key:578 cli/detect/config/gitleaks.toml:gcp-api-key:579 cli/detect/config/gitleaks.toml:gcp-api-key:581 cli/detect/config/gitleaks.toml:gcp-api-key:582 +backend/src/services/smtp/smtp-service.ts:generic-api-key:79 diff --git a/backend/src/db/migrations/20250604174128_identity-kubernetes-auth-gateway-reviewer.ts b/backend/src/db/migrations/20250604174128_identity-kubernetes-auth-gateway-reviewer.ts new file mode 100644 index 000000000..da5493153 --- /dev/null +++ b/backend/src/db/migrations/20250604174128_identity-kubernetes-auth-gateway-reviewer.ts @@ -0,0 +1,23 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasTokenReviewModeColumn = await knex.schema.hasColumn(TableName.IdentityKubernetesAuth, "tokenReviewMode"); + + if (!hasTokenReviewModeColumn) { + await knex.schema.alterTable(TableName.IdentityKubernetesAuth, (table) => { + table.string("tokenReviewMode").notNullable().defaultTo("api"); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasTokenReviewModeColumn = await knex.schema.hasColumn(TableName.IdentityKubernetesAuth, "tokenReviewMode"); + + if (hasTokenReviewModeColumn) { + await knex.schema.alterTable(TableName.IdentityKubernetesAuth, (table) => { + table.dropColumn("tokenReviewMode"); + }); + } +} diff --git a/backend/src/db/schemas/identity-kubernetes-auths.ts b/backend/src/db/schemas/identity-kubernetes-auths.ts index 00d1fd771..8a351014a 100644 --- a/backend/src/db/schemas/identity-kubernetes-auths.ts +++ b/backend/src/db/schemas/identity-kubernetes-auths.ts @@ -31,7 +31,8 @@ export const IdentityKubernetesAuthsSchema = z.object({ encryptedKubernetesTokenReviewerJwt: zodBuffer.nullable().optional(), encryptedKubernetesCaCertificate: zodBuffer.nullable().optional(), gatewayId: z.string().uuid().nullable().optional(), - accessTokenPeriod: z.coerce.number().default(0) + accessTokenPeriod: z.coerce.number().default(0), + tokenReviewMode: z.string().default("api") }); export type TIdentityKubernetesAuths = z.infer; diff --git a/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts b/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts index 8a54ba089..130e0fa92 100644 --- a/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts +++ b/backend/src/ee/services/dynamic-secret/providers/kubernetes.ts @@ -2,7 +2,7 @@ import axios from "axios"; import https from "https"; import { InternalServerError } from "@app/lib/errors"; -import { withGatewayProxy } from "@app/lib/gateway"; +import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; import { TKubernetesTokenRequest } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-types"; @@ -43,6 +43,7 @@ export const KubernetesProvider = ({ gatewayService }: TKubernetesProviderDTO): return res; }, { + protocol: GatewayProxyProtocol.Tcp, targetHost: inputs.targetHost, targetPort: inputs.targetPort, relayHost, diff --git a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts index ce16a1237..39f8dd6de 100644 --- a/backend/src/ee/services/dynamic-secret/providers/sql-database.ts +++ b/backend/src/ee/services/dynamic-secret/providers/sql-database.ts @@ -3,7 +3,7 @@ import handlebars from "handlebars"; import knex from "knex"; import { z } from "zod"; -import { withGatewayProxy } from "@app/lib/gateway"; +import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; @@ -185,6 +185,7 @@ export const SqlDatabaseProvider = ({ gatewayService }: TSqlDatabaseProviderDTO) await gatewayCallback("localhost", port); }, { + protocol: GatewayProxyProtocol.Tcp, targetHost: providerInputs.host, targetPort: providerInputs.port, relayHost, diff --git a/backend/src/ee/services/dynamic-secret/providers/vertica.ts b/backend/src/ee/services/dynamic-secret/providers/vertica.ts index 9e283ab41..e361ab329 100644 --- a/backend/src/ee/services/dynamic-secret/providers/vertica.ts +++ b/backend/src/ee/services/dynamic-secret/providers/vertica.ts @@ -4,7 +4,7 @@ import knex, { Knex } from "knex"; import { z } from "zod"; import { BadRequestError } from "@app/lib/errors"; -import { withGatewayProxy } from "@app/lib/gateway"; +import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; import { logger } from "@app/lib/logger"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; @@ -196,6 +196,7 @@ export const VerticaProvider = ({ gatewayService }: TVerticaProviderDTO): TDynam await gatewayCallback("localhost", port); }, { + protocol: GatewayProxyProtocol.Tcp, targetHost: providerInputs.host, targetPort: providerInputs.port, relayHost, diff --git a/backend/src/ee/services/license/license-service.ts b/backend/src/ee/services/license/license-service.ts index e2cf09bb1..80e58815e 100644 --- a/backend/src/ee/services/license/license-service.ts +++ b/backend/src/ee/services/license/license-service.ts @@ -709,6 +709,10 @@ export const licenseServiceFactory = ({ return licenses; }; + const invalidateGetPlan = async (orgId: string) => { + await keyStore.deleteItem(FEATURE_CACHE_KEY(orgId)); + }; + return { generateOrgCustomerId, removeOrgCustomer, @@ -723,6 +727,7 @@ export const licenseServiceFactory = ({ return onPremFeatures; }, getPlan, + invalidateGetPlan, updateSubscriptionOrgMemberCount, refreshPlan, getOrgPlan, diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 34cffcd98..d1ae69574 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -376,7 +376,8 @@ const DynamicSecretConditionV2Schema = z .object({ [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], [PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ], - [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN] + [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN], + [PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB] }) .partial() ]), @@ -404,6 +405,23 @@ const DynamicSecretConditionV2Schema = z }) .partial(); +const SecretImportConditionSchema = z + .object({ + environment: z.union([ + z.string(), + z + .object({ + [PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ], + [PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ], + [PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN], + [PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB] + }) + .partial() + ]), + secretPath: SECRET_PATH_PERMISSION_OPERATOR_SCHEMA + }) + .partial(); + const SecretConditionV2Schema = z .object({ environment: z.union([ @@ -741,7 +759,7 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [ action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( "Describe what action an entity can take." ), - conditions: SecretConditionV1Schema.describe( + conditions: SecretImportConditionSchema.describe( "When specified, only matching conditions will be allowed to access given resource." ).optional() }), diff --git a/backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-fns.ts b/backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-fns.ts index 5b05b2301..5fb39a0ec 100644 --- a/backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-fns.ts +++ b/backend/src/ee/services/secret-sync/oci-vault/oci-vault-sync-fns.ts @@ -117,6 +117,7 @@ export const OCIVaultSyncFns = { syncSecrets: async (secretSync: TOCIVaultSyncWithCredentials, secretMap: TSecretMap) => { const { connection, + environment, destinationConfig: { compartmentOcid, vaultOcid, keyOcid } } = secretSync; @@ -213,7 +214,7 @@ export const OCIVaultSyncFns = { // Update and delete secrets for await (const [key, variable] of Object.entries(variables)) { // eslint-disable-next-line no-continue - if (!matchesSchema(key, secretSync.syncOptions.keySchema)) continue; + if (!matchesSchema(key, environment?.slug || "", secretSync.syncOptions.keySchema)) continue; // Only update / delete active secrets if (variable.lifecycleState === vault.models.SecretSummary.LifecycleState.Active) { diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts index a7eec1078..c6ec5dccb 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -10,7 +10,8 @@ export const PgSqlLock = { KmsRootKeyInit: 2025, OrgGatewayRootCaInit: (orgId: string) => pgAdvisoryLockHashText(`org-gateway-root-ca:${orgId}`), OrgGatewayCertExchange: (orgId: string) => pgAdvisoryLockHashText(`org-gateway-cert-exchange:${orgId}`), - SecretRotationV2Creation: (folderId: string) => pgAdvisoryLockHashText(`secret-rotation-v2-creation:${folderId}`) + SecretRotationV2Creation: (folderId: string) => pgAdvisoryLockHashText(`secret-rotation-v2-creation:${folderId}`), + CreateProject: (orgId: string) => pgAdvisoryLockHashText(`create-project:${orgId}`) } as const; export type TKeyStoreFactory = ReturnType; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 71e284ae6..3734cbf21 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -400,6 +400,8 @@ export const KUBERNETES_AUTH = { caCert: "The PEM-encoded CA cert for the Kubernetes API server.", tokenReviewerJwt: "Optional JWT token for accessing Kubernetes TokenReview API. If provided, this long-lived token will be used to validate service account tokens during authentication. If omitted, the client's own JWT will be used instead, which requires the client to have the system:auth-delegator ClusterRole binding.", + tokenReviewMode: + "The mode to use for token review. Must be one of: 'api', 'gateway'. If gateway is selected, the gateway must be deployed in Kubernetes, and the gateway must have the system:auth-delegator ClusterRole binding.", allowedNamespaces: "The comma-separated list of trusted namespaces that service accounts must belong to authenticate with Infisical.", allowedNames: "The comma-separated list of trusted service account names that can authenticate with Infisical.", @@ -417,6 +419,8 @@ export const KUBERNETES_AUTH = { caCert: "The new PEM-encoded CA cert for the Kubernetes API server.", tokenReviewerJwt: "Optional JWT token for accessing Kubernetes TokenReview API. If provided, this long-lived token will be used to validate service account tokens during authentication. If omitted, the client's own JWT will be used instead, which requires the client to have the system:auth-delegator ClusterRole binding.", + tokenReviewMode: + "The mode to use for token review. Must be one of: 'api', 'gateway'. If gateway is selected, the gateway must be deployed in Kubernetes, and the gateway must have the system:auth-delegator ClusterRole binding.", allowedNamespaces: "The new comma-separated list of trusted namespaces that service accounts must belong to authenticate with Infisical.", allowedNames: "The new comma-separated list of trusted service account names that can authenticate with Infisical.", diff --git a/backend/src/lib/gateway/gateway.ts b/backend/src/lib/gateway/gateway.ts new file mode 100644 index 000000000..179c29fc8 --- /dev/null +++ b/backend/src/lib/gateway/gateway.ts @@ -0,0 +1,411 @@ +/* eslint-disable no-await-in-loop */ +import crypto from "node:crypto"; +import net from "node:net"; + +import quicDefault, * as quicModule from "@infisical/quic"; +import axios from "axios"; +import https from "https"; + +import { BadRequestError } from "../errors"; +import { logger } from "../logger"; +import { + GatewayProxyProtocol, + IGatewayProxyOptions, + IGatewayProxyServer, + TGatewayTlsOptions, + TPingGatewayAndVerifyDTO +} from "./types"; + +const DEFAULT_MAX_RETRIES = 3; +const DEFAULT_RETRY_DELAY = 1000; // 1 second + +const quic = quicDefault || quicModule; + +const parseSubjectDetails = (data: string) => { + const values: Record = {}; + data.split("\n").forEach((el) => { + const [key, value] = el.split("="); + values[key.trim()] = value.trim(); + }); + return values; +}; + +const createQuicConnection = async ( + relayHost: string, + relayPort: number, + tlsOptions: TGatewayTlsOptions, + identityId: string, + orgId: string +) => { + const client = await quic.QUICClient.createQUICClient({ + host: relayHost, + port: relayPort, + config: { + ca: tlsOptions.ca, + cert: tlsOptions.cert, + key: tlsOptions.key, + applicationProtos: ["infisical-gateway"], + verifyPeer: true, + verifyCallback: async (certs) => { + if (!certs || certs.length === 0) return quic.native.CryptoError.CertificateRequired; + const serverCertificate = new crypto.X509Certificate(Buffer.from(certs[0])); + const caCertificate = new crypto.X509Certificate(tlsOptions.ca); + const isValidServerCertificate = serverCertificate.verify(caCertificate.publicKey); + if (!isValidServerCertificate) return quic.native.CryptoError.BadCertificate; + + const subjectDetails = parseSubjectDetails(serverCertificate.subject); + if (subjectDetails.OU !== "Gateway" || subjectDetails.CN !== identityId || subjectDetails.O !== orgId) { + return quic.native.CryptoError.CertificateUnknown; + } + + if (new Date() > new Date(serverCertificate.validTo) || new Date() < new Date(serverCertificate.validFrom)) { + return quic.native.CryptoError.CertificateExpired; + } + + const formatedRelayHost = + process.env.NODE_ENV === "development" ? relayHost.replace("host.docker.internal", "127.0.0.1") : relayHost; + if (!serverCertificate.checkIP(formatedRelayHost)) return quic.native.CryptoError.BadCertificate; + }, + maxIdleTimeout: 90000, + keepAliveIntervalTime: 30000 + }, + crypto: { + ops: { + randomBytes: async (data) => { + crypto.getRandomValues(new Uint8Array(data)); + } + } + } + }); + return client; +}; + +export const pingGatewayAndVerify = async ({ + relayHost, + relayPort, + tlsOptions, + maxRetries = DEFAULT_MAX_RETRIES, + identityId, + orgId +}: TPingGatewayAndVerifyDTO) => { + let lastError: Error | null = null; + const quicClient = await createQuicConnection(relayHost, relayPort, tlsOptions, identityId, orgId).catch((err) => { + throw new BadRequestError({ + message: (err as Error)?.message, + error: err as Error + }); + }); + + for (let attempt = 1; attempt <= maxRetries; attempt += 1) { + try { + const stream = quicClient.connection.newStream("bidi"); + const pingWriter = stream.writable.getWriter(); + await pingWriter.write(Buffer.from("PING\n")); + pingWriter.releaseLock(); + + // Read PONG response + const reader = stream.readable.getReader(); + const { value, done } = await reader.read(); + + if (done) { + throw new Error("Gateway closed before receiving PONG"); + } + + const response = Buffer.from(value).toString(); + + if (response !== "PONG\n" && response !== "PONG") { + throw new Error(`Failed to Ping. Unexpected response: ${response}`); + } + + reader.releaseLock(); + return; + } catch (err) { + lastError = err as Error; + + if (attempt < maxRetries) { + await new Promise((resolve) => { + setTimeout(resolve, DEFAULT_RETRY_DELAY); + }); + } + } finally { + await quicClient.destroy(); + } + } + + logger.error(lastError); + throw new BadRequestError({ + message: `Failed to ping gateway after ${maxRetries} attempts. Last error: ${lastError?.message}` + }); +}; + +const setupProxyServer = async ({ + targetPort, + targetHost, + tlsOptions, + relayHost, + relayPort, + identityId, + orgId, + protocol = GatewayProxyProtocol.Tcp, + httpsAgent +}: { + targetHost: string; + targetPort: number; + relayPort: number; + relayHost: string; + tlsOptions: TGatewayTlsOptions; + identityId: string; + orgId: string; + protocol?: GatewayProxyProtocol; + httpsAgent?: https.Agent; +}): Promise => { + const quicClient = await createQuicConnection(relayHost, relayPort, tlsOptions, identityId, orgId).catch((err) => { + throw new BadRequestError({ + error: err as Error + }); + }); + const proxyErrorMsg = [""]; + + return new Promise((resolve, reject) => { + const server = net.createServer(); + + let streamClosed = false; + + // eslint-disable-next-line @typescript-eslint/no-misused-promises + server.on("connection", async (clientConn) => { + try { + clientConn.setKeepAlive(true, 30000); // 30 seconds + clientConn.setNoDelay(true); + + const stream = quicClient.connection.newStream("bidi"); + + const forwardWriter = stream.writable.getWriter(); + let command: string; + + if (protocol === GatewayProxyProtocol.Http) { + const targetUrl = `${targetHost}:${targetPort}`; // note(daniel): targetHost MUST include the scheme (https|http) + command = `FORWARD-HTTP ${targetUrl}`; + logger.debug(`Using HTTP proxy mode: ${command.trim()}`); + + // extract ca certificate from httpsAgent if present + if (httpsAgent && targetHost.startsWith("https://")) { + const agentOptions = httpsAgent.options; + if (agentOptions && agentOptions.ca) { + const caCert = Array.isArray(agentOptions.ca) ? agentOptions.ca.join("\n") : agentOptions.ca; + const caB64 = Buffer.from(caCert as string).toString("base64"); + command += ` ca=${caB64}`; + + const rejectUnauthorized = agentOptions.rejectUnauthorized !== false; + command += ` verify=${rejectUnauthorized}`; + + logger.debug(`Using HTTP proxy mode [command=${command.trim()}]`); + } + } + + command += "\n"; + } else if (protocol === GatewayProxyProtocol.Tcp) { + // For TCP mode, send FORWARD-TCP with host:port + command = `FORWARD-TCP ${targetHost}:${targetPort}\n`; + logger.debug(`Using TCP proxy mode: ${command.trim()}`); + } else { + throw new BadRequestError({ + message: `Invalid protocol: ${protocol as string}` + }); + } + + await forwardWriter.write(Buffer.from(command)); + forwardWriter.releaseLock(); + + // Set up bidirectional copy + const setupCopy = () => { + // Client to QUIC + // eslint-disable-next-line + (async () => { + const writer = stream.writable.getWriter(); + + // Create a handler for client data + clientConn.on("data", (chunk) => { + writer.write(chunk).catch((err) => { + proxyErrorMsg.push((err as Error)?.message); + }); + }); + + // Handle client connection close + clientConn.on("end", () => { + if (!streamClosed) { + try { + writer.close().catch((err) => { + logger.debug(err, "Error closing writer (already closed)"); + }); + } catch (error) { + logger.debug(error, "Error in writer close"); + } + } + }); + + clientConn.on("error", (clientConnErr) => { + writer.abort(clientConnErr?.message).catch((err) => { + proxyErrorMsg.push((err as Error)?.message); + }); + }); + })(); + + // QUIC to Client + void (async () => { + try { + const reader = stream.readable.getReader(); + + let reading = true; + while (reading) { + const { value, done } = await reader.read(); + + if (done) { + reading = false; + clientConn.end(); // Close client connection when QUIC stream ends + break; + } + + // Write data to TCP client + const canContinue = clientConn.write(Buffer.from(value)); + + // Handle backpressure + if (!canContinue) { + await new Promise((res) => { + clientConn.once("drain", res); + }); + } + } + } catch (err) { + proxyErrorMsg.push((err as Error)?.message); + clientConn.destroy(); + } + })(); + }; + + setupCopy(); + // Handle connection closure + clientConn.on("close", () => { + if (!streamClosed) { + streamClosed = true; + stream.destroy().catch((err) => { + logger.debug(err, "Stream already destroyed during close event"); + }); + } + }); + + const cleanup = async () => { + try { + clientConn?.destroy(); + } catch (err) { + logger.debug(err, "Error destroying client connection"); + } + + if (!streamClosed) { + streamClosed = true; + try { + await stream.destroy(); + } catch (err) { + logger.debug(err, "Error destroying stream (might be already closed)"); + } + } + }; + + clientConn.on("error", (clientConnErr) => { + logger.error(clientConnErr, "Client socket error"); + cleanup().catch((err) => { + logger.error(err, "Client conn cleanup"); + }); + }); + + clientConn.on("end", () => { + cleanup().catch((err) => { + logger.error(err, "Client conn end"); + }); + }); + } catch (err) { + logger.error(err, "Failed to establish target connection:"); + clientConn.end(); + reject(err); + } + }); + + server.on("error", (err) => { + reject(err); + }); + + server.on("close", () => { + quicClient?.destroy().catch((err) => { + logger.error(err, "Failed to destroy quic client"); + }); + }); + + server.listen(0, () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("Failed to get server port")); + return; + } + + logger.info(`Gateway proxy started on port ${address.port} (${protocol} mode)`); + resolve({ + server, + port: address.port, + cleanup: async () => { + try { + server.close(); + } catch (err) { + logger.debug(err, "Error closing server"); + } + + try { + await quicClient?.destroy(); + } catch (err) { + logger.debug(err, "Error destroying QUIC client"); + } + }, + getProxyError: () => proxyErrorMsg.join(",") + }); + }); + }); +}; + +export const withGatewayProxy = async ( + callback: (port: number, httpsAgent?: https.Agent) => Promise, + options: IGatewayProxyOptions +): Promise => { + const { relayHost, relayPort, targetHost, targetPort, tlsOptions, identityId, orgId, protocol, httpsAgent } = options; + + // Setup the proxy server + const { port, cleanup, getProxyError } = await setupProxyServer({ + targetHost, + targetPort, + relayPort, + relayHost, + tlsOptions, + identityId, + orgId, + protocol, + httpsAgent + }); + + try { + // Execute the callback with the allocated port + return await callback(port, httpsAgent); + } catch (err) { + const proxyErrorMessage = getProxyError(); + if (proxyErrorMessage) { + logger.error(new Error(proxyErrorMessage), "Failed to proxy"); + } + logger.error(err, "Failed to do gateway"); + let errorMessage = proxyErrorMessage || (err as Error)?.message; + if (axios.isAxiosError(err) && (err.response?.data as { message?: string })?.message) { + errorMessage = (err.response?.data as { message: string }).message; + } + + throw new BadRequestError({ message: errorMessage }); + } finally { + // Ensure cleanup happens regardless of success or failure + await cleanup(); + } +}; diff --git a/backend/src/lib/gateway/index.ts b/backend/src/lib/gateway/index.ts index 4d6401eac..9292473e5 100644 --- a/backend/src/lib/gateway/index.ts +++ b/backend/src/lib/gateway/index.ts @@ -1,392 +1,2 @@ -/* eslint-disable no-await-in-loop */ -import crypto from "node:crypto"; -import net from "node:net"; - -import quicDefault, * as quicModule from "@infisical/quic"; -import axios from "axios"; - -import { BadRequestError } from "../errors"; -import { logger } from "../logger"; - -const DEFAULT_MAX_RETRIES = 3; -const DEFAULT_RETRY_DELAY = 1000; // 1 second - -const quic = quicDefault || quicModule; - -const parseSubjectDetails = (data: string) => { - const values: Record = {}; - data.split("\n").forEach((el) => { - const [key, value] = el.split("="); - values[key.trim()] = value.trim(); - }); - return values; -}; - -type TTlsOption = { ca: string; cert: string; key: string }; - -const createQuicConnection = async ( - relayHost: string, - relayPort: number, - tlsOptions: TTlsOption, - identityId: string, - orgId: string -) => { - const client = await quic.QUICClient.createQUICClient({ - host: relayHost, - port: relayPort, - config: { - ca: tlsOptions.ca, - cert: tlsOptions.cert, - key: tlsOptions.key, - applicationProtos: ["infisical-gateway"], - verifyPeer: true, - verifyCallback: async (certs) => { - if (!certs || certs.length === 0) return quic.native.CryptoError.CertificateRequired; - const serverCertificate = new crypto.X509Certificate(Buffer.from(certs[0])); - const caCertificate = new crypto.X509Certificate(tlsOptions.ca); - const isValidServerCertificate = serverCertificate.verify(caCertificate.publicKey); - if (!isValidServerCertificate) return quic.native.CryptoError.BadCertificate; - - const subjectDetails = parseSubjectDetails(serverCertificate.subject); - if (subjectDetails.OU !== "Gateway" || subjectDetails.CN !== identityId || subjectDetails.O !== orgId) { - return quic.native.CryptoError.CertificateUnknown; - } - - if (new Date() > new Date(serverCertificate.validTo) || new Date() < new Date(serverCertificate.validFrom)) { - return quic.native.CryptoError.CertificateExpired; - } - - const formatedRelayHost = - process.env.NODE_ENV === "development" ? relayHost.replace("host.docker.internal", "127.0.0.1") : relayHost; - if (!serverCertificate.checkIP(formatedRelayHost)) return quic.native.CryptoError.BadCertificate; - }, - maxIdleTimeout: 90000, - keepAliveIntervalTime: 30000 - }, - crypto: { - ops: { - randomBytes: async (data) => { - crypto.getRandomValues(new Uint8Array(data)); - } - } - } - }); - return client; -}; - -type TPingGatewayAndVerifyDTO = { - relayHost: string; - relayPort: number; - tlsOptions: TTlsOption; - maxRetries?: number; - identityId: string; - orgId: string; -}; - -export const pingGatewayAndVerify = async ({ - relayHost, - relayPort, - tlsOptions, - maxRetries = DEFAULT_MAX_RETRIES, - identityId, - orgId -}: TPingGatewayAndVerifyDTO) => { - let lastError: Error | null = null; - const quicClient = await createQuicConnection(relayHost, relayPort, tlsOptions, identityId, orgId).catch((err) => { - throw new BadRequestError({ - message: (err as Error)?.message, - error: err as Error - }); - }); - - for (let attempt = 1; attempt <= maxRetries; attempt += 1) { - try { - const stream = quicClient.connection.newStream("bidi"); - const pingWriter = stream.writable.getWriter(); - await pingWriter.write(Buffer.from("PING\n")); - pingWriter.releaseLock(); - - // Read PONG response - const reader = stream.readable.getReader(); - const { value, done } = await reader.read(); - - if (done) { - throw new Error("Gateway closed before receiving PONG"); - } - - const response = Buffer.from(value).toString(); - - if (response !== "PONG\n" && response !== "PONG") { - throw new Error(`Failed to Ping. Unexpected response: ${response}`); - } - - reader.releaseLock(); - return; - } catch (err) { - lastError = err as Error; - - if (attempt < maxRetries) { - await new Promise((resolve) => { - setTimeout(resolve, DEFAULT_RETRY_DELAY); - }); - } - } finally { - await quicClient.destroy(); - } - } - - logger.error(lastError); - throw new BadRequestError({ - message: `Failed to ping gateway after ${maxRetries} attempts. Last error: ${lastError?.message}` - }); -}; - -interface TProxyServer { - server: net.Server; - port: number; - cleanup: () => Promise; - getProxyError: () => string; -} - -const setupProxyServer = async ({ - targetPort, - targetHost, - tlsOptions, - relayHost, - relayPort, - identityId, - orgId -}: { - targetHost: string; - targetPort: number; - relayPort: number; - relayHost: string; - tlsOptions: TTlsOption; - identityId: string; - orgId: string; -}): Promise => { - const quicClient = await createQuicConnection(relayHost, relayPort, tlsOptions, identityId, orgId).catch((err) => { - throw new BadRequestError({ - error: err as Error - }); - }); - const proxyErrorMsg = [""]; - - return new Promise((resolve, reject) => { - const server = net.createServer(); - - let streamClosed = false; - - // eslint-disable-next-line @typescript-eslint/no-misused-promises - server.on("connection", async (clientConn) => { - try { - clientConn.setKeepAlive(true, 30000); // 30 seconds - clientConn.setNoDelay(true); - - const stream = quicClient.connection.newStream("bidi"); - // Send FORWARD-TCP command - const forwardWriter = stream.writable.getWriter(); - await forwardWriter.write(Buffer.from(`FORWARD-TCP ${targetHost}:${targetPort}\n`)); - forwardWriter.releaseLock(); - - // Set up bidirectional copy - const setupCopy = () => { - // Client to QUIC - // eslint-disable-next-line - (async () => { - const writer = stream.writable.getWriter(); - - // Create a handler for client data - clientConn.on("data", (chunk) => { - writer.write(chunk).catch((err) => { - proxyErrorMsg.push((err as Error)?.message); - }); - }); - - // Handle client connection close - clientConn.on("end", () => { - if (!streamClosed) { - try { - writer.close().catch((err) => { - logger.debug(err, "Error closing writer (already closed)"); - }); - } catch (error) { - logger.debug(error, "Error in writer close"); - } - } - }); - - clientConn.on("error", (clientConnErr) => { - writer.abort(clientConnErr?.message).catch((err) => { - proxyErrorMsg.push((err as Error)?.message); - }); - }); - })(); - - // QUIC to Client - void (async () => { - try { - const reader = stream.readable.getReader(); - - let reading = true; - while (reading) { - const { value, done } = await reader.read(); - - if (done) { - reading = false; - clientConn.end(); // Close client connection when QUIC stream ends - break; - } - - // Write data to TCP client - const canContinue = clientConn.write(Buffer.from(value)); - - // Handle backpressure - if (!canContinue) { - await new Promise((res) => { - clientConn.once("drain", res); - }); - } - } - } catch (err) { - proxyErrorMsg.push((err as Error)?.message); - clientConn.destroy(); - } - })(); - }; - - setupCopy(); - // Handle connection closure - clientConn.on("close", () => { - if (!streamClosed) { - streamClosed = true; - stream.destroy().catch((err) => { - logger.debug(err, "Stream already destroyed during close event"); - }); - } - }); - - const cleanup = async () => { - try { - clientConn?.destroy(); - } catch (err) { - logger.debug(err, "Error destroying client connection"); - } - - if (!streamClosed) { - streamClosed = true; - try { - await stream.destroy(); - } catch (err) { - logger.debug(err, "Error destroying stream (might be already closed)"); - } - } - }; - - clientConn.on("error", (clientConnErr) => { - logger.error(clientConnErr, "Client socket error"); - cleanup().catch((err) => { - logger.error(err, "Client conn cleanup"); - }); - }); - - clientConn.on("end", () => { - cleanup().catch((err) => { - logger.error(err, "Client conn end"); - }); - }); - } catch (err) { - logger.error(err, "Failed to establish target connection:"); - clientConn.end(); - reject(err); - } - }); - - server.on("error", (err) => { - reject(err); - }); - - server.on("close", () => { - quicClient?.destroy().catch((err) => { - logger.error(err, "Failed to destroy quic client"); - }); - }); - - server.listen(0, () => { - const address = server.address(); - if (!address || typeof address === "string") { - server.close(); - reject(new Error("Failed to get server port")); - return; - } - - logger.info("Gateway proxy started"); - resolve({ - server, - port: address.port, - cleanup: async () => { - try { - server.close(); - } catch (err) { - logger.debug(err, "Error closing server"); - } - - try { - await quicClient?.destroy(); - } catch (err) { - logger.debug(err, "Error destroying QUIC client"); - } - }, - getProxyError: () => proxyErrorMsg.join(",") - }); - }); - }); -}; - -interface ProxyOptions { - targetHost: string; - targetPort: number; - relayHost: string; - relayPort: number; - tlsOptions: TTlsOption; - identityId: string; - orgId: string; -} - -export const withGatewayProxy = async ( - callback: (port: number) => Promise, - options: ProxyOptions -): Promise => { - const { relayHost, relayPort, targetHost, targetPort, tlsOptions, identityId, orgId } = options; - - // Setup the proxy server - const { port, cleanup, getProxyError } = await setupProxyServer({ - targetHost, - targetPort, - relayPort, - relayHost, - tlsOptions, - identityId, - orgId - }); - - try { - // Execute the callback with the allocated port - return await callback(port); - } catch (err) { - const proxyErrorMessage = getProxyError(); - if (proxyErrorMessage) { - logger.error(new Error(proxyErrorMessage), "Failed to proxy"); - } - logger.error(err, "Failed to do gateway"); - let errorMessage = proxyErrorMessage || (err as Error)?.message; - if (axios.isAxiosError(err) && (err.response?.data as { message?: string })?.message) { - errorMessage = (err.response?.data as { message: string }).message; - } - - throw new BadRequestError({ message: errorMessage }); - } finally { - // Ensure cleanup happens regardless of success or failure - await cleanup(); - } -}; +export { pingGatewayAndVerify, withGatewayProxy } from "./gateway"; +export { GatewayHttpProxyActions, GatewayProxyProtocol } from "./types"; diff --git a/backend/src/lib/gateway/types.ts b/backend/src/lib/gateway/types.ts new file mode 100644 index 000000000..5d0ac8237 --- /dev/null +++ b/backend/src/lib/gateway/types.ts @@ -0,0 +1,42 @@ +import net from "node:net"; + +import https from "https"; + +export type TGatewayTlsOptions = { ca: string; cert: string; key: string }; + +export enum GatewayProxyProtocol { + Http = "http", + Tcp = "tcp" +} + +export enum GatewayHttpProxyActions { + InjectGatewayK8sServiceAccountToken = "inject-k8s-sa-auth-token" +} + +export interface IGatewayProxyOptions { + targetHost: string; + targetPort: number; + relayHost: string; + relayPort: number; + tlsOptions: TGatewayTlsOptions; + identityId: string; + orgId: string; + protocol: GatewayProxyProtocol; + httpsAgent?: https.Agent; +} + +export type TPingGatewayAndVerifyDTO = { + relayHost: string; + relayPort: number; + tlsOptions: TGatewayTlsOptions; + maxRetries?: number; + identityId: string; + orgId: string; +}; + +export interface IGatewayProxyServer { + server: net.Server; + port: number; + cleanup: () => Promise; + getProxyError: () => string; +} diff --git a/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts b/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts index d9ef62087..5eb0c6990 100644 --- a/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts +++ b/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts @@ -8,6 +8,7 @@ import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; import { TIdentityTrustedIp } from "@app/services/identity/identity-types"; +import { IdentityKubernetesAuthTokenReviewMode } from "@app/services/identity-kubernetes-auth/identity-kubernetes-auth-types"; import { isSuperAdmin } from "@app/services/super-admin/super-admin-fns"; const IdentityKubernetesAuthResponseSchema = IdentityKubernetesAuthsSchema.pick({ @@ -18,6 +19,7 @@ const IdentityKubernetesAuthResponseSchema = IdentityKubernetesAuthsSchema.pick( accessTokenTrustedIps: true, createdAt: true, updatedAt: true, + tokenReviewMode: true, identityId: true, kubernetesHost: true, allowedNamespaces: true, @@ -124,6 +126,10 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide ), caCert: z.string().trim().default("").describe(KUBERNETES_AUTH.ATTACH.caCert), tokenReviewerJwt: z.string().trim().optional().describe(KUBERNETES_AUTH.ATTACH.tokenReviewerJwt), + tokenReviewMode: z + .nativeEnum(IdentityKubernetesAuthTokenReviewMode) + .default(IdentityKubernetesAuthTokenReviewMode.Api) + .describe(KUBERNETES_AUTH.ATTACH.tokenReviewMode), allowedNamespaces: z.string().describe(KUBERNETES_AUTH.ATTACH.allowedNamespaces), // TODO: validation allowedNames: z.string().describe(KUBERNETES_AUTH.ATTACH.allowedNames), allowedAudience: z.string().describe(KUBERNETES_AUTH.ATTACH.allowedAudience), @@ -157,10 +163,22 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide .default(0) .describe(KUBERNETES_AUTH.ATTACH.accessTokenNumUsesLimit) }) - .refine( - (val) => val.accessTokenTTL <= val.accessTokenMaxTTL, - "Access Token TTL cannot be greater than Access Token Max TTL." - ), + .superRefine((data, ctx) => { + if (data.tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Gateway && !data.gatewayId) { + ctx.addIssue({ + path: ["gatewayId"], + code: z.ZodIssueCode.custom, + message: "When token review mode is set to Gateway, a gateway must be selected" + }); + } + if (data.accessTokenTTL > data.accessTokenMaxTTL) { + ctx.addIssue({ + path: ["accessTokenTTL"], + code: z.ZodIssueCode.custom, + message: "Access Token TTL cannot be greater than Access Token Max TTL." + }); + } + }), response: { 200: z.object({ identityKubernetesAuth: IdentityKubernetesAuthResponseSchema @@ -247,6 +265,10 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide ), caCert: z.string().trim().optional().describe(KUBERNETES_AUTH.UPDATE.caCert), tokenReviewerJwt: z.string().trim().nullable().optional().describe(KUBERNETES_AUTH.UPDATE.tokenReviewerJwt), + tokenReviewMode: z + .nativeEnum(IdentityKubernetesAuthTokenReviewMode) + .optional() + .describe(KUBERNETES_AUTH.UPDATE.tokenReviewMode), allowedNamespaces: z.string().optional().describe(KUBERNETES_AUTH.UPDATE.allowedNamespaces), // TODO: validation allowedNames: z.string().optional().describe(KUBERNETES_AUTH.UPDATE.allowedNames), allowedAudience: z.string().optional().describe(KUBERNETES_AUTH.UPDATE.allowedAudience), @@ -280,10 +302,26 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide .optional() .describe(KUBERNETES_AUTH.UPDATE.accessTokenMaxTTL) }) - .refine( - (val) => (val.accessTokenMaxTTL && val.accessTokenTTL ? val.accessTokenTTL <= val.accessTokenMaxTTL : true), - "Access Token TTL cannot be greater than Access Token Max TTL." - ), + .superRefine((data, ctx) => { + if ( + data.tokenReviewMode && + data.tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Gateway && + !data.gatewayId + ) { + ctx.addIssue({ + path: ["gatewayId"], + code: z.ZodIssueCode.custom, + message: "When token review mode is set to Gateway, a gateway must be selected" + }); + } + if (data.accessTokenMaxTTL && data.accessTokenTTL ? data.accessTokenTTL > data.accessTokenMaxTTL : false) { + ctx.addIssue({ + path: ["accessTokenTTL"], + code: z.ZodIssueCode.custom, + message: "Access Token TTL cannot be greater than Access Token Max TTL." + }); + } + }), response: { 200: z.object({ identityKubernetesAuth: IdentityKubernetesAuthResponseSchema diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts index 34a28c6a6..b15a2ed4f 100644 --- a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts @@ -20,8 +20,9 @@ import { import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors"; -import { withGatewayProxy } from "@app/lib/gateway"; +import { GatewayHttpProxyActions, GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; +import { logger } from "@app/lib/logger"; import { ActorType, AuthTokenType } from "../auth/auth-type"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; @@ -33,6 +34,7 @@ import { validateIdentityUpdateForSuperAdminPrivileges } from "../super-admin/su import { TIdentityKubernetesAuthDALFactory } from "./identity-kubernetes-auth-dal"; import { extractK8sUsername } from "./identity-kubernetes-auth-fns"; import { + IdentityKubernetesAuthTokenReviewMode, TAttachKubernetesAuthDTO, TCreateTokenReviewResponse, TGetKubernetesAuthDTO, @@ -72,19 +74,25 @@ export const identityKubernetesAuthServiceFactory = ({ gatewayId: string; targetHost: string; targetPort: number; + caCert?: string; + reviewTokenThroughGateway: boolean; }, - gatewayCallback: (host: string, port: number) => Promise + gatewayCallback: (host: string, port: number, httpsAgent?: https.Agent) => Promise ): Promise => { const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(inputs.gatewayId); const [relayHost, relayPort] = relayDetails.relayAddress.split(":"); const callbackResult = await withGatewayProxy( - async (port) => { - // Needs to be https protocol or the kubernetes API server will fail with "Client sent an HTTP request to an HTTPS server" - const res = await gatewayCallback("https://localhost", port); + async (port, httpsAgent) => { + const res = await gatewayCallback( + inputs.reviewTokenThroughGateway ? "http://localhost" : "https://localhost", + port, + httpsAgent + ); return res; }, { + protocol: inputs.reviewTokenThroughGateway ? GatewayProxyProtocol.Http : GatewayProxyProtocol.Tcp, targetHost: inputs.targetHost, targetPort: inputs.targetPort, relayHost, @@ -95,7 +103,12 @@ export const identityKubernetesAuthServiceFactory = ({ ca: relayDetails.certChain, cert: relayDetails.certificate, key: relayDetails.privateKey.toString() - } + }, + // we always pass this, because its needed for both tcp and http protocol + httpsAgent: new https.Agent({ + ca: inputs.caCert, + rejectUnauthorized: Boolean(inputs.caCert) + }) } ); @@ -129,22 +142,29 @@ export const identityKubernetesAuthServiceFactory = ({ caCert = decryptor({ cipherTextBlob: identityKubernetesAuth.encryptedKubernetesCaCertificate }).toString(); } - let tokenReviewerJwt = ""; - if (identityKubernetesAuth.encryptedKubernetesTokenReviewerJwt) { - tokenReviewerJwt = decryptor({ - cipherTextBlob: identityKubernetesAuth.encryptedKubernetesTokenReviewerJwt - }).toString(); - } else { - // if no token reviewer is provided means the incoming token has to act as reviewer - tokenReviewerJwt = serviceAccountJwt; - } + const tokenReviewCallbackRaw = async (host: string = identityKubernetesAuth.kubernetesHost, port?: number) => { + logger.info({ host, port }, "tokenReviewCallbackRaw: Processing kubernetes token review using raw API"); + let tokenReviewerJwt = ""; + if (identityKubernetesAuth.encryptedKubernetesTokenReviewerJwt) { + tokenReviewerJwt = decryptor({ + cipherTextBlob: identityKubernetesAuth.encryptedKubernetesTokenReviewerJwt + }).toString(); + } else { + // if no token reviewer is provided means the incoming token has to act as reviewer + tokenReviewerJwt = serviceAccountJwt; + } - let { kubernetesHost } = identityKubernetesAuth; - if (kubernetesHost.startsWith("https://") || kubernetesHost.startsWith("http://")) { - kubernetesHost = new RE2("^https?:\\/\\/").replace(kubernetesHost, ""); - } + let servername = identityKubernetesAuth.kubernetesHost; + if (servername.startsWith("https://") || servername.startsWith("http://")) { + servername = new RE2("^https?:\\/\\/").replace(servername, ""); + } + + // get the last colon index, if it has a port, remove it, including the colon + const lastColonIndex = servername.lastIndexOf(":"); + if (lastColonIndex !== -1) { + servername = servername.substring(0, lastColonIndex); + } - const tokenReviewCallback = async (host: string = identityKubernetesAuth.kubernetesHost, port?: number) => { const baseUrl = port ? `${host}:${port}` : host; const res = await axios @@ -165,11 +185,10 @@ export const identityKubernetesAuthServiceFactory = ({ }, signal: AbortSignal.timeout(10000), timeout: 10000, - // if ca cert, rejectUnauthorized: true httpsAgent: new https.Agent({ ca: caCert, rejectUnauthorized: Boolean(caCert), - servername: kubernetesHost + servername }) } ) @@ -192,18 +211,137 @@ export const identityKubernetesAuthServiceFactory = ({ return res.data; }; - const [k8sHost, k8sPort] = kubernetesHost.split(":"); + const tokenReviewCallbackThroughGateway = async ( + host: string = identityKubernetesAuth.kubernetesHost, + port?: number, + httpsAgent?: https.Agent + ) => { + logger.info( + { + host, + port + }, + "tokenReviewCallbackThroughGateway: Processing kubernetes token review using gateway" + ); - const data = identityKubernetesAuth.gatewayId - ? await $gatewayProxyWrapper( + const baseUrl = port ? `${host}:${port}` : host; + + const res = await axios + .post( + `${baseUrl}/apis/authentication.k8s.io/v1/tokenreviews`, { - gatewayId: identityKubernetesAuth.gatewayId, - targetHost: k8sHost, - targetPort: k8sPort ? Number(k8sPort) : 443 + apiVersion: "authentication.k8s.io/v1", + kind: "TokenReview", + spec: { + token: serviceAccountJwt, + ...(identityKubernetesAuth.allowedAudience ? { audiences: [identityKubernetesAuth.allowedAudience] } : {}) + } }, - tokenReviewCallback + { + headers: { + "Content-Type": "application/json", + "x-infisical-action": GatewayHttpProxyActions.InjectGatewayK8sServiceAccountToken + }, + signal: AbortSignal.timeout(10000), + timeout: 10000, + ...(httpsAgent ? { httpsAgent } : {}) + } ) - : await tokenReviewCallback(); + .catch((err) => { + if (err instanceof AxiosError) { + if (err.response) { + let { message } = err?.response?.data as unknown as { message?: string }; + + if (!message && typeof err.response.data === "string") { + message = err.response.data; + } + + if (message) { + throw new UnauthorizedError({ + message, + name: "KubernetesTokenReviewRequestError" + }); + } + } + } + throw err; + }); + + return res.data; + }; + + let data: TCreateTokenReviewResponse | undefined; + + if (identityKubernetesAuth.tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Gateway) { + const { kubernetesHost } = identityKubernetesAuth; + + let urlString = kubernetesHost; + if (!kubernetesHost.startsWith("http://") && !kubernetesHost.startsWith("https://")) { + urlString = `https://${kubernetesHost}`; + } + + const url = new URL(urlString); + let { port: k8sPort } = url; + const { protocol, hostname: k8sHost } = url; + + const cleanedProtocol = new RE2(/[^a-zA-Z0-9]/g).replace(protocol, "").toLowerCase(); + + if (!["https", "http"].includes(cleanedProtocol)) { + throw new BadRequestError({ + message: "Invalid Kubernetes host URL, must start with http:// or https://" + }); + } + + if (!k8sPort) { + k8sPort = cleanedProtocol === "https" ? "443" : "80"; + } + + if (!identityKubernetesAuth.gatewayId) { + throw new BadRequestError({ + message: "Gateway ID is required when token review mode is set to Gateway" + }); + } + + data = await $gatewayProxyWrapper( + { + gatewayId: identityKubernetesAuth.gatewayId, + targetHost: `${cleanedProtocol}://${k8sHost}`, // note(daniel): must include the protocol (https|http) + targetPort: k8sPort ? Number(k8sPort) : 443, + caCert, + reviewTokenThroughGateway: true + }, + tokenReviewCallbackThroughGateway + ); + } else if (identityKubernetesAuth.tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Api) { + let { kubernetesHost } = identityKubernetesAuth; + if (kubernetesHost.startsWith("https://") || kubernetesHost.startsWith("http://")) { + kubernetesHost = new RE2("^https?:\\/\\/").replace(kubernetesHost, ""); + } + + const [k8sHost, k8sPort] = kubernetesHost.split(":"); + + data = identityKubernetesAuth.gatewayId + ? await $gatewayProxyWrapper( + { + gatewayId: identityKubernetesAuth.gatewayId, + targetHost: k8sHost, + targetPort: k8sPort ? Number(k8sPort) : 443, + reviewTokenThroughGateway: false + }, + tokenReviewCallbackRaw + ) + : await tokenReviewCallbackRaw(); + } else { + throw new BadRequestError({ + message: `Invalid token review mode: ${identityKubernetesAuth.tokenReviewMode}` + }); + } + + if (!data) { + throw new BadRequestError({ + message: "Failed to review token" + }); + } if ("error" in data.status) throw new UnauthorizedError({ message: data.status.error, name: "KubernetesTokenReviewError" }); @@ -298,6 +436,7 @@ export const identityKubernetesAuthServiceFactory = ({ kubernetesHost, caCert, tokenReviewerJwt, + tokenReviewMode, allowedNamespaces, allowedNames, allowedAudience, @@ -384,6 +523,7 @@ export const identityKubernetesAuthServiceFactory = ({ { identityId: identityMembershipOrg.identityId, kubernetesHost, + tokenReviewMode, allowedNamespaces, allowedNames, allowedAudience, @@ -410,6 +550,7 @@ export const identityKubernetesAuthServiceFactory = ({ kubernetesHost, caCert, tokenReviewerJwt, + tokenReviewMode, allowedNamespaces, allowedNames, allowedAudience, @@ -492,6 +633,7 @@ export const identityKubernetesAuthServiceFactory = ({ const updateQuery: TIdentityKubernetesAuthsUpdate = { kubernetesHost, + tokenReviewMode, allowedNamespaces, allowedNames, allowedAudience, diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts index 12edd266f..03dd7fd77 100644 --- a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-types.ts @@ -5,11 +5,17 @@ export type TLoginKubernetesAuthDTO = { jwt: string; }; +export enum IdentityKubernetesAuthTokenReviewMode { + Api = "api", + Gateway = "gateway" +} + export type TAttachKubernetesAuthDTO = { identityId: string; kubernetesHost: string; caCert: string; tokenReviewerJwt?: string; + tokenReviewMode: IdentityKubernetesAuthTokenReviewMode; allowedNamespaces: string; allowedNames: string; allowedAudience: string; @@ -26,6 +32,7 @@ export type TUpdateKubernetesAuthDTO = { kubernetesHost?: string; caCert?: string; tokenReviewerJwt?: string | null; + tokenReviewMode?: IdentityKubernetesAuthTokenReviewMode; allowedNamespaces?: string; allowedNames?: string; allowedAudience?: string; diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 6e8766a84..63cb8935d 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -837,16 +837,22 @@ export const orgServiceFactory = ({ // if the user doesn't exist we create the user with the email if (!inviteeUser) { - inviteeUser = await userDAL.create( - { - isAccepted: false, - email: inviteeEmail, - username: inviteeEmail, - authMethods: [AuthMethod.EMAIL], - isGhost: false - }, - tx - ); + // TODO(carlos): will be removed once the function receives usernames instead of emails + const usersByEmail = await userDAL.findUserByEmail(inviteeEmail, tx); + if (usersByEmail?.length === 1) { + [inviteeUser] = usersByEmail; + } else { + inviteeUser = await userDAL.create( + { + isAccepted: false, + email: inviteeEmail, + username: inviteeEmail, + authMethods: [AuthMethod.EMAIL], + isGhost: false + }, + tx + ); + } } const inviteeUserId = inviteeUser?.id; diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index 6b132f654..d8eee188a 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -30,7 +30,7 @@ import { TSshCertificateDALFactory } from "@app/ee/services/ssh-certificate/ssh- import { TSshCertificateTemplateDALFactory } from "@app/ee/services/ssh-certificate-template/ssh-certificate-template-dal"; import { TSshHostDALFactory } from "@app/ee/services/ssh-host/ssh-host-dal"; import { TSshHostGroupDALFactory } from "@app/ee/services/ssh-host-group/ssh-host-group-dal"; -import { TKeyStoreFactory } from "@app/keystore/keystore"; +import { PgSqlLock, TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; @@ -165,7 +165,7 @@ type TProjectServiceFactoryDep = { sshHostGroupDAL: Pick; permissionService: TPermissionServiceFactory; orgService: Pick; - licenseService: Pick; + licenseService: Pick; queueService: Pick; smtpService: Pick; orgDAL: Pick; @@ -259,16 +259,17 @@ export const projectServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Create, OrgPermissionSubjects.Workspace); - const plan = await licenseService.getPlan(organization.id); - if (plan.workspaceLimit !== null && plan.workspacesUsed >= plan.workspaceLimit) { - // case: limit imposed on number of workspaces allowed - // case: number of workspaces used exceeds the number of workspaces allowed - throw new BadRequestError({ - message: "Failed to create workspace due to plan limit reached. Upgrade plan to add more workspaces." - }); - } - const results = await (trx || projectDAL).transaction(async (tx) => { + await tx.raw("SELECT pg_advisory_xact_lock(?)", [PgSqlLock.CreateProject(organization.id)]); + + const plan = await licenseService.getPlan(organization.id); + if (plan.workspaceLimit !== null && plan.workspacesUsed >= plan.workspaceLimit) { + // case: limit imposed on number of workspaces allowed + // case: number of workspaces used exceeds the number of workspaces allowed + throw new BadRequestError({ + message: "Failed to create workspace due to plan limit reached. Upgrade plan to add more workspaces." + }); + } const ghostUser = await orgService.addGhostUser(organization.id, tx); if (kmsKeyId) { @@ -493,6 +494,10 @@ export const projectServiceFactory = ({ ); } + // no need to invalidate if there was no limit + if (plan.workspaceLimit) { + await licenseService.invalidateGetPlan(organization.id); + } return { ...project, environments: envs, diff --git a/backend/src/services/secret-sync/1password/1password-sync-fns.ts b/backend/src/services/secret-sync/1password/1password-sync-fns.ts index c832fbbdb..9305f2e3c 100644 --- a/backend/src/services/secret-sync/1password/1password-sync-fns.ts +++ b/backend/src/services/secret-sync/1password/1password-sync-fns.ts @@ -127,6 +127,7 @@ export const OnePassSyncFns = { syncSecrets: async (secretSync: TOnePassSyncWithCredentials, secretMap: TSecretMap) => { const { connection, + environment, destinationConfig: { vaultId } } = secretSync; @@ -164,7 +165,7 @@ export const OnePassSyncFns = { for await (const [key, variable] of Object.entries(items)) { // eslint-disable-next-line no-continue - if (!matchesSchema(key, secretSync.syncOptions.keySchema)) continue; + if (!matchesSchema(key, environment?.slug || "", secretSync.syncOptions.keySchema)) continue; if (!(key in secretMap)) { try { diff --git a/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-fns.ts b/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-fns.ts index a73bc81c9..b687d81dd 100644 --- a/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-fns.ts +++ b/backend/src/services/secret-sync/aws-parameter-store/aws-parameter-store-sync-fns.ts @@ -294,7 +294,7 @@ const deleteParametersBatch = async ( export const AwsParameterStoreSyncFns = { syncSecrets: async (secretSync: TAwsParameterStoreSyncWithCredentials, secretMap: TSecretMap) => { - const { destinationConfig, syncOptions } = secretSync; + const { destinationConfig, syncOptions, environment } = secretSync; const ssm = await getSSM(secretSync); @@ -391,7 +391,7 @@ export const AwsParameterStoreSyncFns = { const [key, parameter] = entry; // eslint-disable-next-line no-continue - if (!matchesSchema(key, syncOptions.keySchema)) continue; + if (!matchesSchema(key, environment?.slug || "", syncOptions.keySchema)) continue; if (!(key in secretMap) || !secretMap[key].value) { parametersToDelete.push(parameter); diff --git a/backend/src/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-fns.ts b/backend/src/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-fns.ts index 1b1daf2ac..df73512e5 100644 --- a/backend/src/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-fns.ts +++ b/backend/src/services/secret-sync/aws-secrets-manager/aws-secrets-manager-sync-fns.ts @@ -57,7 +57,11 @@ const sleep = async () => setTimeout(resolve, 1000); }); -const getSecretsRecord = async (client: SecretsManagerClient, keySchema?: string): Promise => { +const getSecretsRecord = async ( + client: SecretsManagerClient, + environment: string, + keySchema?: string +): Promise => { const awsSecretsRecord: TAwsSecretsRecord = {}; let hasNext = true; let nextToken: string | undefined; @@ -72,7 +76,7 @@ const getSecretsRecord = async (client: SecretsManagerClient, keySchema?: string if (output.SecretList) { output.SecretList.forEach((secretEntry) => { - if (secretEntry.Name && matchesSchema(secretEntry.Name, keySchema)) { + if (secretEntry.Name && matchesSchema(secretEntry.Name, environment, keySchema)) { awsSecretsRecord[secretEntry.Name] = secretEntry; } }); @@ -307,11 +311,11 @@ const processTags = ({ export const AwsSecretsManagerSyncFns = { syncSecrets: async (secretSync: TAwsSecretsManagerSyncWithCredentials, secretMap: TSecretMap) => { - const { destinationConfig, syncOptions } = secretSync; + const { destinationConfig, syncOptions, environment } = secretSync; const client = await getSecretsManagerClient(secretSync); - const awsSecretsRecord = await getSecretsRecord(client, syncOptions.keySchema); + const awsSecretsRecord = await getSecretsRecord(client, environment?.slug || "", syncOptions.keySchema); const awsValuesRecord = await getSecretValuesRecord(client, awsSecretsRecord); @@ -401,7 +405,7 @@ export const AwsSecretsManagerSyncFns = { for await (const secretKey of Object.keys(awsSecretsRecord)) { // eslint-disable-next-line no-continue - if (!matchesSchema(secretKey, syncOptions.keySchema)) continue; + if (!matchesSchema(secretKey, environment?.slug || "", syncOptions.keySchema)) continue; if (!(secretKey in secretMap) || !secretMap[secretKey].value) { try { @@ -468,7 +472,11 @@ export const AwsSecretsManagerSyncFns = { getSecrets: async (secretSync: TAwsSecretsManagerSyncWithCredentials): Promise => { const client = await getSecretsManagerClient(secretSync); - const awsSecretsRecord = await getSecretsRecord(client, secretSync.syncOptions.keySchema); + const awsSecretsRecord = await getSecretsRecord( + client, + secretSync.environment?.slug || "", + secretSync.syncOptions.keySchema + ); const awsValuesRecord = await getSecretValuesRecord(client, awsSecretsRecord); const { destinationConfig } = secretSync; @@ -503,11 +511,11 @@ export const AwsSecretsManagerSyncFns = { } }, removeSecrets: async (secretSync: TAwsSecretsManagerSyncWithCredentials, secretMap: TSecretMap) => { - const { destinationConfig, syncOptions } = secretSync; + const { destinationConfig, syncOptions, environment } = secretSync; const client = await getSecretsManagerClient(secretSync); - const awsSecretsRecord = await getSecretsRecord(client, syncOptions.keySchema); + const awsSecretsRecord = await getSecretsRecord(client, environment?.slug || "", syncOptions.keySchema); if (destinationConfig.mappingBehavior === AwsSecretsManagerSyncMappingBehavior.OneToOne) { for await (const secretKey of Object.keys(awsSecretsRecord)) { diff --git a/backend/src/services/secret-sync/azure-app-configuration/azure-app-configuration-sync-fns.ts b/backend/src/services/secret-sync/azure-app-configuration/azure-app-configuration-sync-fns.ts index dce509fac..7aa1c16ce 100644 --- a/backend/src/services/secret-sync/azure-app-configuration/azure-app-configuration-sync-fns.ts +++ b/backend/src/services/secret-sync/azure-app-configuration/azure-app-configuration-sync-fns.ts @@ -141,7 +141,7 @@ export const azureAppConfigurationSyncFactory = ({ for await (const key of Object.keys(azureAppConfigSecrets)) { // eslint-disable-next-line no-continue - if (!matchesSchema(key, secretSync.syncOptions.keySchema)) continue; + if (!matchesSchema(key, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema)) continue; const azureSecret = azureAppConfigSecrets[key]; if ( diff --git a/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-fns.ts b/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-fns.ts index fd1e2bd78..edc8af709 100644 --- a/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-fns.ts +++ b/backend/src/services/secret-sync/azure-key-vault/azure-key-vault-sync-fns.ts @@ -194,7 +194,7 @@ export const azureKeyVaultSyncFactory = ({ kmsService, appConnectionDAL }: TAzur for await (const deleteSecretKey of deleteSecrets.filter( (secret) => - matchesSchema(secret, secretSync.syncOptions.keySchema) && + matchesSchema(secret, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema) && !setSecrets.find((setSecret) => setSecret.key === secret) )) { await request.delete(`${secretSync.destinationConfig.vaultBaseUrl}/secrets/${deleteSecretKey}?api-version=7.3`, { diff --git a/backend/src/services/secret-sync/camunda/camunda-sync-fns.ts b/backend/src/services/secret-sync/camunda/camunda-sync-fns.ts index 256ae4644..516efae10 100644 --- a/backend/src/services/secret-sync/camunda/camunda-sync-fns.ts +++ b/backend/src/services/secret-sync/camunda/camunda-sync-fns.ts @@ -118,7 +118,7 @@ export const camundaSyncFactory = ({ kmsService, appConnectionDAL }: TCamundaSec for await (const secret of Object.keys(camundaSecrets)) { // eslint-disable-next-line no-continue - if (!matchesSchema(secret, secretSync.syncOptions.keySchema)) continue; + if (!matchesSchema(secret, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema)) continue; if (!(secret in secretMap) || !secretMap[secret].value) { try { diff --git a/backend/src/services/secret-sync/databricks/databricks-sync-fns.ts b/backend/src/services/secret-sync/databricks/databricks-sync-fns.ts index 11143e24d..175901323 100644 --- a/backend/src/services/secret-sync/databricks/databricks-sync-fns.ts +++ b/backend/src/services/secret-sync/databricks/databricks-sync-fns.ts @@ -117,7 +117,7 @@ export const databricksSyncFactory = ({ kmsService, appConnectionDAL }: TDatabri for await (const secret of databricksSecretKeys) { // eslint-disable-next-line no-continue - if (!matchesSchema(secret.key, secretSync.syncOptions.keySchema)) continue; + if (!matchesSchema(secret.key, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema)) continue; if (!(secret.key in secretMap)) { await deleteDatabricksSecrets({ diff --git a/backend/src/services/secret-sync/gcp/gcp-sync-fns.ts b/backend/src/services/secret-sync/gcp/gcp-sync-fns.ts index 97da66a48..6a45aab31 100644 --- a/backend/src/services/secret-sync/gcp/gcp-sync-fns.ts +++ b/backend/src/services/secret-sync/gcp/gcp-sync-fns.ts @@ -155,7 +155,7 @@ export const GcpSyncFns = { for await (const key of Object.keys(gcpSecrets)) { // eslint-disable-next-line no-continue - if (!matchesSchema(key, secretSync.syncOptions.keySchema)) continue; + if (!matchesSchema(key, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema)) continue; try { if (!(key in secretMap) || !secretMap[key].value) { diff --git a/backend/src/services/secret-sync/github/github-sync-fns.ts b/backend/src/services/secret-sync/github/github-sync-fns.ts index 952f4b512..f06f0cfc2 100644 --- a/backend/src/services/secret-sync/github/github-sync-fns.ts +++ b/backend/src/services/secret-sync/github/github-sync-fns.ts @@ -223,8 +223,9 @@ export const GithubSyncFns = { if (secretSync.syncOptions.disableSecretDeletion) return; for await (const encryptedSecret of encryptedSecrets) { - // eslint-disable-next-line no-continue - if (!matchesSchema(encryptedSecret.name, secretSync.syncOptions.keySchema)) continue; + if (!matchesSchema(encryptedSecret.name, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema)) + // eslint-disable-next-line no-continue + continue; if (!(encryptedSecret.name in secretMap)) { await deleteSecret(client, secretSync, encryptedSecret); diff --git a/backend/src/services/secret-sync/hc-vault/hc-vault-sync-fns.ts b/backend/src/services/secret-sync/hc-vault/hc-vault-sync-fns.ts index 6331cd91f..724eec7be 100644 --- a/backend/src/services/secret-sync/hc-vault/hc-vault-sync-fns.ts +++ b/backend/src/services/secret-sync/hc-vault/hc-vault-sync-fns.ts @@ -68,6 +68,7 @@ export const HCVaultSyncFns = { syncSecrets: async (secretSync: THCVaultSyncWithCredentials, secretMap: TSecretMap) => { const { connection, + environment, destinationConfig: { mount, path }, syncOptions: { disableSecretDeletion, keySchema } } = secretSync; @@ -97,7 +98,7 @@ export const HCVaultSyncFns = { for await (const [key] of Object.entries(variables)) { // eslint-disable-next-line no-continue - if (!matchesSchema(key, keySchema)) continue; + if (!matchesSchema(key, environment?.slug || "", keySchema)) continue; if (!(key in secretMap)) { delete variables[key]; diff --git a/backend/src/services/secret-sync/humanitec/humanitec-sync-fns.ts b/backend/src/services/secret-sync/humanitec/humanitec-sync-fns.ts index 2fcf488aa..ccb6ac2bc 100644 --- a/backend/src/services/secret-sync/humanitec/humanitec-sync-fns.ts +++ b/backend/src/services/secret-sync/humanitec/humanitec-sync-fns.ts @@ -200,8 +200,9 @@ export const HumanitecSyncFns = { if (secretSync.syncOptions.disableSecretDeletion) return; for await (const humanitecSecret of humanitecSecrets) { - // eslint-disable-next-line no-continue - if (!matchesSchema(humanitecSecret.key, secretSync.syncOptions.keySchema)) continue; + if (!matchesSchema(humanitecSecret.key, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema)) + // eslint-disable-next-line no-continue + continue; if (!secretMap[humanitecSecret.key]) { await deleteSecret(secretSync, humanitecSecret); diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts index dbf3a3699..ba3a79c50 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -1,5 +1,5 @@ import { AxiosError } from "axios"; -import RE2 from "re2"; +import handlebars from "handlebars"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { OCI_VAULT_SYNC_LIST_OPTION, OCIVaultSyncFns } from "@app/ee/services/secret-sync/oci-vault"; @@ -68,13 +68,17 @@ type TSyncSecretDeps = { }; // Add schema to secret keys -const addSchema = (unprocessedSecretMap: TSecretMap, schema?: string): TSecretMap => { +const addSchema = (unprocessedSecretMap: TSecretMap, environment: string, schema?: string): TSecretMap => { if (!schema) return unprocessedSecretMap; const processedSecretMap: TSecretMap = {}; for (const [key, value] of Object.entries(unprocessedSecretMap)) { - const newKey = new RE2("{{secretKey}}").replace(schema, key); + const newKey = handlebars.compile(schema)({ + secretKey: key, + environment + }); + processedSecretMap[newKey] = value; } @@ -82,10 +86,17 @@ const addSchema = (unprocessedSecretMap: TSecretMap, schema?: string): TSecretMa }; // Strip schema from secret keys -const stripSchema = (unprocessedSecretMap: TSecretMap, schema?: string): TSecretMap => { +const stripSchema = (unprocessedSecretMap: TSecretMap, environment: string, schema?: string): TSecretMap => { if (!schema) return unprocessedSecretMap; - const [prefix, suffix] = schema.split("{{secretKey}}"); + const compiledSchemaPattern = handlebars.compile(schema)({ + secretKey: "{{secretKey}}", // Keep secretKey + environment + }); + + const parts = compiledSchemaPattern.split("{{secretKey}}"); + const prefix = parts[0]; + const suffix = parts[parts.length - 1]; const strippedMap: TSecretMap = {}; @@ -103,21 +114,40 @@ const stripSchema = (unprocessedSecretMap: TSecretMap, schema?: string): TSecret }; // Checks if a key matches a schema -export const matchesSchema = (key: string, schema?: string): boolean => { +export const matchesSchema = (key: string, environment: string, schema?: string): boolean => { if (!schema) return true; - const [prefix, suffix] = schema.split("{{secretKey}}"); - if (prefix === undefined || suffix === undefined) return true; + const compiledSchemaPattern = handlebars.compile(schema)({ + secretKey: "{{secretKey}}", // Keep secretKey + environment + }); - return key.startsWith(prefix) && key.endsWith(suffix); + // This edge-case shouldn't be possible + if (!compiledSchemaPattern.includes("{{secretKey}}")) { + return key === compiledSchemaPattern; + } + + const parts = compiledSchemaPattern.split("{{secretKey}}"); + const prefix = parts[0]; + const suffix = parts[parts.length - 1]; + + if (prefix === "" && suffix === "") return true; + + // If prefix is empty, key must end with suffix + if (prefix === "") return key.endsWith(suffix); + + // If suffix is empty, key must start with prefix + if (suffix === "") return key.startsWith(prefix); + + return key.startsWith(prefix) && key.endsWith(suffix) && key.length >= prefix.length + suffix.length; }; // Filter only for secrets with keys that match the schema -const filterForSchema = (secretMap: TSecretMap, schema?: string): TSecretMap => { +const filterForSchema = (secretMap: TSecretMap, environment: string, schema?: string): TSecretMap => { const filteredMap: TSecretMap = {}; for (const [key, value] of Object.entries(secretMap)) { - if (matchesSchema(key, schema)) { + if (matchesSchema(key, environment, schema)) { filteredMap[key] = value; } } @@ -131,7 +161,7 @@ export const SecretSyncFns = { secretMap: TSecretMap, { kmsService, appConnectionDAL }: TSyncSecretDeps ): Promise => { - const schemaSecretMap = addSchema(secretMap, secretSync.syncOptions.keySchema); + const schemaSecretMap = addSchema(secretMap, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema); switch (secretSync.destination) { case SecretSync.AWSParameterStore: @@ -255,14 +285,16 @@ export const SecretSyncFns = { ); } - return stripSchema(filterForSchema(secretMap), secretSync.syncOptions.keySchema); + const filtered = filterForSchema(secretMap, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema); + const stripped = stripSchema(filtered, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema); + return stripped; }, removeSecrets: ( secretSync: TSecretSyncWithCredentials, secretMap: TSecretMap, { kmsService, appConnectionDAL }: TSyncSecretDeps ): Promise => { - const schemaSecretMap = addSchema(secretMap, secretSync.syncOptions.keySchema); + const schemaSecretMap = addSchema(secretMap, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema); switch (secretSync.destination) { case SecretSync.AWSParameterStore: diff --git a/backend/src/services/secret-sync/secret-sync-schemas.ts b/backend/src/services/secret-sync/secret-sync-schemas.ts index 80e96bf8b..3622ef3d0 100644 --- a/backend/src/services/secret-sync/secret-sync-schemas.ts +++ b/backend/src/services/secret-sync/secret-sync-schemas.ts @@ -28,10 +28,30 @@ const BaseSyncOptionsSchema = ({ keySchema: z .string() .optional() - .refine((val) => !val || new RE2(/^(?:[a-zA-Z0-9_\-/]*)(?:\{\{secretKey\}\})(?:[a-zA-Z0-9_\-/]*)$/).test(val), { - message: - "Key schema must include one {{secretKey}} and only contain letters, numbers, dashes, underscores, slashes, and the {{secretKey}} placeholder." - }) + .refine( + (val) => { + if (!val) return true; + + const allowedOptionalPlaceholders = ["{{environment}}"]; + + const allowedPlaceholdersRegexPart = ["{{secretKey}}", ...allowedOptionalPlaceholders] + .map((p) => p.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")) // Escape regex special characters + .join("|"); + + const allowedContentRegex = new RE2(`^([a-zA-Z0-9_\\-/]|${allowedPlaceholdersRegexPart})*$`); + const contentIsValid = allowedContentRegex.test(val); + + // Check if {{secretKey}} is present + const secretKeyRegex = new RE2(/\{\{secretKey\}\}/); + const secretKeyIsPresent = secretKeyRegex.test(val); + + return contentIsValid && secretKeyIsPresent; + }, + { + message: + "Key schema must include exactly one {{secretKey}} placeholder. It can also include {{environment}} placeholders. Only alphanumeric characters (a-z, A-Z, 0-9), dashes (-), underscores (_), and slashes (/) are allowed besides the placeholders." + } + ) .describe(SecretSyncs.SYNC_OPTIONS(destination).keySchema), disableSecretDeletion: z.boolean().optional().describe(SecretSyncs.SYNC_OPTIONS(destination).disableSecretDeletion) }); diff --git a/backend/src/services/secret-sync/teamcity/teamcity-sync-fns.ts b/backend/src/services/secret-sync/teamcity/teamcity-sync-fns.ts index 0afe29beb..28ef2d0d3 100644 --- a/backend/src/services/secret-sync/teamcity/teamcity-sync-fns.ts +++ b/backend/src/services/secret-sync/teamcity/teamcity-sync-fns.ts @@ -127,7 +127,7 @@ export const TeamCitySyncFns = { for await (const [key, variable] of Object.entries(variables)) { // eslint-disable-next-line no-continue - if (!matchesSchema(key, secretSync.syncOptions.keySchema)) continue; + if (!matchesSchema(key, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema)) continue; if (!(key in secretMap)) { try { diff --git a/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-fns.ts b/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-fns.ts index a58ec213c..cb546ba63 100644 --- a/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-fns.ts +++ b/backend/src/services/secret-sync/terraform-cloud/terraform-cloud-sync-fns.ts @@ -232,8 +232,11 @@ export const TerraformCloudSyncFns = { if (secretSync.syncOptions.disableSecretDeletion) return; for (const terraformCloudVariable of terraformCloudVariables) { - // eslint-disable-next-line no-continue - if (!matchesSchema(terraformCloudVariable.key, secretSync.syncOptions.keySchema)) continue; + if ( + !matchesSchema(terraformCloudVariable.key, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema) + ) + // eslint-disable-next-line no-continue + continue; if (!Object.prototype.hasOwnProperty.call(secretMap, terraformCloudVariable.key)) { await deleteVariable(secretSync, terraformCloudVariable); diff --git a/backend/src/services/secret-sync/vercel/vercel-sync-fns.ts b/backend/src/services/secret-sync/vercel/vercel-sync-fns.ts index 90e9327e5..b5ea98265 100644 --- a/backend/src/services/secret-sync/vercel/vercel-sync-fns.ts +++ b/backend/src/services/secret-sync/vercel/vercel-sync-fns.ts @@ -291,8 +291,9 @@ export const VercelSyncFns = { if (secretSync.syncOptions.disableSecretDeletion) return; for await (const vercelSecret of vercelSecrets) { - // eslint-disable-next-line no-continue - if (!matchesSchema(vercelSecret.key, secretSync.syncOptions.keySchema)) continue; + if (!matchesSchema(vercelSecret.key, secretSync.environment?.slug || "", secretSync.syncOptions.keySchema)) + // eslint-disable-next-line no-continue + continue; if (!secretMap[vercelSecret.key]) { await deleteSecret(secretSync, vercelSecret); diff --git a/backend/src/services/secret-sync/windmill/windmill-sync-fns.ts b/backend/src/services/secret-sync/windmill/windmill-sync-fns.ts index a09706581..b5e11c957 100644 --- a/backend/src/services/secret-sync/windmill/windmill-sync-fns.ts +++ b/backend/src/services/secret-sync/windmill/windmill-sync-fns.ts @@ -128,6 +128,7 @@ export const WindmillSyncFns = { syncSecrets: async (secretSync: TWindmillSyncWithCredentials, secretMap: TSecretMap) => { const { connection, + environment, destinationConfig: { path }, syncOptions: { disableSecretDeletion, keySchema } } = secretSync; @@ -171,7 +172,7 @@ export const WindmillSyncFns = { for await (const [key, variable] of Object.entries(variables)) { // eslint-disable-next-line no-continue - if (!matchesSchema(key, keySchema)) continue; + if (!matchesSchema(key, environment?.slug || "", keySchema)) continue; if (!(key in secretMap)) { try { diff --git a/backend/src/services/user/user-dal.ts b/backend/src/services/user/user-dal.ts index b5a29fc8c..0f623dff1 100644 --- a/backend/src/services/user/user-dal.ts +++ b/backend/src/services/user/user-dal.ts @@ -21,6 +21,11 @@ export const userDALFactory = (db: TDbClient) => { const findUserByUsername = async (username: string, tx?: Knex) => (tx || db)(TableName.Users).whereRaw('lower("username") = :username', { username: username.toLowerCase() }); + const findUserByEmail = async (email: string, tx?: Knex) => + (tx || db)(TableName.Users).whereRaw('lower("email") = :email', { email: email.toLowerCase() }).where({ + isEmailVerified: true + }); + const getUsersByFilter = async ({ limit, offset, @@ -234,6 +239,7 @@ export const userDALFactory = (db: TDbClient) => { findOneUserAction, createUserAction, getUsersByFilter, - findAllMyAccounts + findAllMyAccounts, + findUserByEmail }; }; diff --git a/cli/go.mod b/cli/go.mod index 229e37137..fc7322f61 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -14,7 +14,7 @@ require ( github.com/fatih/semgroup v1.2.0 github.com/gitleaks/go-gitdiff v0.9.1 github.com/h2non/filetype v1.1.3 - github.com/infisical/go-sdk v0.5.92 + github.com/infisical/go-sdk v0.5.95 github.com/infisical/infisical-kmip v0.3.5 github.com/mattn/go-isatty v0.0.20 github.com/muesli/ansi v0.0.0-20221106050444-61f0cd9a192a diff --git a/cli/go.sum b/cli/go.sum index d253c70d5..aa8dc1f61 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -294,6 +294,10 @@ github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7P github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/infisical/go-sdk v0.5.92 h1:PoCnVndrd6Dbkipuxl9fFiwlD5vCKsabtQo09mo8lUE= github.com/infisical/go-sdk v0.5.92/go.mod h1:ExjqFLRz7LSpZpGluqDLvFl6dFBLq5LKyLW7GBaMAIs= +github.com/infisical/go-sdk v0.5.94 h1:wKBj+KpJEe+ZzOJ7koXQZDR0dLL9bt0Kqgf/1q+7tG4= +github.com/infisical/go-sdk v0.5.94/go.mod h1:ExjqFLRz7LSpZpGluqDLvFl6dFBLq5LKyLW7GBaMAIs= +github.com/infisical/go-sdk v0.5.95 h1:so0YwPofbT7j6Ao8Xcxee/o3ia33meuEVDU2vWr9yfs= +github.com/infisical/go-sdk v0.5.95/go.mod h1:ExjqFLRz7LSpZpGluqDLvFl6dFBLq5LKyLW7GBaMAIs= github.com/infisical/infisical-kmip v0.3.5 h1:QM3s0e18B+mYv3a9HQNjNAlbwZJBzXq5BAJM2scIeiE= github.com/infisical/infisical-kmip v0.3.5/go.mod h1:bO1M4YtKyutNg1bREPmlyZspC5duSR7hyQ3lPmLzrIs= github.com/jedib0t/go-pretty v4.3.0+incompatible h1:CGs8AVhEKg/n9YbUenWmNStRW2PHJzaeDodcfvRAbIo= diff --git a/cli/packages/cmd/gateway.go b/cli/packages/cmd/gateway.go index 51565b6fd..90710154e 100644 --- a/cli/packages/cmd/gateway.go +++ b/cli/packages/cmd/gateway.go @@ -7,16 +7,76 @@ import ( "os/exec" "os/signal" "runtime" + "sync/atomic" "syscall" "time" + "github.com/Infisical/infisical-merge/packages/api" + "github.com/Infisical/infisical-merge/packages/config" "github.com/Infisical/infisical-merge/packages/gateway" "github.com/Infisical/infisical-merge/packages/util" + infisicalSdk "github.com/infisical/go-sdk" + "github.com/pkg/errors" "github.com/posthog/posthog-go" "github.com/rs/zerolog/log" "github.com/spf13/cobra" ) +func getInfisicalSdkInstance(cmd *cobra.Command) (infisicalSdk.InfisicalClientInterface, context.CancelFunc, error) { + + ctx, cancel := context.WithCancel(cmd.Context()) + infisicalClient := infisicalSdk.NewInfisicalClient(ctx, infisicalSdk.Config{ + SiteUrl: config.INFISICAL_URL, + UserAgent: api.USER_AGENT, + }) + + token, err := util.GetInfisicalToken(cmd) + if err != nil { + cancel() + return nil, nil, err + } + + // if the --token param is set, we use it directly for authentication + if token != nil { + infisicalClient.Auth().SetAccessToken(token.Token) + return infisicalClient, cancel, nil + } + + // if the --token param is not set, we use the auth-method flag to determine the authentication method, and perform the appropriate login flow based on that + authMethod, err := cmd.Flags().GetString("auth-method") + if err != nil { + cancel() + return nil, nil, err + } + + authMethodValid, strategy := util.IsAuthMethodValid(authMethod, false) + if !authMethodValid { + util.PrintErrorMessageAndExit(fmt.Sprintf("Invalid login method: %s", authMethod)) + } + + sdkAuthenticator := util.NewSdkAuthenticator(infisicalClient, cmd) + + authStrategies := map[util.AuthStrategyType]func() (credential infisicalSdk.MachineIdentityCredential, e error){ + util.AuthStrategy.UNIVERSAL_AUTH: sdkAuthenticator.HandleUniversalAuthLogin, + util.AuthStrategy.KUBERNETES_AUTH: sdkAuthenticator.HandleKubernetesAuthLogin, + util.AuthStrategy.AZURE_AUTH: sdkAuthenticator.HandleAzureAuthLogin, + util.AuthStrategy.GCP_ID_TOKEN_AUTH: sdkAuthenticator.HandleGcpIdTokenAuthLogin, + util.AuthStrategy.GCP_IAM_AUTH: sdkAuthenticator.HandleGcpIamAuthLogin, + util.AuthStrategy.AWS_IAM_AUTH: sdkAuthenticator.HandleAwsIamAuthLogin, + util.AuthStrategy.OIDC_AUTH: sdkAuthenticator.HandleOidcAuthLogin, + util.AuthStrategy.JWT_AUTH: sdkAuthenticator.HandleJwtAuthLogin, + } + + _, err = authStrategies[strategy]() + + if err != nil { + cancel() + return nil, nil, err + } + + return infisicalClient, cancel, nil +} + var gatewayCmd = &cobra.Command{ Use: "gateway", Short: "Run the Infisical gateway or manage its systemd service", @@ -26,13 +86,18 @@ var gatewayCmd = &cobra.Command{ DisableFlagsInUseLine: true, Args: cobra.NoArgs, Run: func(cmd *cobra.Command, args []string) { - token, err := util.GetInfisicalToken(cmd) - if err != nil { - util.HandleError(err, "Unable to parse token flag") - } - if token == nil { - util.HandleError(fmt.Errorf("Token not found")) + infisicalClient, cancelSdk, err := getInfisicalSdkInstance(cmd) + if err != nil { + util.HandleError(err, "unable to get infisical client") + } + defer cancelSdk() + + var accessToken atomic.Value + accessToken.Store(infisicalClient.Auth().GetAccessToken()) + + if accessToken.Load().(string) == "" { + util.HandleError(errors.New("no access token found")) } Telemetry.CaptureEvent("cli-command:gateway", posthog.NewProperties().Set("version", util.CLI_VERSION)) @@ -41,13 +106,14 @@ var gatewayCmd = &cobra.Command{ signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) sigStopCh := make(chan bool, 1) - ctx, cancel := context.WithCancel(cmd.Context()) - defer cancel() + ctx, cancelCmd := context.WithCancel(cmd.Context()) + defer cancelCmd() go func() { <-sigCh close(sigStopCh) - cancel() + cancelCmd() + cancelSdk() // If we get a second signal, force exit <-sigCh @@ -55,6 +121,34 @@ var gatewayCmd = &cobra.Command{ os.Exit(1) }() + var gatewayInstance *gateway.Gateway + + // Token refresh goroutine - runs every 10 seconds + go func() { + tokenRefreshTicker := time.NewTicker(10 * time.Second) + defer tokenRefreshTicker.Stop() + + for { + select { + case <-tokenRefreshTicker.C: + if ctx.Err() != nil { + return + } + + newToken := infisicalClient.Auth().GetAccessToken() + if newToken != "" && newToken != accessToken.Load().(string) { + accessToken.Store(newToken) + if gatewayInstance != nil { + gatewayInstance.UpdateIdentityAccessToken(newToken) + } + } + + case <-ctx.Done(): + return + } + } + }() + // Main gateway retry loop with proper context handling retryTicker := time.NewTicker(5 * time.Second) defer retryTicker.Stop() @@ -64,7 +158,7 @@ var gatewayCmd = &cobra.Command{ log.Info().Msg("Shutting down gateway") return } - gatewayInstance, err := gateway.NewGateway(token.Token) + gatewayInstance, err := gateway.NewGateway(accessToken.Load().(string)) if err != nil { util.HandleError(err) } @@ -126,7 +220,7 @@ var gatewayInstallCmd = &cobra.Command{ } if token == nil { - util.HandleError(fmt.Errorf("Token not found")) + util.HandleError(errors.New("Token not found")) } domain, err := cmd.Flags().GetString("domain") @@ -183,7 +277,7 @@ var gatewayRelayCmd = &cobra.Command{ } if relayConfigFilePath == "" { - util.HandleError(fmt.Errorf("Missing config file")) + util.HandleError(errors.New("Missing config file")) } gatewayRelay, err := gateway.NewGatewayRelay(relayConfigFilePath) @@ -198,7 +292,19 @@ var gatewayRelayCmd = &cobra.Command{ } func init() { - gatewayCmd.Flags().String("token", "", "Connect with Infisical using machine identity access token") + gatewayCmd.Flags().String("token", "", "connect with Infisical using machine identity access token. if not provided, you must set the auth-method flag") + + gatewayCmd.Flags().String("auth-method", "", "login method [universal-auth, kubernetes, azure, gcp-id-token, gcp-iam, aws-iam, oidc-auth]. if not provided, you must set the token flag") + + gatewayCmd.Flags().String("client-id", "", "client id for universal auth") + gatewayCmd.Flags().String("client-secret", "", "client secret for universal auth") + + gatewayCmd.Flags().String("machine-identity-id", "", "machine identity id for kubernetes, azure, gcp-id-token, gcp-iam, and aws-iam auth methods") + gatewayCmd.Flags().String("service-account-token-path", "", "service account token path for kubernetes auth") + gatewayCmd.Flags().String("service-account-key-file-path", "", "service account key file path for GCP IAM auth") + + gatewayCmd.Flags().String("jwt", "", "JWT for jwt-based auth methods [oidc-auth, jwt-auth]") + gatewayInstallCmd.Flags().String("token", "", "Connect with Infisical using machine identity access token") gatewayInstallCmd.Flags().String("domain", "", "Domain of your self-hosted Infisical instance") diff --git a/cli/packages/cmd/kmip.go b/cli/packages/cmd/kmip.go index b0c397895..91335d122 100644 --- a/cli/packages/cmd/kmip.go +++ b/cli/packages/cmd/kmip.go @@ -49,13 +49,13 @@ func startKmipServer(cmd *cobra.Command, args []string) { var identityClientSecret string if strategy == util.AuthStrategy.UNIVERSAL_AUTH { - identityClientId, err = util.GetCmdFlagOrEnv(cmd, "identity-client-id", util.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_NAME) + identityClientId, err = util.GetCmdFlagOrEnv(cmd, "identity-client-id", []string{util.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_NAME}) if err != nil { util.HandleError(err, "Unable to parse identity client ID") } - identityClientSecret, err = util.GetCmdFlagOrEnv(cmd, "identity-client-secret", util.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_NAME) + identityClientSecret, err = util.GetCmdFlagOrEnv(cmd, "identity-client-secret", []string{util.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_NAME}) if err != nil { util.HandleError(err, "Unable to parse identity client secret") } diff --git a/cli/packages/cmd/login.go b/cli/packages/cmd/login.go index b0ce7564b..ef549aabe 100644 --- a/cli/packages/cmd/login.go +++ b/cli/packages/cmd/login.go @@ -49,97 +49,6 @@ type params struct { keyLength uint32 } -func handleUniversalAuthLogin(cmd *cobra.Command, infisicalClient infisicalSdk.InfisicalClientInterface) (credential infisicalSdk.MachineIdentityCredential, e error) { - - clientId, err := util.GetCmdFlagOrEnv(cmd, "client-id", util.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_NAME) - - if err != nil { - return infisicalSdk.MachineIdentityCredential{}, err - } - - clientSecret, err := util.GetCmdFlagOrEnv(cmd, "client-secret", util.INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_NAME) - if err != nil { - return infisicalSdk.MachineIdentityCredential{}, err - } - - return infisicalClient.Auth().UniversalAuthLogin(clientId, clientSecret) -} - -func handleKubernetesAuthLogin(cmd *cobra.Command, infisicalClient infisicalSdk.InfisicalClientInterface) (credential infisicalSdk.MachineIdentityCredential, e error) { - - identityId, err := util.GetCmdFlagOrEnv(cmd, "machine-identity-id", util.INFISICAL_MACHINE_IDENTITY_ID_NAME) - if err != nil { - return infisicalSdk.MachineIdentityCredential{}, err - } - - serviceAccountTokenPath, err := util.GetCmdFlagOrEnv(cmd, "service-account-token-path", util.INFISICAL_KUBERNETES_SERVICE_ACCOUNT_TOKEN_NAME) - if err != nil { - return infisicalSdk.MachineIdentityCredential{}, err - } - - return infisicalClient.Auth().KubernetesAuthLogin(identityId, serviceAccountTokenPath) -} - -func handleAzureAuthLogin(cmd *cobra.Command, infisicalClient infisicalSdk.InfisicalClientInterface) (credential infisicalSdk.MachineIdentityCredential, e error) { - - identityId, err := util.GetCmdFlagOrEnv(cmd, "machine-identity-id", util.INFISICAL_MACHINE_IDENTITY_ID_NAME) - if err != nil { - return infisicalSdk.MachineIdentityCredential{}, err - } - - return infisicalClient.Auth().AzureAuthLogin(identityId, "") -} - -func handleGcpIdTokenAuthLogin(cmd *cobra.Command, infisicalClient infisicalSdk.InfisicalClientInterface) (credential infisicalSdk.MachineIdentityCredential, e error) { - - identityId, err := util.GetCmdFlagOrEnv(cmd, "machine-identity-id", util.INFISICAL_MACHINE_IDENTITY_ID_NAME) - if err != nil { - return infisicalSdk.MachineIdentityCredential{}, err - } - - return infisicalClient.Auth().GcpIdTokenAuthLogin(identityId) -} - -func handleGcpIamAuthLogin(cmd *cobra.Command, infisicalClient infisicalSdk.InfisicalClientInterface) (credential infisicalSdk.MachineIdentityCredential, e error) { - - identityId, err := util.GetCmdFlagOrEnv(cmd, "machine-identity-id", util.INFISICAL_MACHINE_IDENTITY_ID_NAME) - if err != nil { - return infisicalSdk.MachineIdentityCredential{}, err - } - - serviceAccountKeyFilePath, err := util.GetCmdFlagOrEnv(cmd, "service-account-key-file-path", util.INFISICAL_GCP_IAM_SERVICE_ACCOUNT_KEY_FILE_PATH_NAME) - if err != nil { - return infisicalSdk.MachineIdentityCredential{}, err - } - - return infisicalClient.Auth().GcpIamAuthLogin(identityId, serviceAccountKeyFilePath) -} - -func handleAwsIamAuthLogin(cmd *cobra.Command, infisicalClient infisicalSdk.InfisicalClientInterface) (credential infisicalSdk.MachineIdentityCredential, e error) { - - identityId, err := util.GetCmdFlagOrEnv(cmd, "machine-identity-id", util.INFISICAL_MACHINE_IDENTITY_ID_NAME) - if err != nil { - return infisicalSdk.MachineIdentityCredential{}, err - } - - return infisicalClient.Auth().AwsIamAuthLogin(identityId) -} - -func handleOidcAuthLogin(cmd *cobra.Command, infisicalClient infisicalSdk.InfisicalClientInterface) (credential infisicalSdk.MachineIdentityCredential, e error) { - - identityId, err := util.GetCmdFlagOrEnv(cmd, "machine-identity-id", util.INFISICAL_MACHINE_IDENTITY_ID_NAME) - if err != nil { - return infisicalSdk.MachineIdentityCredential{}, err - } - - jwt, err := util.GetCmdFlagOrEnv(cmd, "oidc-jwt", util.INFISICAL_OIDC_AUTH_JWT_NAME) - if err != nil { - return infisicalSdk.MachineIdentityCredential{}, err - } - - return infisicalClient.Auth().OidcAuthLogin(identityId, jwt) -} - func formatAuthMethod(authMethod string) string { return strings.ReplaceAll(authMethod, "-", " ") } @@ -154,8 +63,22 @@ var loginCmd = &cobra.Command{ Use: "login", Short: "Login into your Infisical account", DisableFlagsInUseLine: true, - Run: func(cmd *cobra.Command, args []string) { + PreRunE: func(cmd *cobra.Command, args []string) error { + // daniel: oidc-jwt is deprecated in favor of `jwt`. we backfill the `jwt` flag with the value of `oidc-jwt` if it's set. + if cmd.Flags().Changed("oidc-jwt") && !cmd.Flags().Changed("jwt") { + oidcJWT, err := cmd.Flags().GetString("oidc-jwt") + if err != nil { + return err + } + err = cmd.Flags().Set("jwt", oidcJWT) + if err != nil { + return err + } + } + return nil + }, + Run: func(cmd *cobra.Command, args []string) { presetDomain := config.INFISICAL_URL clearSelfHostedDomains, err := cmd.Flags().GetBool("clear-domains") @@ -310,17 +233,19 @@ var loginCmd = &cobra.Command{ Telemetry.CaptureEvent("cli-command:login", posthog.NewProperties().Set("infisical-backend", config.INFISICAL_URL).Set("version", util.CLI_VERSION)) } else { - authStrategies := map[util.AuthStrategyType]func(cmd *cobra.Command, infisicalClient infisicalSdk.InfisicalClientInterface) (credential infisicalSdk.MachineIdentityCredential, e error){ - util.AuthStrategy.UNIVERSAL_AUTH: handleUniversalAuthLogin, - util.AuthStrategy.KUBERNETES_AUTH: handleKubernetesAuthLogin, - util.AuthStrategy.AZURE_AUTH: handleAzureAuthLogin, - util.AuthStrategy.GCP_ID_TOKEN_AUTH: handleGcpIdTokenAuthLogin, - util.AuthStrategy.GCP_IAM_AUTH: handleGcpIamAuthLogin, - util.AuthStrategy.AWS_IAM_AUTH: handleAwsIamAuthLogin, - util.AuthStrategy.OIDC_AUTH: handleOidcAuthLogin, + sdkAuthenticator := util.NewSdkAuthenticator(infisicalClient, cmd) + + authStrategies := map[util.AuthStrategyType]func() (credential infisicalSdk.MachineIdentityCredential, e error){ + util.AuthStrategy.UNIVERSAL_AUTH: sdkAuthenticator.HandleUniversalAuthLogin, + util.AuthStrategy.KUBERNETES_AUTH: sdkAuthenticator.HandleKubernetesAuthLogin, + util.AuthStrategy.AZURE_AUTH: sdkAuthenticator.HandleAzureAuthLogin, + util.AuthStrategy.GCP_ID_TOKEN_AUTH: sdkAuthenticator.HandleGcpIdTokenAuthLogin, + util.AuthStrategy.GCP_IAM_AUTH: sdkAuthenticator.HandleGcpIamAuthLogin, + util.AuthStrategy.AWS_IAM_AUTH: sdkAuthenticator.HandleAwsIamAuthLogin, + util.AuthStrategy.OIDC_AUTH: sdkAuthenticator.HandleOidcAuthLogin, } - credential, err := authStrategies[strategy](cmd, infisicalClient) + credential, err := authStrategies[strategy]() if err != nil { euErrorMessage := "" @@ -518,14 +443,18 @@ func init() { rootCmd.AddCommand(loginCmd) loginCmd.Flags().Bool("clear-domains", false, "clear all self-hosting domains from the config file") loginCmd.Flags().BoolP("interactive", "i", false, "login via the command line") - loginCmd.Flags().String("method", "user", "login method [user, universal-auth]") loginCmd.Flags().Bool("plain", false, "only output the token without any formatting") + loginCmd.Flags().String("method", "user", "login method [user, universal-auth, kubernetes, azure, gcp-id-token, gcp-iam, aws-iam, oidc-auth]") loginCmd.Flags().String("client-id", "", "client id for universal auth") loginCmd.Flags().String("client-secret", "", "client secret for universal auth") loginCmd.Flags().String("machine-identity-id", "", "machine identity id for kubernetes, azure, gcp-id-token, gcp-iam, and aws-iam auth methods") loginCmd.Flags().String("service-account-token-path", "", "service account token path for kubernetes auth") loginCmd.Flags().String("service-account-key-file-path", "", "service account key file path for GCP IAM auth") - loginCmd.Flags().String("oidc-jwt", "", "JWT for OIDC authentication") + loginCmd.Flags().String("jwt", "", "jwt for jwt-based auth methods [oidc-auth, jwt-auth]") + loginCmd.Flags().String("oidc-jwt", "", "JWT for OIDC authentication. Deprecated, use --jwt instead") + + loginCmd.Flags().MarkDeprecated("oidc-jwt", "use --jwt instead") + } func DomainOverridePrompt() (bool, error) { diff --git a/cli/packages/gateway/connection.go b/cli/packages/gateway/connection.go index 58a0503ff..46d194c96 100644 --- a/cli/packages/gateway/connection.go +++ b/cli/packages/gateway/connection.go @@ -4,11 +4,19 @@ import ( "bufio" "bytes" "context" + "crypto/tls" + "crypto/x509" + "encoding/base64" "errors" + "fmt" "io" "net" + "net/http" + "net/url" + "os" "strings" "sync" + "time" "github.com/quic-go/quic-go" "github.com/rs/zerolog/log" @@ -89,6 +97,39 @@ func handleStream(stream quic.Stream, quicConn quic.Connection) { CopyDataFromQuicToTcp(stream, destTarget) log.Info().Msgf("Ending secure transmission between %s->%s", quicConn.LocalAddr().String(), destTarget.LocalAddr().String()) return + + case "FORWARD-HTTP": + argParts := bytes.Split(args, []byte(" ")) + if len(argParts) == 0 { + log.Error().Msg("FORWARD-HTTP requires target URL") + return + } + + targetURL := string(argParts[0]) + + if !isValidURL(targetURL) { + log.Error().Msgf("Invalid target URL: %s", targetURL) + return + } + + // Parse optional parameters + var caCertB64, verifyParam string + for _, part := range argParts[1:] { + partStr := string(part) + if strings.HasPrefix(partStr, "ca=") { + caCertB64 = strings.TrimPrefix(partStr, "ca=") + } else if strings.HasPrefix(partStr, "verify=") { + verifyParam = strings.TrimPrefix(partStr, "verify=") + } + } + + log.Info().Msgf("Starting HTTP proxy to: %s", targetURL) + + if err := handleHTTPProxy(stream, reader, targetURL, caCertB64, verifyParam); err != nil { + log.Error().Msgf("HTTP proxy error: %v", err) + } + return + case "PING": if _, err := stream.Write([]byte("PONG\n")); err != nil { log.Error().Msgf("Error writing PONG response: %v", err) @@ -100,11 +141,131 @@ func handleStream(stream quic.Stream, quicConn quic.Connection) { } } } +func handleHTTPProxy(stream quic.Stream, reader *bufio.Reader, targetURL string, caCertB64 string, verifyParam string) error { + transport := &http.Transport{ + DisableKeepAlives: false, + MaxIdleConns: 10, + IdleConnTimeout: 30 * time.Second, + } + + if strings.HasPrefix(targetURL, "https://") { + tlsConfig := &tls.Config{} + + if caCertB64 != "" { + caCert, err := base64.StdEncoding.DecodeString(caCertB64) + if err == nil { + caCertPool := x509.NewCertPool() + if caCertPool.AppendCertsFromPEM(caCert) { + tlsConfig.RootCAs = caCertPool + log.Info().Msg("Using provided CA certificate from gateway client") + } else { + log.Error().Msg("Failed to parse provided CA certificate") + } + } else { + log.Error().Msgf("Failed to decode CA certificate: %v", err) + } + } + + // set certificate verification based on what the gateway client sent + if verifyParam != "" { + tlsConfig.InsecureSkipVerify = verifyParam == "false" + log.Info().Msgf("TLS verification set to: %s", verifyParam) + } + + transport.TLSClientConfig = tlsConfig + } + + // read and parse the http request from the stream + req, err := http.ReadRequest(reader) + if err != nil { + return fmt.Errorf("failed to read HTTP request: %v", err) + } + + actionHeader := req.Header.Get("x-infisical-action") + if actionHeader != "" { + + if actionHeader == "inject-k8s-sa-auth-token" { + token, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/token") + + if err != nil { + stream.Write([]byte(buildHttpInternalServerError("failed to read k8s sa auth token"))) + return fmt.Errorf("failed to read k8s sa auth token: %v", err) + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", string(token))) + log.Info().Msgf("Injected gateway k8s SA auth token in request to %s", targetURL) + } + + req.Header.Del("x-infisical-action") + } + + var targetFullURL string + if strings.HasPrefix(targetURL, "http://") || strings.HasPrefix(targetURL, "https://") { + baseURL := strings.TrimSuffix(targetURL, "/") + targetFullURL = baseURL + req.URL.Path + if req.URL.RawQuery != "" { + targetFullURL += "?" + req.URL.RawQuery + } + } else { + baseURL := strings.TrimSuffix("http://"+targetURL, "/") + targetFullURL = baseURL + req.URL.Path + if req.URL.RawQuery != "" { + targetFullURL += "?" + req.URL.RawQuery + } + } + + // create the request to the target + proxyReq, err := http.NewRequest(req.Method, targetFullURL, req.Body) + proxyReq.Header = req.Header.Clone() + if err != nil { + return fmt.Errorf("failed to create proxy request: %v", err) + } + + log.Info().Msgf("Proxying %s %s to %s", req.Method, req.URL.Path, targetFullURL) + + client := &http.Client{ + Transport: transport, + Timeout: 30 * time.Second, + } + + // make the request to the target + resp, err := client.Do(proxyReq) + if err != nil { + stream.Write([]byte(buildHttpInternalServerError(fmt.Sprintf("failed to reach target due to networking error: %s", err.Error())))) + return fmt.Errorf("failed to reach target due to networking error: %v", err) + } + defer resp.Body.Close() + + // Write the entire response (status line, headers, body) to the stream + // http.Response.Write handles this for "Connection: close" correctly. + // For other connection tokens, manual removal might be needed if they cause issues with QUIC. + // For a simple proxy, this is generally sufficient. + resp.Header.Del("Connection") // Good practice for proxies + + log.Info().Msgf("Writing response to stream: %s", resp.Status) + if err := resp.Write(stream); err != nil { + // If writing the response fails, the connection to the client might be broken. + // Logging the error is important. The original error will be returned. + log.Error().Err(err).Msg("Failed to write response to stream") + return fmt.Errorf("failed to write response to stream: %w", err) + } + + return nil +} + +func buildHttpInternalServerError(message string) string { + return fmt.Sprintf("HTTP/1.1 500 Internal Server Error\r\nContent-Type: application/json\r\n\r\n{\"message\": \"gateway: %s\"}", message) +} type CloseWrite interface { CloseWrite() error } +func isValidURL(str string) bool { + u, err := url.Parse(str) + return err == nil && u.Scheme != "" && u.Host != "" +} + func CopyDataFromQuicToTcp(quicStream quic.Stream, tcpConn net.Conn) { // Create a WaitGroup to wait for both copy operations var wg sync.WaitGroup diff --git a/cli/packages/gateway/gateway.go b/cli/packages/gateway/gateway.go index d0a25ca9c..eb0c72d5d 100644 --- a/cli/packages/gateway/gateway.go +++ b/cli/packages/gateway/gateway.go @@ -54,6 +54,10 @@ func NewGateway(identityToken string) (Gateway, error) { }, nil } +func (g *Gateway) UpdateIdentityAccessToken(accessToken string) { + g.httpClient.SetAuthToken(accessToken) +} + func (g *Gateway) ConnectWithRelay() error { relayDetails, err := api.CallRegisterGatewayIdentityV1(g.httpClient) if err != nil { diff --git a/cli/packages/util/auth.go b/cli/packages/util/auth.go index b54bde45b..eaf7cecc1 100644 --- a/cli/packages/util/auth.go +++ b/cli/packages/util/auth.go @@ -5,7 +5,9 @@ import ( "os" "os/exec" + infisicalSdk "github.com/infisical/go-sdk" "github.com/rs/zerolog/log" + "github.com/spf13/cobra" ) type AuthStrategyType string @@ -18,6 +20,7 @@ var AuthStrategy = struct { GCP_IAM_AUTH AuthStrategyType AWS_IAM_AUTH AuthStrategyType OIDC_AUTH AuthStrategyType + JWT_AUTH AuthStrategyType }{ UNIVERSAL_AUTH: "universal-auth", KUBERNETES_AUTH: "kubernetes", @@ -26,6 +29,7 @@ var AuthStrategy = struct { GCP_IAM_AUTH: "gcp-iam", AWS_IAM_AUTH: "aws-iam", OIDC_AUTH: "oidc-auth", + JWT_AUTH: "jwt-auth", } var AVAILABLE_AUTH_STRATEGIES = []AuthStrategyType{ @@ -36,6 +40,7 @@ var AVAILABLE_AUTH_STRATEGIES = []AuthStrategyType{ AuthStrategy.GCP_IAM_AUTH, AuthStrategy.AWS_IAM_AUTH, AuthStrategy.OIDC_AUTH, + AuthStrategy.JWT_AUTH, } func IsAuthMethodValid(authMethod string, allowUserAuth bool) (isValid bool, strategy AuthStrategyType) { @@ -84,3 +89,120 @@ func EstablishUserLoginSession() LoggedInUserDetails { return loggedInUserDetails } + +type SdkAuthenticator struct { + infisicalClient infisicalSdk.InfisicalClientInterface + cmd *cobra.Command +} + +func NewSdkAuthenticator(infisicalClient infisicalSdk.InfisicalClientInterface, cmd *cobra.Command) *SdkAuthenticator { + return &SdkAuthenticator{ + infisicalClient: infisicalClient, + cmd: cmd, + } +} +func (a *SdkAuthenticator) HandleUniversalAuthLogin() (credential infisicalSdk.MachineIdentityCredential, e error) { + + clientId, err := GetCmdFlagOrEnv(a.cmd, "client-id", []string{INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_NAME}) + + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + clientSecret, err := GetCmdFlagOrEnv(a.cmd, "client-secret", []string{INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_NAME}) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + return a.infisicalClient.Auth().UniversalAuthLogin(clientId, clientSecret) +} + +func (a *SdkAuthenticator) HandleJwtAuthLogin() (credential infisicalSdk.MachineIdentityCredential, e error) { + + identityId, err := GetCmdFlagOrEnv(a.cmd, "machine-identity-id", []string{INFISICAL_MACHINE_IDENTITY_ID_NAME}) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + jwt, err := GetCmdFlagOrEnv(a.cmd, "jwt", []string{INFISICAL_JWT_NAME}) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + return a.infisicalClient.Auth().JwtAuthLogin(identityId, jwt) +} + +func (a *SdkAuthenticator) HandleKubernetesAuthLogin() (credential infisicalSdk.MachineIdentityCredential, e error) { + + identityId, err := GetCmdFlagOrEnv(a.cmd, "machine-identity-id", []string{INFISICAL_MACHINE_IDENTITY_ID_NAME}) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + serviceAccountTokenPath, err := GetCmdFlagOrEnv(a.cmd, "service-account-token-path", []string{INFISICAL_KUBERNETES_SERVICE_ACCOUNT_TOKEN_NAME}) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + return a.infisicalClient.Auth().KubernetesAuthLogin(identityId, serviceAccountTokenPath) +} + +func (a *SdkAuthenticator) HandleAzureAuthLogin() (credential infisicalSdk.MachineIdentityCredential, e error) { + + identityId, err := GetCmdFlagOrEnv(a.cmd, "machine-identity-id", []string{INFISICAL_MACHINE_IDENTITY_ID_NAME}) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + return a.infisicalClient.Auth().AzureAuthLogin(identityId, "") +} + +func (a *SdkAuthenticator) HandleGcpIdTokenAuthLogin() (credential infisicalSdk.MachineIdentityCredential, e error) { + + identityId, err := GetCmdFlagOrEnv(a.cmd, "machine-identity-id", []string{INFISICAL_MACHINE_IDENTITY_ID_NAME}) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + return a.infisicalClient.Auth().GcpIdTokenAuthLogin(identityId) +} + +func (a *SdkAuthenticator) HandleGcpIamAuthLogin() (credential infisicalSdk.MachineIdentityCredential, e error) { + + identityId, err := GetCmdFlagOrEnv(a.cmd, "machine-identity-id", []string{INFISICAL_MACHINE_IDENTITY_ID_NAME}) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + serviceAccountKeyFilePath, err := GetCmdFlagOrEnv(a.cmd, "service-account-key-file-path", []string{INFISICAL_GCP_IAM_SERVICE_ACCOUNT_KEY_FILE_PATH_NAME}) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + return a.infisicalClient.Auth().GcpIamAuthLogin(identityId, serviceAccountKeyFilePath) +} + +func (a *SdkAuthenticator) HandleAwsIamAuthLogin() (credential infisicalSdk.MachineIdentityCredential, e error) { + + identityId, err := GetCmdFlagOrEnv(a.cmd, "machine-identity-id", []string{INFISICAL_MACHINE_IDENTITY_ID_NAME}) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + return a.infisicalClient.Auth().AwsIamAuthLogin(identityId) +} + +func (a *SdkAuthenticator) HandleOidcAuthLogin() (credential infisicalSdk.MachineIdentityCredential, e error) { + + identityId, err := GetCmdFlagOrEnv(a.cmd, "machine-identity-id", []string{INFISICAL_MACHINE_IDENTITY_ID_NAME}) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + jwt, err := GetCmdFlagOrEnv(a.cmd, "jwt", []string{INFISICAL_JWT_NAME, INFISICAL_OIDC_AUTH_JWT_NAME}) + if err != nil { + return infisicalSdk.MachineIdentityCredential{}, err + } + + return a.infisicalClient.Auth().OidcAuthLogin(identityId, jwt) +} diff --git a/cli/packages/util/constants.go b/cli/packages/util/constants.go index 8b4c586e6..68fda6d50 100644 --- a/cli/packages/util/constants.go +++ b/cli/packages/util/constants.go @@ -24,7 +24,10 @@ const ( INFISICAL_GCP_IAM_SERVICE_ACCOUNT_KEY_FILE_PATH_NAME = "INFISICAL_GCP_IAM_SERVICE_ACCOUNT_KEY_FILE_PATH" // OIDC Auth - INFISICAL_OIDC_AUTH_JWT_NAME = "INFISICAL_OIDC_AUTH_JWT" + INFISICAL_OIDC_AUTH_JWT_NAME = "INFISICAL_OIDC_AUTH_JWT" // deprecated in favor of INFISICAL_JWT + + // JWT AUTH + INFISICAL_JWT_NAME = "INFISICAL_JWT" // Generic env variable used for auth methods that require a machine identity ID INFISICAL_MACHINE_IDENTITY_ID_NAME = "INFISICAL_MACHINE_IDENTITY_ID" diff --git a/cli/packages/util/helper.go b/cli/packages/util/helper.go index 153a5a281..fc3f994a7 100644 --- a/cli/packages/util/helper.go +++ b/cli/packages/util/helper.go @@ -292,13 +292,18 @@ func GetEnvVarOrFileContent(envName string, filePath string) (string, error) { return fileContent, nil } -func GetCmdFlagOrEnv(cmd *cobra.Command, flag, envName string) (string, error) { +func GetCmdFlagOrEnv(cmd *cobra.Command, flag string, envNames []string) (string, error) { value, flagsErr := cmd.Flags().GetString(flag) if flagsErr != nil { return "", flagsErr } if value == "" { - value = os.Getenv(envName) + for _, env := range envNames { + value = strings.TrimSpace(os.Getenv(env)) + if value != "" { + break + } + } } if value == "" { return "", fmt.Errorf("please provide %s flag", flag) diff --git a/docs/documentation/platform/gateways/gateway-security.mdx b/docs/documentation/platform/gateways/gateway-security.mdx index 83490fd4d..93a7f662f 100644 --- a/docs/documentation/platform/gateways/gateway-security.mdx +++ b/docs/documentation/platform/gateways/gateway-security.mdx @@ -89,22 +89,3 @@ The relay system provides secure tunneling: - Gateways only accept connections to approved resources - Each connection requires explicit project authorization - Resources remain private to their assigned organization - -## Security Measures - -### Certificate Lifecycle -- Certificates have limited validity periods -- Automatic certificate rotation -- Immediate certificate revocation capabilities - -### Monitoring and Verification -1. **Continuous Verification**: - - Regular heartbeat checks - - Certificate chain validation - - Connection state monitoring - -2. **Security Controls**: - - Automatic connection termination on verification failure - - Audit logging of all access attempts - - Machine identity based authentication - diff --git a/docs/documentation/platform/gateways/networking.mdx b/docs/documentation/platform/gateways/networking.mdx new file mode 100644 index 000000000..6acdc1993 --- /dev/null +++ b/docs/documentation/platform/gateways/networking.mdx @@ -0,0 +1,168 @@ +--- +title: "Networking" +description: "Network configuration and firewall requirements for Infisical Gateway" +--- + +The Infisical Gateway requires outbound network connectivity to establish secure communication with Infisical's relay infrastructure. +This page outlines the required ports, protocols, and firewall configurations needed for optimal gateway usage. + +## Network Architecture + +The gateway uses a relay-based architecture to establish secure connections: + +1. **Gateway** connects outbound to **Relay Servers** using UDP/QUIC protocol +2. **Relay Servers** facilitate secure communication between Gateway and Infisical Cloud +3. All traffic is end-to-end encrypted using mutual TLS over QUIC + +## Required Network Connectivity + +### Outbound Connections (Required) + +The gateway requires the following outbound connectivity: + +| Protocol | Destination | Ports | Purpose | +|----------|-------------|-------|---------| +| UDP | Relay Servers | 49152-65535 | Allocated relay communication (TLS) | +| TCP | app.infisical.com / eu.infisical.com | 443 | API communication and relay allocation | + +### Relay Server IP Addresses + +Your firewall must allow outbound connectivity to the following Infisical relay servers on dynamically allocated ports. + + + + ``` + 54.235.197.91:49152-65535 + 18.215.196.229:49152-65535 + 3.222.120.233:49152-65535 + 34.196.115.157:49152-65535 + ``` + + + ``` + 3.125.237.40:49152-65535 + 52.28.157.98:49152-65535 + 3.125.176.90:49152-65535 + ``` + + + Please contact your Infisical account manager for dedicated relay server IP addresses. + + + + + These IP addresses are static and managed by Infisical. Any changes will be communicated with 60-day advance notice. + + +## Protocol Details + +### QUIC over UDP + +The gateway uses QUIC (Quick UDP Internet Connections) for primary communication: + +- **Port 5349**: STUN/TURN over TLS (secure relay communication) +- **Built-in features**: Connection migration, multiplexing, reduced latency +- **Encryption**: TLS 1.3 with certificate pinning + +## Understanding Firewall Behavior with UDP + +Unlike TCP connections, UDP is a stateless protocol, and depending on your organization's firewall configuration, you may need to adjust network rules accordingly. +When the gateway sends UDP packets to a relay server, the return responses need to be allowed back through the firewall. +Modern firewalls handle this through "connection tracking" (also called "stateful inspection"), but the behavior can vary depending on your firewall configuration. + + +### Connection Tracking + +Modern firewalls automatically track UDP connections and allow return responses. This is the preferred configuration as it: +- Automatically handles return responses +- Reduces firewall rule complexity +- Avoids the need for manual IP whitelisting + +In the event that your firewall does not support connection tracking, you will need to whitelist the relay IPs to explicitly define return traffic manually. + +## Common Network Scenarios + +### Corporate Firewalls + +For corporate environments with strict egress filtering: + +1. **Whitelist relay IP addresses** (listed above) +2. **Allow UDP port 5349** outbound +3. **Configure connection tracking** for UDP return traffic +4. **Allow ephemeral port range** 49152-65535 for return traffic if connection tracking is disabled + +### Cloud Environments (AWS/GCP/Azure) + +Configure security groups to allow: +- **Outbound UDP** to relay IPs on port 5349 +- **Outbound HTTPS** to app.infisical.com/eu.infisical.com on port 443 +- **Inbound UDP** on ephemeral ports (if not using stateful rules) + +## Frequently Asked Questions + + +The gateway is designed to handle network interruptions gracefully: + +- **Automatic reconnection**: The gateway will automatically attempt to reconnect to relay servers every 5 seconds if the connection is lost +- **Connection retry logic**: Built-in retry mechanisms handle temporary network outages without manual intervention +- **Multiple relay servers**: If one relay server is unavailable, the gateway can connect to alternative relay servers +- **Persistent sessions**: Existing connections are maintained where possible during brief network interruptions +- **Graceful degradation**: The gateway logs connection issues and continues attempting to restore connectivity + +No manual intervention is typically required during network interruptions. + + + +QUIC (Quick UDP Internet Connections) provides several advantages over traditional TCP for gateway communication: + +- **Faster connection establishment**: QUIC combines transport and security handshakes, reducing connection setup time +- **Built-in encryption**: TLS 1.3 is integrated into the protocol, ensuring all traffic is encrypted by default +- **Connection migration**: QUIC connections can survive IP address changes (useful for NAT rebinding) +- **Reduced head-of-line blocking**: Multiple data streams can be multiplexed without blocking each other +- **Better performance over unreliable networks**: Advanced congestion control and packet loss recovery +- **Lower latency**: Optimized for real-time communication between gateway and cloud services + +While TCP is stateful and easier for firewalls to track, QUIC's performance benefits outweigh the additional firewall configuration requirements. + + + +No inbound ports need to be opened. The gateway only makes outbound connections: + +- **Outbound UDP** to relay servers on ports 49152-65535 +- **Outbound HTTPS** to Infisical API endpoints +- **Return responses** are handled by connection tracking or explicit IP whitelisting + +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 UDP restrictions: + +1. **Work with your network team** to allow outbound UDP to the specific relay IP addresses +2. **Use explicit IP whitelisting** if connection tracking is disabled +3. **Consider network policy exceptions** for the gateway host +4. **Monitor firewall logs** to identify which specific rules are blocking traffic + +The gateway requires UDP connectivity to function - TCP-only configurations are not supported. + + + +The gateway connects to **one relay server at a time**: + +- **Single active connection**: Only one relay connection is established per gateway instance +- **Automatic failover**: If the current relay becomes unavailable, the gateway will connect to an alternative relay +- **Load distribution**: Different gateway instances may connect to different relay servers for load balancing +- **No manual selection**: The Infisical API automatically assigns the optimal relay server based on availability and proximity + +You should whitelist all relay IP addresses to ensure proper failover functionality. + + +No, relay servers cannot decrypt any traffic passing through them: + +- **End-to-end encryption**: All traffic between the gateway and Infisical Cloud is encrypted using mutual TLS with certificate pinning +- **Relay acts as a tunnel**: The relay server only forwards encrypted packets - it has no access to encryption keys +- **No data storage**: Relay servers do not store any traffic or network-identifiable information +- **Certificate isolation**: Each organization has its own private PKI system, ensuring complete tenant isolation + +The relay infrastructure is designed as a secure forwarding mechanism, similar to a VPN tunnel, where the relay provider cannot see the contents of the traffic flowing through it. + \ No newline at end of file diff --git a/docs/documentation/platform/gateways/overview.mdx b/docs/documentation/platform/gateways/overview.mdx index ae4a3c7ad..e5f9623f5 100644 --- a/docs/documentation/platform/gateways/overview.mdx +++ b/docs/documentation/platform/gateways/overview.mdx @@ -32,7 +32,7 @@ For detailed installation instructions, refer to the Infisical [CLI Installation To function, the Gateway must authenticate with Infisical. This requires a machine identity configured with the appropriate permissions to create and manage a Gateway. Once authenticated, the Gateway establishes a secure connection with Infisical to allow your private resources to be reachable. -### Deployment process +### Get started @@ -128,7 +128,7 @@ Once authenticated, the Gateway establishes a secure connection with Infisical t - + 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 --token $(infisical login --method=universal-auth --client-id=<> --client-secret=<> --plain) diff --git a/docs/documentation/platform/identities/kubernetes-auth.mdx b/docs/documentation/platform/identities/kubernetes-auth.mdx index 9daff1e81..e357ba75e 100644 --- a/docs/documentation/platform/identities/kubernetes-auth.mdx +++ b/docs/documentation/platform/identities/kubernetes-auth.mdx @@ -52,10 +52,11 @@ Infisical is able to authenticate and interact with the TokenReview API by using In the following steps, we explore how to create and use identities for your applications in Kubernetes to access the Infisical API using the Kubernetes Auth authentication method. + + - - + **When to use this option**: Choose this approach when you want centralized authentication management. Only one service account needs special permissions, and your application service accounts remain unchanged. @@ -126,41 +127,91 @@ In the following steps, we explore how to create and use identities for your app ``` Keep this JWT token handy as you will need it for the **Token Reviewer JWT** field when configuring the Kubernetes Auth authentication method for the identity in step 2. + - - + + + **When to use this option**: Choose this approach to eliminate long-lived tokens. This option simplifies Infisical configuration but requires each application service account to have elevated permissions. + - - **When to use this option**: Choose this approach to eliminate long-lived tokens. This option simplifies Infisical configuration but requires each application service account to have elevated permissions. - + The self-validation method eliminates the need for a separate long-lived reviewer JWT by using the same token for both authentication and validation. Instead of creating a dedicated reviewer service account, you'll grant the necessary permissions to each application service account. - The self-validation method eliminates the need for a separate long-lived reviewer JWT by using the same token for both authentication and validation. Instead of creating a dedicated reviewer service account, you'll grant the necessary permissions to each application service account. + For each service account that needs to authenticate with Infisical, add the `system:auth-delegator` role: - For each service account that needs to authenticate with Infisical, add the `system:auth-delegator` role: + ```yaml client-role-binding.yaml + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: infisical-client-binding-[your-app-name] + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: system:auth-delegator + subjects: + - kind: ServiceAccount + name: [your-app-service-account] + namespace: [your-app-namespace] + ``` - ```yaml client-role-binding.yaml - apiVersion: rbac.authorization.k8s.io/v1 - kind: ClusterRoleBinding - metadata: - name: infisical-client-binding-[your-app-name] - roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: system:auth-delegator - subjects: - - kind: ServiceAccount - name: [your-app-service-account] - namespace: [your-app-namespace] - ``` + ``` + kubectl apply -f client-role-binding.yaml + ``` - ``` - kubectl apply -f client-role-binding.yaml - ``` + When configuring Kubernetes Auth in Infisical, leave the **Token Reviewer JWT** field empty. Infisical will use the client's own token for validation. + + + + **When to use this option**: Choose this approach when you have a gateway deployed in your Kubernetes Cluster and wish to eliminate long-lived tokens. This approach simplifies Infisical Kubernetes Auth configuration, and only one service account will need to have the elevated `system:auth-delegator` ClusterRole binding. + - When configuring Kubernetes Auth in Infisical, leave the **Token Reviewer JWT** field empty. Infisical will use the client's own token for validation. - - - + + **Note:** Gateway is a paid feature. - **Infisical Cloud users:** Gateway is + available under the **Enterprise Tier**. - **Self-Hosted Infisical:** Please + contact [sales@infisical.com](mailto:sales@infisical.com) to purchase an + enterprise license. + + + + + To deploy a gateway in your Kubernetes cluster, follow our [Gateway deployment guide using helm](/documentation/platform/gateways/overview). + + + + To grant the gateway the `system:auth-delegator` ClusterRole binding, you can use the following command: + + ```yaml gateway-role-binding.yaml + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: infisical-token-reviewer-role-binding + namespace: default # Replace with your namespace if not default + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: system:auth-delegator + subjects: + - kind: ServiceAccount + name: infisical-gateway # The name of the gateway service account + namespace: default # Replace with your namespace if not default + ``` + + ```bash + kubectl apply -f gateway-role-binding.yaml + ``` + + + The gateway service account name is `infisical-gateway` by default if deployed using Helm. + + + + + To configure your Kubernetes Auth method to use the gateway as the token reviewer, set the `Review Method` to "Gateway as Reviewer", and select the gateway you want to use as the token reviewer. + + ![identities organization create kubernetes auth method](/images/platform/identities/identities-kubernetes-auth-gateway-as-reviewer.png) + + + + To create an identity, head to your Organization Settings > Access Control > Identities and press **Create identity**. diff --git a/docs/documentation/setup/networking.mdx b/docs/documentation/setup/networking.mdx index 4a666b73c..6de27c3c0 100644 --- a/docs/documentation/setup/networking.mdx +++ b/docs/documentation/setup/networking.mdx @@ -4,33 +4,36 @@ sidebarTitle: "Networking" description: "Network configuration details for Infisical Cloud" --- -## Overview - When integrating your infrastructure with Infisical Cloud, you may need to configure network access controls. This page provides the IP addresses that Infisical uses to communicate with your services. -## Egress IP Addresses +## Infisical IP Addresses -Infisical Cloud operates from two regions: US and EU. If your infrastructure has strict network policies, you may need to allow traffic from Infisical by adding the following IP addresses to your ingress rules. These are the egress IPs Infisical uses when making outbound requests to your services. +Infisical Cloud operates from multiple regions. If your infrastructure has strict network policies, you may need to allow traffic from Infisical by adding the following IP addresses to your ingress rules. These are the IP addresses that Infisical uses when making outbound requests to your services. -### US Region + + + ``` + 3.213.63.16 + 54.164.68.7 + ``` + + + + ``` + 3.77.89.19 + 3.125.209.189 + ``` + + + + For dedicated Infisical deployments, please contact your account manager for the specific IP addresses used in your dedicated environment. + + -To allow connections from Infisical US, add these IP addresses to your ingress rules: + +These IP addresses are static and managed by Infisical. Any changes will be communicated with 60-day advance notice. + -- `3.213.63.16` -- `54.164.68.7` +## What These IP Addresses Are Used For -### EU Region - -To allow connections from Infisical EU, add these IP addresses to your ingress rules: - -- `3.77.89.19` -- `3.125.209.189` - -## Common Use Cases - -You may need to allow Infisical’s egress IPs if your services require inbound connections for: - -- Secret rotation - When Infisical needs to send requests to your systems to automatically rotate credentials -- Dynamic secrets - When Infisical generates and manages temporary credentials for your cloud services -- Secret integrations - When syncing secrets with third-party services like Azure Key Vault -- Native authentication with machine identities - When using methods like Kubernetes authentication +These IP addresses represent the source IPs you'll see when Infisical Cloud makes connections to your infrastructure. All outbound traffic from Infisical Cloud originates from these IP addresses, ensuring predictable source IP addresses for your firewall rules. diff --git a/docs/images/platform/identities/identities-kubernetes-auth-gateway-as-reviewer.png b/docs/images/platform/identities/identities-kubernetes-auth-gateway-as-reviewer.png new file mode 100644 index 000000000..30ac12545 Binary files /dev/null and b/docs/images/platform/identities/identities-kubernetes-auth-gateway-as-reviewer.png differ diff --git a/docs/integrations/secret-syncs/1password.mdx b/docs/integrations/secret-syncs/1password.mdx index a33f54c8d..6e2b96b4a 100644 --- a/docs/integrations/secret-syncs/1password.mdx +++ b/docs/integrations/secret-syncs/1password.mdx @@ -46,7 +46,7 @@ description: "Learn how to configure a 1Password Sync for Infisical." - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over 1Password when keys conflict. - **Import Secrets (Prioritize 1Password)**: Imports secrets from the destination endpoint before syncing, prioritizing values from 1Password over Infisical when keys conflict. - - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. diff --git a/docs/integrations/secret-syncs/aws-parameter-store.mdx b/docs/integrations/secret-syncs/aws-parameter-store.mdx index 11f0c94ad..abc52d971 100644 --- a/docs/integrations/secret-syncs/aws-parameter-store.mdx +++ b/docs/integrations/secret-syncs/aws-parameter-store.mdx @@ -40,7 +40,7 @@ description: "Learn how to configure an AWS Parameter Store Sync for Infisical." - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Parameter Store when keys conflict. - **Import Secrets (Prioritize AWS Parameter Store)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Parameter Store over Infisical when keys conflict. - - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. diff --git a/docs/integrations/secret-syncs/aws-secrets-manager.mdx b/docs/integrations/secret-syncs/aws-secrets-manager.mdx index f7654eeae..91c606b0a 100644 --- a/docs/integrations/secret-syncs/aws-secrets-manager.mdx +++ b/docs/integrations/secret-syncs/aws-secrets-manager.mdx @@ -43,7 +43,7 @@ description: "Learn how to configure an AWS Secrets Manager Sync for Infisical." - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Secrets Manager when keys conflict. - **Import Secrets (Prioritize AWS Secrets Manager)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Secrets Manager over Infisical when keys conflict. - - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. diff --git a/docs/integrations/secret-syncs/azure-app-configuration.mdx b/docs/integrations/secret-syncs/azure-app-configuration.mdx index ee47504bc..f4aaa7edd 100644 --- a/docs/integrations/secret-syncs/azure-app-configuration.mdx +++ b/docs/integrations/secret-syncs/azure-app-configuration.mdx @@ -48,7 +48,7 @@ description: "Learn how to configure an Azure App Configuration Sync for Infisic - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Secrets Manager when keys conflict. - **Import Secrets (Prioritize Azure App Configuration)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Secrets Manager over Infisical when keys conflict. - - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. diff --git a/docs/integrations/secret-syncs/azure-key-vault.mdx b/docs/integrations/secret-syncs/azure-key-vault.mdx index 609ba8b8d..d19a0162e 100644 --- a/docs/integrations/secret-syncs/azure-key-vault.mdx +++ b/docs/integrations/secret-syncs/azure-key-vault.mdx @@ -51,7 +51,7 @@ description: "Learn how to configure a Azure Key Vault Sync for Infisical." - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Secrets Manager when keys conflict. - **Import Secrets (Prioritize Azure Key Vault)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Secrets Manager over Infisical when keys conflict. - - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. diff --git a/docs/integrations/secret-syncs/camunda.mdx b/docs/integrations/secret-syncs/camunda.mdx index df57a5b7d..0e977aa27 100644 --- a/docs/integrations/secret-syncs/camunda.mdx +++ b/docs/integrations/secret-syncs/camunda.mdx @@ -39,7 +39,7 @@ description: "Learn how to configure a Camunda Sync for Infisical." - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Camunda when keys conflict. - **Import Secrets (Prioritize Camunda)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Camunda over Infisical when keys conflict. - - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. diff --git a/docs/integrations/secret-syncs/databricks.mdx b/docs/integrations/secret-syncs/databricks.mdx index 225bad5b1..e11537420 100644 --- a/docs/integrations/secret-syncs/databricks.mdx +++ b/docs/integrations/secret-syncs/databricks.mdx @@ -46,7 +46,7 @@ description: "Learn how to configure a Databricks Sync for Infisical." Databricks does not support importing secrets. - - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. diff --git a/docs/integrations/secret-syncs/gcp-secret-manager.mdx b/docs/integrations/secret-syncs/gcp-secret-manager.mdx index ace63787d..df490c6b5 100644 --- a/docs/integrations/secret-syncs/gcp-secret-manager.mdx +++ b/docs/integrations/secret-syncs/gcp-secret-manager.mdx @@ -42,7 +42,7 @@ description: "Learn how to configure a GCP Secret Manager Sync for Infisical." - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over GCP Secret Manager when keys conflict. - **Import Secrets (Prioritize GCP Secret Manager)**: Imports secrets from the destination endpoint before syncing, prioritizing values from GCP Secret Manager over Infisical when keys conflict. - - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. diff --git a/docs/integrations/secret-syncs/github.mdx b/docs/integrations/secret-syncs/github.mdx index 7786567cc..14b2d9a7f 100644 --- a/docs/integrations/secret-syncs/github.mdx +++ b/docs/integrations/secret-syncs/github.mdx @@ -62,7 +62,7 @@ description: "Learn how to configure a GitHub Sync for Infisical." GitHub does not support importing secrets. - - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. diff --git a/docs/integrations/secret-syncs/hashicorp-vault.mdx b/docs/integrations/secret-syncs/hashicorp-vault.mdx index 48e4d8dfd..fae2e0962 100644 --- a/docs/integrations/secret-syncs/hashicorp-vault.mdx +++ b/docs/integrations/secret-syncs/hashicorp-vault.mdx @@ -54,7 +54,7 @@ description: "Learn how to configure a Hashicorp Vault Sync for Infisical." - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Hashicorp Vault when keys conflict. - **Import Secrets (Prioritize Hashicorp Vault)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Hashicorp Vault over Infisical when keys conflict. - - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. diff --git a/docs/integrations/secret-syncs/humanitec.mdx b/docs/integrations/secret-syncs/humanitec.mdx index ec36bd4da..f252724fb 100644 --- a/docs/integrations/secret-syncs/humanitec.mdx +++ b/docs/integrations/secret-syncs/humanitec.mdx @@ -55,7 +55,7 @@ description: "Learn how to configure a Humanitec Sync for Infisical." Humanitec does not support importing secrets. - - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. diff --git a/docs/integrations/secret-syncs/oci-vault.mdx b/docs/integrations/secret-syncs/oci-vault.mdx index 00b7120e7..396b4d13f 100644 --- a/docs/integrations/secret-syncs/oci-vault.mdx +++ b/docs/integrations/secret-syncs/oci-vault.mdx @@ -57,7 +57,7 @@ description: "Learn how to configure an Oracle Cloud Infrastructure Vault Sync f - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over OCI Vault when keys conflict. - **Import Secrets (Prioritize OCI Vault)**: Imports secrets from the destination endpoint before syncing, prioritizing values from OCI Vault over Infisical when keys conflict. - - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. diff --git a/docs/integrations/secret-syncs/overview.mdx b/docs/integrations/secret-syncs/overview.mdx index 65ec4e6cf..937c8d826 100644 --- a/docs/integrations/secret-syncs/overview.mdx +++ b/docs/integrations/secret-syncs/overview.mdx @@ -101,6 +101,10 @@ Key Schemas transform your secret keys by applying a prefix, suffix, or format p Any destination secrets which do not match the schema will not get deleted or updated by Infisical. +Key Schemas use handlebars syntax to define dynamic values. Here's a full list of available variables: +- `{{secretKey}}` - The key of the secret +- `{{environment}}` - The environment which the secret is in (e.g. dev, staging, prod) + **Example:** - Infisical key: `SECRET_1` - Schema: `INFISICAL_{{secretKey}}` diff --git a/docs/integrations/secret-syncs/teamcity.mdx b/docs/integrations/secret-syncs/teamcity.mdx index 3482101ca..52f2c1bac 100644 --- a/docs/integrations/secret-syncs/teamcity.mdx +++ b/docs/integrations/secret-syncs/teamcity.mdx @@ -48,7 +48,7 @@ description: "Learn how to configure a TeamCity Sync for Infisical." Infisical only syncs secrets from within the target scope; inherited secrets will not be imported. - - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. diff --git a/docs/integrations/secret-syncs/terraform-cloud.mdx b/docs/integrations/secret-syncs/terraform-cloud.mdx index d2f762ef1..c48b87609 100644 --- a/docs/integrations/secret-syncs/terraform-cloud.mdx +++ b/docs/integrations/secret-syncs/terraform-cloud.mdx @@ -56,7 +56,7 @@ description: "Learn how to configure a Terraform Cloud Sync for Infisical." Terraform Cloud does not support importing secrets. - - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. diff --git a/docs/integrations/secret-syncs/vercel.mdx b/docs/integrations/secret-syncs/vercel.mdx index c903d3faa..74cffbc11 100644 --- a/docs/integrations/secret-syncs/vercel.mdx +++ b/docs/integrations/secret-syncs/vercel.mdx @@ -43,7 +43,7 @@ description: "Learn how to configure a Vercel Sync for Infisical." - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Vercel when keys conflict. - **Import Secrets (Prioritize Vercel)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Vercel over Infisical when keys conflict. - - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. diff --git a/docs/integrations/secret-syncs/windmill.mdx b/docs/integrations/secret-syncs/windmill.mdx index e98a2c7b6..c0757ef37 100644 --- a/docs/integrations/secret-syncs/windmill.mdx +++ b/docs/integrations/secret-syncs/windmill.mdx @@ -44,7 +44,7 @@ description: "Learn how to configure a Windmill Sync for Infisical." - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Windmill when keys conflict. - **Import Secrets (Prioritize Windmill)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Windmill over Infisical when keys conflict. - - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. diff --git a/docs/mint.json b/docs/mint.json index 67a771085..64b827f55 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -233,7 +233,8 @@ "group": "Gateway", "pages": [ "documentation/platform/gateways/overview", - "documentation/platform/gateways/gateway-security" + "documentation/platform/gateways/gateway-security", + "documentation/platform/gateways/networking" ] }, "documentation/platform/project-templates", diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx index 6050cab21..30f0b250d 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx @@ -131,7 +131,27 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { render={({ field: { value, onChange }, fieldState: { error } }) => ( + + When a secret is synced, values will be injected into the key schema before it + reaches the destination. This is useful for organization. + + +
+ Available keys: +
    +
  • + {"{{secretKey}}"} - The key of the secret +
  • +
  • + {"{{environment}}"} - The environment which the secret is in + (e.g. dev, staging, prod) +
  • +
+
+ + } isError={Boolean(error)} isOptional errorText={error?.message} diff --git a/frontend/src/components/secret-syncs/forms/schemas/base-secret-sync-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/base-secret-sync-schema.ts index 75a5b68c1..1d925eb07 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/base-secret-sync-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/base-secret-sync-schema.ts @@ -13,11 +13,27 @@ export const BaseSecretSyncSchema = - !val || /^(?:[a-zA-Z0-9_\-/]*)(?:\{\{secretKey\}\})(?:[a-zA-Z0-9_\-/]*)$/.test(val), + (val) => { + if (!val) return true; + + const allowedOptionalPlaceholders = ["{{environment}}"]; + + const allowedPlaceholdersRegexPart = ["{{secretKey}}", ...allowedOptionalPlaceholders] + .map((p) => p.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")) // Escape regex special characters + .join("|"); + + const allowedContentRegex = new RegExp( + `^([a-zA-Z0-9_\\-/]|${allowedPlaceholdersRegexPart})*$` + ); + const contentIsValid = allowedContentRegex.test(val); + + const secretKeyCount = (val.match(/\{\{secretKey\}\}/g) || []).length; + + return contentIsValid && secretKeyCount === 1; + }, { message: - "Key schema must include one {{secretKey}} and only contain letters, numbers, dashes, underscores, slashes, and the {{secretKey}} placeholder." + "Key schema must include exactly one {{secretKey}} placeholder. It can also include {{environment}} placeholders. Only alphanumeric characters (a-z, A-Z, 0-9), dashes (-), underscores (_), and slashes (/) are allowed besides the placeholders." } ) }); diff --git a/frontend/src/hooks/api/identities/mutations.tsx b/frontend/src/hooks/api/identities/mutations.tsx index c08081f11..a7bd88ce9 100644 --- a/frontend/src/hooks/api/identities/mutations.tsx +++ b/frontend/src/hooks/api/identities/mutations.tsx @@ -843,7 +843,8 @@ export const useAddIdentityKubernetesAuth = () => { accessTokenMaxTTL, accessTokenNumUsesLimit, accessTokenTrustedIps, - gatewayId + gatewayId, + tokenReviewMode }) => { const { data: { identityKubernetesAuth } @@ -860,7 +861,8 @@ export const useAddIdentityKubernetesAuth = () => { accessTokenMaxTTL, accessTokenNumUsesLimit, accessTokenTrustedIps, - gatewayId + gatewayId, + tokenReviewMode } ); @@ -950,7 +952,8 @@ export const useUpdateIdentityKubernetesAuth = () => { accessTokenMaxTTL, accessTokenNumUsesLimit, accessTokenTrustedIps, - gatewayId + gatewayId, + tokenReviewMode }) => { const { data: { identityKubernetesAuth } @@ -967,7 +970,8 @@ export const useUpdateIdentityKubernetesAuth = () => { accessTokenMaxTTL, accessTokenNumUsesLimit, accessTokenTrustedIps, - gatewayId + gatewayId, + tokenReviewMode } ); diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index c26466213..f5e0f1065 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -379,10 +379,16 @@ export type DeleteIdentityAzureAuthDTO = { identityId: string; }; +export enum IdentityKubernetesAuthTokenReviewMode { + Api = "api", + Gateway = "gateway" +} + export type IdentityKubernetesAuth = { identityId: string; kubernetesHost: string; tokenReviewerJwt: string; + tokenReviewMode: IdentityKubernetesAuthTokenReviewMode; allowedNamespaces: string; allowedNames: string; allowedAudience: string; @@ -399,6 +405,7 @@ export type AddIdentityKubernetesAuthDTO = { identityId: string; kubernetesHost: string; tokenReviewerJwt?: string; + tokenReviewMode: IdentityKubernetesAuthTokenReviewMode; allowedNamespaces: string; allowedNames: string; allowedAudience: string; @@ -417,6 +424,7 @@ export type UpdateIdentityKubernetesAuthDTO = { identityId: string; kubernetesHost?: string; tokenReviewerJwt?: string | null; + tokenReviewMode?: IdentityKubernetesAuthTokenReviewMode; allowedNamespaces?: string; allowedNames?: string; allowedAudience?: string; diff --git a/frontend/src/hooks/api/roles/mutation.tsx b/frontend/src/hooks/api/roles/mutation.tsx index 8d662f993..6df9aa937 100644 --- a/frontend/src/hooks/api/roles/mutation.tsx +++ b/frontend/src/hooks/api/roles/mutation.tsx @@ -96,7 +96,7 @@ export const useUpdateOrgRole = () => { data: { role } } = await apiRequest.patch(`/api/v1/organization/${orgId}/roles/${id}`, { ...dto, - permissions: permissions?.length ? packRules(permissions) : undefined + permissions: permissions ? packRules(permissions) : undefined }); return role; diff --git a/frontend/src/pages/organization/AccessManagementPage/AccessManagementPage.tsx b/frontend/src/pages/organization/AccessManagementPage/AccessManagementPage.tsx index 61c4f4411..bb1d5010a 100644 --- a/frontend/src/pages/organization/AccessManagementPage/AccessManagementPage.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/AccessManagementPage.tsx @@ -5,6 +5,7 @@ import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useNavigate, useSearch } from "@tanstack/react-router"; +import { OrgPermissionGuardBanner } from "@app/components/permissions/OrgPermissionCan"; import { Button, PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; import { ROUTE_PATHS } from "@app/const/routes"; import { @@ -72,6 +73,8 @@ export const AccessManagementPage = () => { } ]; + const hasNoAccess = tabSections.every((tab) => tab.isHidden); + return (
@@ -126,6 +129,7 @@ export const AccessManagementPage = () => { ))}
+ {hasNoAccess && } ); }; diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityKubernetesAuthForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityKubernetesAuthForm.tsx index 369977a04..e43c94a7e 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityKubernetesAuthForm.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityKubernetesAuthForm.tsx @@ -33,13 +33,19 @@ import { useGetIdentityKubernetesAuth, useUpdateIdentityKubernetesAuth } from "@app/hooks/api"; -import { IdentityTrustedIp } from "@app/hooks/api/identities/types"; +import { + IdentityKubernetesAuthTokenReviewMode, + IdentityTrustedIp +} from "@app/hooks/api/identities/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; import { IdentityFormTab } from "./types"; const schema = z .object({ + tokenReviewMode: z + .nativeEnum(IdentityKubernetesAuthTokenReviewMode) + .default(IdentityKubernetesAuthTokenReviewMode.Api), kubernetesHost: z.string().min(1), tokenReviewerJwt: z.string().optional(), gatewayId: z.string().optional().nullable(), @@ -62,7 +68,15 @@ const schema = z ) .min(1) }) - .required(); + .superRefine((data, ctx) => { + if (data.tokenReviewMode === IdentityKubernetesAuthTokenReviewMode.Gateway && !data.gatewayId) { + ctx.addIssue({ + path: ["gatewayId"], + code: z.ZodIssueCode.custom, + message: "When token review mode is set to Gateway, a gateway must be selected" + }); + } + }); export type FormData = z.infer; @@ -100,11 +114,14 @@ export const IdentityKubernetesAuthForm = ({ control, handleSubmit, reset, + watch, + setValue, formState: { isSubmitting } } = useForm({ resolver: zodResolver(schema), defaultValues: { + tokenReviewMode: IdentityKubernetesAuthTokenReviewMode.Api, kubernetesHost: "", tokenReviewerJwt: "", allowedNames: "", @@ -128,6 +145,7 @@ export const IdentityKubernetesAuthForm = ({ useEffect(() => { if (data) { reset({ + tokenReviewMode: data.tokenReviewMode, kubernetesHost: data.kubernetesHost, tokenReviewerJwt: data.tokenReviewerJwt, allowedNames: data.allowedNames, @@ -148,6 +166,7 @@ export const IdentityKubernetesAuthForm = ({ }); } else { reset({ + tokenReviewMode: IdentityKubernetesAuthTokenReviewMode.Api, kubernetesHost: "", tokenReviewerJwt: "", allowedNames: "", @@ -173,6 +192,7 @@ export const IdentityKubernetesAuthForm = ({ accessTokenMaxTTL, accessTokenNumUsesLimit, gatewayId, + tokenReviewMode, accessTokenTrustedIps }: FormData) => { try { @@ -189,6 +209,7 @@ export const IdentityKubernetesAuthForm = ({ caCert, identityId, gatewayId: gatewayId || null, + tokenReviewMode, accessTokenTTL: Number(accessTokenTTL), accessTokenMaxTTL: Number(accessTokenMaxTTL), accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), @@ -205,6 +226,7 @@ export const IdentityKubernetesAuthForm = ({ allowedAudience: allowedAudience || "", gatewayId: gatewayId || null, caCert: caCert || "", + tokenReviewMode, accessTokenTTL: Number(accessTokenTTL), accessTokenMaxTTL: Number(accessTokenMaxTTL), accessTokenNumUsesLimit: Number(accessTokenNumUsesLimit), @@ -228,6 +250,8 @@ export const IdentityKubernetesAuthForm = ({ } }; + const tokenReviewMode = watch("tokenReviewMode"); + return (
{ @@ -235,6 +259,7 @@ export const IdentityKubernetesAuthForm = ({ [ "kubernetesHost", "tokenReviewerJwt", + "tokenReviewMode", "gatewayId", "accessTokenTTL", "accessTokenMaxTTL", @@ -269,21 +294,113 @@ export const IdentityKubernetesAuthForm = ({ )} /> - ( - +
+ - - - )} - /> + {(isAllowed) => ( + ( + + +
+ +
+
+
+ )} + /> + )} +
+
+ + ( + + + + )} + /> + + + {tokenReviewMode === "api" && ( + ( + + + + )} + /> + )} - - {(isAllowed) => ( - ( - - -
- -
-
-
- )} - /> - )} -
- 0.41.8. + * This new image version supports using the gateway as a token reviewer for the Identity Kubernetes Auth method. + ## 0.0.1 (May 1, 2025) * Initial helm release \ No newline at end of file diff --git a/helm-charts/infisical-gateway/Chart.yaml b/helm-charts/infisical-gateway/Chart.yaml index 382acd3e1..8d9d4dac3 100644 --- a/helm-charts/infisical-gateway/Chart.yaml +++ b/helm-charts/infisical-gateway/Chart.yaml @@ -15,10 +15,10 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 0.0.1 +version: 0.0.3 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to # follow Semantic Versioning. They should reflect the version the application is using. # It is recommended to use it with quotes. -appVersion: "0.0.1" +appVersion: "0.0.3" diff --git a/helm-charts/infisical-gateway/values.yaml b/helm-charts/infisical-gateway/values.yaml index b1d55c0fc..9e897f461 100644 --- a/helm-charts/infisical-gateway/values.yaml +++ b/helm-charts/infisical-gateway/values.yaml @@ -1,6 +1,6 @@ image: pullPolicy: IfNotPresent - tag: "0.41.1" + tag: "0.41.81" secret: # The secret that contains the environment variables to be used by the gateway, such as INFISICAL_API_URL and TOKEN