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

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

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

    + Upgrade your Infisical Version +

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

    Calculate Upgrade Path

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

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

    +

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

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

    Upgrade Steps

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

    {step.version}

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

    + Made with ❤️ by{" "} + + Infisical + +
    + 235 2nd st, San Francisco, California, 94105, United States. 🇺🇸 +

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

    + Recovery codes downloaded. You can now close this modal. +

    + ) : ( +

    + Download the recovery codes to continue. +

    + )} +
    +
    +
    + ); +}; diff --git a/frontend/src/components/mfa/TotpRegistration.tsx b/frontend/src/components/mfa/TotpRegistration.tsx index b6e2ebe2a..cd5189d25 100644 --- a/frontend/src/components/mfa/TotpRegistration.tsx +++ b/frontend/src/components/mfa/TotpRegistration.tsx @@ -7,6 +7,7 @@ import { useVerifyUserTotpRegistration } from "@app/hooks/api/users/mutation"; import { createNotification } from "../notifications"; import { Button, ContentLoader, Input } from "../v2"; +import { RecoveryCodesDownload } from "./RecoveryCodesDownload"; type Props = { onComplete?: () => Promise; @@ -19,20 +20,39 @@ const TotpRegistration = ({ onComplete, shouldCenterQr }: Props) => { useVerifyUserTotpRegistration(); const [qrCodeUrl, setQrCodeUrl] = useState(""); const [totp, setTotp] = useState(""); + const [showRecoveryModal, setShowRecoveryModal] = useState(false); + const [recoveryCodes, setRecoveryCodes] = useState([]); const handleTotpVerify = async (event: React.FormEvent) => { event.preventDefault(); - await verifyUserTotp({ - totp - }); + try { + const result = await verifyUserTotp({ + totp + }); - createNotification({ - text: "Successfully configured mobile authenticator", - type: "success" - }); + createNotification({ + text: "Successfully configured mobile authenticator", + type: "success" + }); + if (result.recoveryCodes) { + setRecoveryCodes(result.recoveryCodes); + setShowRecoveryModal(true); + } else if (onComplete) { + onComplete(); + } + } catch { + createNotification({ + text: "Failed to verify TOTP code", + type: "error" + }); + } + }; + + const handleRecoveryDownloadComplete = async () => { + setShowRecoveryModal(false); if (onComplete) { - onComplete(); + await onComplete(); } }; @@ -52,28 +72,37 @@ const TotpRegistration = ({ onComplete, shouldCenterQr }: Props) => { } return ( -
    -
    - 1. Download a two-step verification app (Duo, Google Authenticator, etc.) and scan the QR - code. -
    -
    - registration-qr -
    -
    -
    2. Enter the resulting verification code
    -
    - setTotp(e.target.value)} - value={totp} - placeholder="Verification code" - /> - + <> +
    +
    + 1. Download a two-step verification app (Duo, Google Authenticator, etc.) and scan the QR + code.
    - -
    +
    + registration-qr +
    +
    +
    2. Enter the resulting verification code
    +
    + setTotp(e.target.value)} + value={totp} + placeholder="Verification code" + /> + +
    +
    +
    + + setShowRecoveryModal(false)} + recoveryCodes={recoveryCodes} + onDownloadComplete={handleRecoveryDownloadComplete} + /> + ); }; diff --git a/frontend/src/hooks/api/auth/queries.tsx b/frontend/src/hooks/api/auth/queries.tsx index d41a4d115..207980017 100644 --- a/frontend/src/hooks/api/auth/queries.tsx +++ b/frontend/src/hooks/api/auth/queries.tsx @@ -183,6 +183,13 @@ export const useVerifyMfaToken = () => { }); }; +export const verifyRecoveryCode = async (recoveryCode: string) => { + const { data } = await apiRequest.post("/api/v2/auth/mfa/verify/recovery-code", { + recoveryCode + }); + return data; +}; + export const verifySignupInvite = async (details: VerifySignupInviteDTO) => { const { data } = await apiRequest.post("/api/v1/invite-org/verify", details); return data; diff --git a/frontend/src/hooks/api/users/mutation.tsx b/frontend/src/hooks/api/users/mutation.tsx index 7acfb8fe0..10df1b8a9 100644 --- a/frontend/src/hooks/api/users/mutation.tsx +++ b/frontend/src/hooks/api/users/mutation.tsx @@ -77,13 +77,16 @@ export const useUpdateUserProjectFavorites = () => { }; export const useVerifyUserTotpRegistration = () => { - return useMutation({ + return useMutation<{ recoveryCodes: string[] }, unknown, { totp: string }>({ mutationFn: async ({ totp }: { totp: string }) => { - await apiRequest.post("/api/v1/user/me/totp/verify", { - totp - }); + const { data } = await apiRequest.post<{ recoveryCodes: string[] }>( + "/api/v1/user/me/totp/verify", + { + totp + } + ); - return {}; + return data; } }); }; From fe3a46a9e7a8561f0ce6dffe1c32b34adbed30cb Mon Sep 17 00:00:00 2001 From: Daniel Hougaard Date: Sat, 20 Sep 2025 06:12:08 +0400 Subject: [PATCH 07/60] feat: redis app connection & secret rotation --- .../v2/secret-rotation-v2-routers/index.ts | 4 +- .../redis-credentials-rotation-router.ts | 19 ++ .../secret-rotation-v2-router.ts | 4 +- .../redis-credentials/index.ts | 4 + .../redis-credentials-rotation-constants.ts | 15 + .../redis-credentials-rotation-fns.ts | 170 ++++++++++ .../redis-credentials-rotation-schemas.ts | 75 +++++ .../redis-credentials-rotation-types.ts | 24 ++ .../secret-rotation-v2-enums.ts | 3 +- .../secret-rotation-v2-fns.ts | 4 +- .../secret-rotation-v2-maps.ts | 6 +- .../secret-rotation-v2-service.ts | 4 +- .../secret-rotation-v2-types.ts | 22 +- .../secret-rotation-v2-union-schema.ts | 4 +- backend/src/lib/api-docs/constants.ts | 7 + .../app-connection-router.ts | 7 +- .../routes/v1/app-connection-routers/index.ts | 4 +- .../redis-connection-router.ts | 18 + .../app-connection/app-connection-enums.ts | 3 +- .../app-connection/app-connection-fns.ts | 11 +- .../app-connection/app-connection-maps.ts | 6 +- .../app-connection/app-connection-service.ts | 4 +- .../app-connection/app-connection-types.ts | 14 +- .../services/app-connection/redis/index.ts | 4 + .../redis/redis-connection-enums.ts | 3 + .../redis/redis-connection-fns.ts | 56 ++++ .../redis/redis-connection-schemas.ts | 87 +++++ .../redis/redis-connection-types.ts | 22 ++ frontend/public/images/integrations/Redis.png | Bin 0 -> 3478 bytes ...redentialsRotationGeneratedCredentials.tsx | 38 +++ ...ewSecretRotationV2GeneratedCredentials.tsx | 8 + ...disCredentialsRotationParametersFields.tsx | 197 +++++++++++ .../SecretRotationV2ParametersFields.tsx | 4 +- .../RedisCredentialsRotationReviewFields.tsx | 50 +++ .../SecretRotationReviewFields.tsx | 4 +- ...redentialsRotationSecretsMappingFields.tsx | 58 ++++ .../SecretRotationV2SecretsMappingFields.tsx | 4 +- .../forms/schemas/index.ts | 4 +- .../redis-credentials-rotation-schema.ts | 20 ++ frontend/src/helpers/appConnections.ts | 3 +- frontend/src/helpers/secretRotationsV2.ts | 11 +- .../src/hooks/api/appConnections/enums.ts | 3 +- .../api/appConnections/types/app-options.ts | 5 + .../hooks/api/appConnections/types/index.ts | 5 +- .../appConnections/types/redis-connection.ts | 21 ++ .../src/hooks/api/secretRotationsV2/enums.ts | 3 +- .../api/secretRotationsV2/types/index.ts | 14 +- .../types/redis-credentials-rotation.ts | 39 +++ .../AppConnectionForm/AppConnectionForm.tsx | 3 + .../AppConnectionForm/RedisConnectionForm.tsx | 316 ++++++++++++++++++ 50 files changed, 1377 insertions(+), 37 deletions(-) create mode 100644 backend/src/ee/routes/v2/secret-rotation-v2-routers/redis-credentials-rotation-router.ts create mode 100644 backend/src/ee/services/secret-rotation-v2/redis-credentials/index.ts create mode 100644 backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-constants.ts create mode 100644 backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-fns.ts create mode 100644 backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-schemas.ts create mode 100644 backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-types.ts create mode 100644 backend/src/server/routes/v1/app-connection-routers/redis-connection-router.ts create mode 100644 backend/src/services/app-connection/redis/index.ts create mode 100644 backend/src/services/app-connection/redis/redis-connection-enums.ts create mode 100644 backend/src/services/app-connection/redis/redis-connection-fns.ts create mode 100644 backend/src/services/app-connection/redis/redis-connection-schemas.ts create mode 100644 backend/src/services/app-connection/redis/redis-connection-types.ts create mode 100644 frontend/public/images/integrations/Redis.png create mode 100644 frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewRedisCredentialsRotationGeneratedCredentials.tsx create mode 100644 frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/RedisCredentialsRotationParametersFields.tsx create mode 100644 frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/RedisCredentialsRotationReviewFields.tsx create mode 100644 frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/RedisCredentialsRotationSecretsMappingFields.tsx create mode 100644 frontend/src/components/secret-rotations-v2/forms/schemas/redis-credentials-rotation-schema.ts create mode 100644 frontend/src/hooks/api/appConnections/types/redis-connection.ts create mode 100644 frontend/src/hooks/api/secretRotationsV2/types/redis-credentials-rotation.ts create mode 100644 frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/RedisConnectionForm.tsx diff --git a/backend/src/ee/routes/v2/secret-rotation-v2-routers/index.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/index.ts index 5f8dea5d7..8d1702850 100644 --- a/backend/src/ee/routes/v2/secret-rotation-v2-routers/index.ts +++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/index.ts @@ -9,6 +9,7 @@ import { registerMySqlCredentialsRotationRouter } from "./mysql-credentials-rota import { registerOktaClientSecretRotationRouter } from "./okta-client-secret-rotation-router"; import { registerOracleDBCredentialsRotationRouter } from "./oracledb-credentials-rotation-router"; import { registerPostgresCredentialsRotationRouter } from "./postgres-credentials-rotation-router"; +import { registerRedisCredentialsRotationRouter } from "./redis-credentials-rotation-router"; export * from "./secret-rotation-v2-router"; @@ -24,5 +25,6 @@ export const SECRET_ROTATION_REGISTER_ROUTER_MAP: Record< [SecretRotation.AzureClientSecret]: registerAzureClientSecretRotationRouter, [SecretRotation.AwsIamUserSecret]: registerAwsIamUserSecretRotationRouter, [SecretRotation.LdapPassword]: registerLdapPasswordRotationRouter, - [SecretRotation.OktaClientSecret]: registerOktaClientSecretRotationRouter + [SecretRotation.OktaClientSecret]: registerOktaClientSecretRotationRouter, + [SecretRotation.RedisCredentials]: registerRedisCredentialsRotationRouter }; diff --git a/backend/src/ee/routes/v2/secret-rotation-v2-routers/redis-credentials-rotation-router.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/redis-credentials-rotation-router.ts new file mode 100644 index 000000000..b83cec52c --- /dev/null +++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/redis-credentials-rotation-router.ts @@ -0,0 +1,19 @@ +import { + CreateRedisCredentialsRotationSchema, + RedisCredentialsRotationGeneratedCredentialsSchema, + RedisCredentialsRotationSchema, + UpdateRedisCredentialsRotationSchema +} from "@app/ee/services/secret-rotation-v2/redis-credentials"; +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; + +import { registerSecretRotationEndpoints } from "./secret-rotation-v2-endpoints"; + +export const registerRedisCredentialsRotationRouter = async (server: FastifyZodProvider) => + registerSecretRotationEndpoints({ + type: SecretRotation.RedisCredentials, + server, + responseSchema: RedisCredentialsRotationSchema, + createSchema: CreateRedisCredentialsRotationSchema, + updateSchema: UpdateRedisCredentialsRotationSchema, + generatedCredentialsSchema: RedisCredentialsRotationGeneratedCredentialsSchema + }); diff --git a/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts b/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts index 7db99c8c4..6ea6497e4 100644 --- a/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts +++ b/backend/src/ee/routes/v2/secret-rotation-v2-routers/secret-rotation-v2-router.ts @@ -10,6 +10,7 @@ import { MySqlCredentialsRotationListItemSchema } from "@app/ee/services/secret- import { OktaClientSecretRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/okta-client-secret"; import { OracleDBCredentialsRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/oracledb-credentials"; import { PostgresCredentialsRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/postgres-credentials"; +import { RedisCredentialsRotationListItemSchema } from "@app/ee/services/secret-rotation-v2/redis-credentials"; import { SecretRotationV2Schema } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema"; import { ApiDocsTags, SecretRotations } from "@app/lib/api-docs"; import { readLimit } from "@app/server/config/rateLimiter"; @@ -25,7 +26,8 @@ const SecretRotationV2OptionsSchema = z.discriminatedUnion("type", [ AzureClientSecretRotationListItemSchema, AwsIamUserSecretRotationListItemSchema, LdapPasswordRotationListItemSchema, - OktaClientSecretRotationListItemSchema + OktaClientSecretRotationListItemSchema, + RedisCredentialsRotationListItemSchema ]); export const registerSecretRotationV2Router = async (server: FastifyZodProvider) => { diff --git a/backend/src/ee/services/secret-rotation-v2/redis-credentials/index.ts b/backend/src/ee/services/secret-rotation-v2/redis-credentials/index.ts new file mode 100644 index 000000000..2d90beab3 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/redis-credentials/index.ts @@ -0,0 +1,4 @@ +export * from "./redis-credentials-rotation-constants"; +export * from "./redis-credentials-rotation-fns"; +export * from "./redis-credentials-rotation-schemas"; +export * from "./redis-credentials-rotation-types"; diff --git a/backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-constants.ts b/backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-constants.ts new file mode 100644 index 000000000..1cb14a922 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-constants.ts @@ -0,0 +1,15 @@ +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { TSecretRotationV2ListItem } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +export const REDIS_CREDENTIALS_ROTATION_LIST_OPTION: TSecretRotationV2ListItem = { + name: "Redis Credentials", + type: SecretRotation.RedisCredentials, + connection: AppConnection.Redis, + template: { + secretsMapping: { + username: "REDIS_USERNAME", + password: "REDIS_PASSWORD" + } + } +}; diff --git a/backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-fns.ts b/backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-fns.ts new file mode 100644 index 000000000..c321cdeb0 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-fns.ts @@ -0,0 +1,170 @@ +/* eslint-disable no-await-in-loop */ +import Redis from "ioredis"; + +import { + TRotationFactory, + TRotationFactoryGetSecretsPayload, + TRotationFactoryIssueCredentials, + TRotationFactoryRevokeCredentials, + TRotationFactoryRotateCredentials +} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; +import { BadRequestError } from "@app/lib/errors"; + +import { DEFAULT_PASSWORD_REQUIREMENTS, generatePassword } from "../shared/utils"; +import { + TRedisCredentialsRotationGeneratedCredentials, + TRedisCredentialsRotationWithConnection +} from "./redis-credentials-rotation-types"; + +export const redisCredentialsRotationFactory: TRotationFactory< + TRedisCredentialsRotationWithConnection, + TRedisCredentialsRotationGeneratedCredentials +> = (secretRotation) => { + const { connection, secretsMapping, parameters } = secretRotation; + + const $getClient = async () => { + let conn: Redis | null = null; + try { + conn = new Redis({ + username: connection.credentials.username, + host: connection.credentials.host, + port: connection.credentials.port, + password: connection.credentials.password, + ...(connection.credentials.sslEnabled && { + tls: { + rejectUnauthorized: connection.credentials.sslRejectUnauthorized, + ca: connection.credentials.sslCertificate + } + }) + }); + + let result: string; + if (connection.credentials.password) { + result = await conn.auth(connection.credentials.username, connection.credentials.password, () => {}); + } else { + result = await conn.auth(connection.credentials.username, () => {}); + } + + if (result !== "OK") { + throw new BadRequestError({ message: `Invalid credentials, Redis returned ${result} status` }); + } + + return conn; + } catch (err) { + if (conn) await conn.quit(); + + throw err; + } + }; + + /** + * Creates a new user and password for the redis user using ACL + */ + const $rotateAclUser = async () => { + const client = await $getClient(); + + const username = generatePassword({ + length: 32, + required: { + symbols: 0, + digits: 5, + uppercase: 5, + lowercase: 5 + } + }); + + const password = generatePassword(parameters.passwordRequirements || DEFAULT_PASSWORD_REQUIREMENTS); + + try { + // important: permissionScope is user input so we need to sanitize it, which we do by splitting the permission scope into parts and then passing them to the ACL command as separate arguments + const permissionParts = (parameters.permissionScope || "~* +@all").split(" "); + await client.call("ACL", "SETUSER", username, `>${password}`, "on", ...permissionParts); + + return { + username, + password + }; + } catch (error: unknown) { + throw new BadRequestError({ + message: "Unable to validate connection: verify credentials" + }); + } + }; + + /** + * Revokes a ACL password from the Redis server using its username and password. + */ + const revokeCredential = async (username: string) => { + const client = await $getClient(); + + try { + await client.call("ACL", "DELUSER", username); + } catch (error: unknown) { + throw new BadRequestError({ + message: "Unable to revoke credential: verify credentials" + }); + } + }; + + /** + * Issues a new set of credentials. + */ + const issueCredentials: TRotationFactoryIssueCredentials = async ( + callback + ) => { + const credentials = await $rotateAclUser(); + + return callback(credentials); + }; + + /** + * Revokes a list of credentials. + */ + const revokeCredentials: TRotationFactoryRevokeCredentials = async ( + credentials, + callback + ) => { + if (!credentials?.length) return callback(); + + for (const { username } of credentials) { + await revokeCredential(username); + // eslint-disable-next-line no-promise-executor-return + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + return callback(); + }; + + /** + * Rotates credentials by issuing new ones and revoking the old. + */ + const rotateCredentials: TRotationFactoryRotateCredentials = async ( + oldCredentials, + callback + ) => { + const newCredentials = await $rotateAclUser(); + + if (oldCredentials?.username) { + await revokeCredential(oldCredentials.username); + } + + return callback(newCredentials); + }; + + /** + * Maps the generated credentials into the secret payload format. + */ + const getSecretsPayload: TRotationFactoryGetSecretsPayload = ({ + username, + password + }) => [ + { key: secretsMapping.username, value: username }, + { key: secretsMapping.password, value: password } + ]; + + return { + issueCredentials, + revokeCredentials, + rotateCredentials, + getSecretsPayload + }; +}; diff --git a/backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-schemas.ts b/backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-schemas.ts new file mode 100644 index 000000000..4df00f336 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-schemas.ts @@ -0,0 +1,75 @@ +import { z } from "zod"; + +import { SecretRotation } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-enums"; +import { + BaseCreateSecretRotationSchema, + BaseSecretRotationSchema, + BaseUpdateSecretRotationSchema +} from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-schemas"; +import { SecretRotations } from "@app/lib/api-docs"; +import { SecretNameSchema } from "@app/server/lib/schemas"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +import { PasswordRequirementsSchema } from "../shared/general"; + +export const RedisCredentialsRotationGeneratedCredentialsSchema = z + .object({ + username: z.string(), + password: z.string() + }) + .array() + .min(1) + .max(2); + +const RedisCredentialsRotationSecretsMappingSchema = z.object({ + username: SecretNameSchema.describe(SecretRotations.SECRETS_MAPPING.REDIS_CREDENTIALS.username), + password: SecretNameSchema.describe(SecretRotations.SECRETS_MAPPING.REDIS_CREDENTIALS.password) +}); + +export const RedisCredentialsRotationParametersSchema = z.object({ + passwordRequirements: PasswordRequirementsSchema.optional(), + permissionScope: z.string().optional().describe(SecretRotations.PARAMETERS.REDIS_CREDENTIALS.permissionScope) +}); + +export const RedisCredentialsRotationTemplateSchema = z.object({ + secretsMapping: z.object({ + username: z.string(), + password: z.string() + }) +}); + +export const RedisCredentialsRotationSchema = BaseSecretRotationSchema(SecretRotation.RedisCredentials).extend({ + type: z.literal(SecretRotation.RedisCredentials), + parameters: z.object({ + passwordRequirements: PasswordRequirementsSchema.optional(), + permissionScope: z.string().optional() + }), + secretsMapping: RedisCredentialsRotationSecretsMappingSchema +}); + +export const CreateRedisCredentialsRotationSchema = BaseCreateSecretRotationSchema( + SecretRotation.RedisCredentials +).extend({ + parameters: z.object({ + passwordRequirements: PasswordRequirementsSchema.optional(), + permissionScope: z.string().optional() + }), + secretsMapping: RedisCredentialsRotationSecretsMappingSchema +}); + +export const UpdateRedisCredentialsRotationSchema = BaseUpdateSecretRotationSchema( + SecretRotation.RedisCredentials +).extend({ + parameters: z.object({ + passwordRequirements: PasswordRequirementsSchema.optional(), + permissionScope: z.string().optional() + }), + secretsMapping: RedisCredentialsRotationSecretsMappingSchema.optional() +}); + +export const RedisCredentialsRotationListItemSchema = z.object({ + name: z.literal("Redis Credentials"), + connection: z.literal(AppConnection.Redis), + type: z.literal(SecretRotation.RedisCredentials), + template: RedisCredentialsRotationTemplateSchema +}); diff --git a/backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-types.ts b/backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-types.ts new file mode 100644 index 000000000..46f217d61 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-types.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; + +import { TRedisConnection } from "@app/services/app-connection/redis"; + +import { + CreateRedisCredentialsRotationSchema, + RedisCredentialsRotationGeneratedCredentialsSchema, + RedisCredentialsRotationListItemSchema, + RedisCredentialsRotationSchema +} from "./redis-credentials-rotation-schemas"; + +export type TRedisCredentialsRotation = z.infer; + +export type TRedisCredentialsRotationInput = z.infer; + +export type TRedisCredentialsRotationListItem = z.infer; + +export type TRedisCredentialsRotationWithConnection = TRedisCredentialsRotation & { + connection: TRedisConnection; +}; + +export type TRedisCredentialsRotationGeneratedCredentials = z.infer< + typeof RedisCredentialsRotationGeneratedCredentialsSchema +>; diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts index cf0fe578a..661a2399a 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-enums.ts @@ -7,7 +7,8 @@ export enum SecretRotation { AzureClientSecret = "azure-client-secret", AwsIamUserSecret = "aws-iam-user-secret", LdapPassword = "ldap-password", - OktaClientSecret = "okta-client-secret" + OktaClientSecret = "okta-client-secret", + RedisCredentials = "redis-credentials" } export enum SecretRotationStatus { diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts index 4d8cea6a3..e4e6a8531 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-fns.ts @@ -14,6 +14,7 @@ import { MYSQL_CREDENTIALS_ROTATION_LIST_OPTION } from "./mysql-credentials"; import { OKTA_CLIENT_SECRET_ROTATION_LIST_OPTION } from "./okta-client-secret"; import { ORACLEDB_CREDENTIALS_ROTATION_LIST_OPTION } from "./oracledb-credentials"; import { POSTGRES_CREDENTIALS_ROTATION_LIST_OPTION } from "./postgres-credentials"; +import { REDIS_CREDENTIALS_ROTATION_LIST_OPTION } from "./redis-credentials"; import { TSecretRotationV2DALFactory } from "./secret-rotation-v2-dal"; import { SecretRotation, SecretRotationStatus } from "./secret-rotation-v2-enums"; import { TSecretRotationV2ServiceFactory, TSecretRotationV2ServiceFactoryDep } from "./secret-rotation-v2-service"; @@ -35,7 +36,8 @@ const SECRET_ROTATION_LIST_OPTIONS: Record { diff --git a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-maps.ts b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-maps.ts index d9a771101..2087fa195 100644 --- a/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-maps.ts +++ b/backend/src/ee/services/secret-rotation-v2/secret-rotation-v2-maps.ts @@ -10,7 +10,8 @@ export const SECRET_ROTATION_NAME_MAP: Record = { [SecretRotation.AzureClientSecret]: "Azure Client Secret", [SecretRotation.AwsIamUserSecret]: "AWS IAM User Secret", [SecretRotation.LdapPassword]: "LDAP Password", - [SecretRotation.OktaClientSecret]: "Okta Client Secret" + [SecretRotation.OktaClientSecret]: "Okta Client Secret", + [SecretRotation.RedisCredentials]: "Redis Credentials" }; export const SECRET_ROTATION_CONNECTION_MAP: Record = { @@ -22,5 +23,6 @@ export const SECRET_ROTATION_CONNECTION_MAP: Record { diff --git a/backend/src/server/routes/v1/app-connection-routers/index.ts b/backend/src/server/routes/v1/app-connection-routers/index.ts index 70804d173..11d9ce5e6 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -31,6 +31,7 @@ import { registerNetlifyConnectionRouter } from "./netlify-connection-router"; import { registerOktaConnectionRouter } from "./okta-connection-router"; import { registerPostgresConnectionRouter } from "./postgres-connection-router"; import { registerRailwayConnectionRouter } from "./railway-connection-router"; +import { registerRedisConnectionRouter } from "./redis-connection-router"; import { registerRenderConnectionRouter } from "./render-connection-router"; import { registerSupabaseConnectionRouter } from "./supabase-connection-router"; import { registerTeamCityConnectionRouter } from "./teamcity-connection-router"; @@ -80,5 +81,6 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record { + registerAppConnectionEndpoints({ + app: AppConnection.Redis, + server, + sanitizedResponseSchema: SanitizedRedisConnectionSchema, + createSchema: CreateRedisConnectionSchema, + updateSchema: UpdateRedisConnectionSchema + }); +}; diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index 76dcdd5f0..996cd872a 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -36,7 +36,8 @@ export enum AppConnection { Supabase = "supabase", DigitalOcean = "digital-ocean", Netlify = "netlify", - Okta = "okta" + Okta = "okta", + Redis = "redis" } export enum AWSRegion { diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index f88b5a357..7455a6ac0 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -111,6 +111,7 @@ import { getNetlifyConnectionListItem, validateNetlifyConnectionCredentials } fr import { getOktaConnectionListItem, OktaConnectionMethod, validateOktaConnectionCredentials } from "./okta"; import { getPostgresConnectionListItem, PostgresConnectionMethod } from "./postgres"; import { getRailwayConnectionListItem, validateRailwayConnectionCredentials } from "./railway"; +import { getRedisConnectionListItem, RedisConnectionMethod, validateRedisConnectionCredentials } from "./redis"; import { RenderConnectionMethod } from "./render/render-connection-enums"; import { getRenderConnectionListItem, validateRenderConnectionCredentials } from "./render/render-connection-fns"; import { @@ -191,7 +192,8 @@ export const listAppConnectionOptions = (projectType?: ProjectType) => { getSupabaseConnectionListItem(), getDigitalOceanConnectionListItem(), getNetlifyConnectionListItem(), - getOktaConnectionListItem() + getOktaConnectionListItem(), + getRedisConnectionListItem() ] .filter((option) => { switch (projectType) { @@ -317,7 +319,8 @@ export const validateAppConnectionCredentials = async ( [AppConnection.Supabase]: validateSupabaseConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.DigitalOcean]: validateDigitalOceanConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Okta]: validateOktaConnectionCredentials as TAppConnectionCredentialsValidator, - [AppConnection.Netlify]: validateNetlifyConnectionCredentials as TAppConnectionCredentialsValidator + [AppConnection.Netlify]: validateNetlifyConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.Redis]: validateRedisConnectionCredentials as TAppConnectionCredentialsValidator }; return VALIDATE_APP_CONNECTION_CREDENTIALS_MAP[appConnection.app](appConnection, gatewayService, gatewayV2Service); @@ -364,6 +367,7 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => case MySqlConnectionMethod.UsernameAndPassword: case OracleDBConnectionMethod.UsernameAndPassword: case AzureADCSConnectionMethod.UsernamePassword: + case RedisConnectionMethod.UsernameAndPassword: return "Username & Password"; case WindmillConnectionMethod.AccessToken: case HCVaultConnectionMethod.AccessToken: @@ -451,7 +455,8 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.Supabase]: platformManagedCredentialsNotSupported, [AppConnection.DigitalOcean]: platformManagedCredentialsNotSupported, [AppConnection.Netlify]: platformManagedCredentialsNotSupported, - [AppConnection.Okta]: platformManagedCredentialsNotSupported + [AppConnection.Okta]: platformManagedCredentialsNotSupported, + [AppConnection.Redis]: platformManagedCredentialsNotSupported }; export const enterpriseAppCheck = async ( diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index a2ce02669..e3235d2f7 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -38,7 +38,8 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.Supabase]: "Supabase", [AppConnection.DigitalOcean]: "DigitalOcean App Platform", [AppConnection.Netlify]: "Netlify", - [AppConnection.Okta]: "Okta" + [AppConnection.Okta]: "Okta", + [AppConnection.Redis]: "Redis" }; export const APP_CONNECTION_PLAN_MAP: Record = { @@ -79,5 +80,6 @@ export const APP_CONNECTION_PLAN_MAP: Record>>; @@ -306,6 +313,7 @@ export type TAppConnectionInput = { id: string } & ( | TDigitalOceanConnectionInput | TNetlifyConnectionInput | TOktaConnectionInput + | TRedisConnectionInput ); export type TSqlConnectionInput = @@ -368,7 +376,8 @@ export type TAppConnectionConfig = | TSupabaseConnectionConfig | TDigitalOceanConnectionConfig | TNetlifyConnectionConfig - | TOktaConnectionConfig; + | TOktaConnectionConfig + | TRedisConnectionConfig; export type TValidateAppConnectionCredentialsSchema = | TValidateAwsConnectionCredentialsSchema @@ -408,7 +417,8 @@ export type TValidateAppConnectionCredentialsSchema = | TValidateSupabaseConnectionCredentialsSchema | TValidateDigitalOceanCredentialsSchema | TValidateNetlifyConnectionCredentialsSchema - | TValidateOktaConnectionCredentialsSchema; + | TValidateOktaConnectionCredentialsSchema + | TValidateRedisConnectionCredentialsSchema; export type TListAwsConnectionKmsKeys = { connectionId: string; diff --git a/backend/src/services/app-connection/redis/index.ts b/backend/src/services/app-connection/redis/index.ts new file mode 100644 index 000000000..76b071958 --- /dev/null +++ b/backend/src/services/app-connection/redis/index.ts @@ -0,0 +1,4 @@ +export * from "./redis-connection-enums"; +export * from "./redis-connection-fns"; +export * from "./redis-connection-schemas"; +export * from "./redis-connection-types"; diff --git a/backend/src/services/app-connection/redis/redis-connection-enums.ts b/backend/src/services/app-connection/redis/redis-connection-enums.ts new file mode 100644 index 000000000..01befeee9 --- /dev/null +++ b/backend/src/services/app-connection/redis/redis-connection-enums.ts @@ -0,0 +1,3 @@ +export enum RedisConnectionMethod { + UsernameAndPassword = "username-and-password" +} diff --git a/backend/src/services/app-connection/redis/redis-connection-fns.ts b/backend/src/services/app-connection/redis/redis-connection-fns.ts new file mode 100644 index 000000000..901016b3c --- /dev/null +++ b/backend/src/services/app-connection/redis/redis-connection-fns.ts @@ -0,0 +1,56 @@ +import Redis from "ioredis"; + +import { BadRequestError } from "@app/lib/errors"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; + +import { RedisConnectionMethod } from "./redis-connection-enums"; +import { TRedisConnectionConfig } from "./redis-connection-types"; + +export const getRedisConnectionListItem = () => { + return { + name: "Redis" as const, + app: AppConnection.Redis as const, + methods: Object.values(RedisConnectionMethod) as [RedisConnectionMethod.UsernameAndPassword], + supportsPlatformManagement: false as const + }; +}; + +export const validateRedisConnectionCredentials = async (config: TRedisConnectionConfig) => { + let connection: Redis | null = null; + try { + connection = new Redis({ + username: config.credentials.username, + host: config.credentials.host, + port: config.credentials.port, + password: config.credentials.password, + ...(config.credentials.sslEnabled && { + tls: { + rejectUnauthorized: config.credentials.sslRejectUnauthorized, + ca: config.credentials.sslCertificate + } + }) + }); + + let result: string; + if (config.credentials.password) { + result = await connection.auth(config.credentials.username, config.credentials.password, () => {}); + } else { + result = await connection.auth(config.credentials.username, () => {}); + } + + if (result !== "OK") { + throw new BadRequestError({ message: `Invalid credentials, Redis returned ${result} status` }); + } + + return config.credentials; + } catch (err) { + if (err instanceof BadRequestError) { + throw err; + } + throw new BadRequestError({ + message: `Unable to validate connection: ${(err as Error)?.message || "verify credentials"}` + }); + } finally { + if (connection) await connection.quit(); + } +}; diff --git a/backend/src/services/app-connection/redis/redis-connection-schemas.ts b/backend/src/services/app-connection/redis/redis-connection-schemas.ts new file mode 100644 index 000000000..60c8b8458 --- /dev/null +++ b/backend/src/services/app-connection/redis/redis-connection-schemas.ts @@ -0,0 +1,87 @@ +import z from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { AppConnection } from "../app-connection-enums"; +import { RedisConnectionMethod } from "./redis-connection-enums"; + +export const BaseRedisUsernameAndPasswordConnectionSchema = z.object({ + host: z.string().toLowerCase().min(1), + port: z.coerce.number(), + username: z.string().min(1), + password: z.string().min(1).optional(), + + sslRejectUnauthorized: z.boolean(), + sslEnabled: z.boolean(), + sslCertificate: z + .string() + .trim() + .transform((value) => value || undefined) + .optional() +}); + +export const RedisConnectionAccessTokenCredentialsSchema = BaseRedisUsernameAndPasswordConnectionSchema; + +const BaseRedisConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.Redis) }); + +export const RedisConnectionSchema = BaseRedisConnectionSchema.extend({ + method: z.literal(RedisConnectionMethod.UsernameAndPassword), + credentials: RedisConnectionAccessTokenCredentialsSchema +}); + +export const SanitizedRedisConnectionSchema = z.discriminatedUnion("method", [ + BaseRedisConnectionSchema.extend({ + method: z.literal(RedisConnectionMethod.UsernameAndPassword), + credentials: RedisConnectionAccessTokenCredentialsSchema.pick({ + host: true, + port: true, + username: true, + sslEnabled: true, + sslRejectUnauthorized: true, + sslCertificate: true + }) + }) +]); + +export const ValidateRedisConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z + .literal(RedisConnectionMethod.UsernameAndPassword) + .describe(AppConnections.CREATE(AppConnection.Redis).method), + credentials: RedisConnectionAccessTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.Redis).credentials + ) + }) +]); + +export const CreateRedisConnectionSchema = ValidateRedisConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.Redis, { + supportsPlatformManagedCredentials: true, + supportsGateways: true + }) +); + +export const UpdateRedisConnectionSchema = z + .object({ + credentials: RedisConnectionAccessTokenCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.Redis).credentials + ) + }) + .and( + GenericUpdateAppConnectionFieldsSchema(AppConnection.Redis, { + supportsPlatformManagedCredentials: true, + supportsGateways: true + }) + ); + +export const RedisConnectionListItemSchema = z.object({ + name: z.literal("Redis"), + app: z.literal(AppConnection.Redis), + methods: z.nativeEnum(RedisConnectionMethod).array(), + supportsPlatformManagement: z.literal(false) +}); diff --git a/backend/src/services/app-connection/redis/redis-connection-types.ts b/backend/src/services/app-connection/redis/redis-connection-types.ts new file mode 100644 index 000000000..2d1ba7699 --- /dev/null +++ b/backend/src/services/app-connection/redis/redis-connection-types.ts @@ -0,0 +1,22 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateRedisConnectionSchema, + RedisConnectionSchema, + ValidateRedisConnectionCredentialsSchema +} from "./redis-connection-schemas"; + +export type TRedisConnection = z.infer; + +export type TRedisConnectionInput = z.infer & { + app: AppConnection.Redis; +}; + +export type TValidateRedisConnectionCredentialsSchema = typeof ValidateRedisConnectionCredentialsSchema; + +export type TRedisConnectionConfig = DiscriminativePick & { + orgId: string; +}; diff --git a/frontend/public/images/integrations/Redis.png b/frontend/public/images/integrations/Redis.png new file mode 100644 index 0000000000000000000000000000000000000000..3ef8adffda10502a274319cf6dec210ea7e005ec GIT binary patch literal 3478 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 literal 0 HcmV?d00001 diff --git a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewRedisCredentialsRotationGeneratedCredentials.tsx b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewRedisCredentialsRotationGeneratedCredentials.tsx new file mode 100644 index 000000000..18feefa09 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewRedisCredentialsRotationGeneratedCredentials.tsx @@ -0,0 +1,38 @@ +import { CredentialDisplay } from "@app/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/shared/CredentialDisplay"; + +import { ViewRotationGeneratedCredentialsDisplay } from "./shared"; +import { TRedisCredentialsRotationGeneratedCredentialsResponse } from "@app/hooks/api/secretRotationsV2/types/redis-credentials-rotation"; + +type Props = { + generatedCredentialsResponse: TRedisCredentialsRotationGeneratedCredentialsResponse; +}; + +export const ViewRedisCredentialsRotationGeneratedCredentials = ({ + generatedCredentialsResponse: { generatedCredentials, activeIndex } +}: Props) => { + const inactiveIndex = activeIndex === 0 ? 1 : 0; + + const activeCredentials = generatedCredentials[activeIndex]; + const inactiveCredentials = generatedCredentials[inactiveIndex]; + + return ( + + {activeCredentials?.username} + + {activeCredentials?.password} + + + } + inactiveCredentials={ + <> + {inactiveCredentials?.username} + + {inactiveCredentials?.password} + + + } + /> + ); +}; diff --git a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx index 33d3fccc1..e8553f6d9 100644 --- a/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx +++ b/frontend/src/components/secret-rotations-v2/ViewSecretRotationV2GeneratedCredentials/ViewSecretRotationV2GeneratedCredentials.tsx @@ -23,6 +23,7 @@ import { import { ViewSqlCredentialsRotationGeneratedCredentials } from "./shared"; import { ViewAwsIamUserSecretRotationGeneratedCredentials } from "./ViewAwsIamUserSecretRotationGeneratedCredentials"; import { ViewOktaClientSecretRotationGeneratedCredentials } from "./ViewOktaClientSecretRotationGeneratedCredentials"; +import { ViewRedisCredentialsRotationGeneratedCredentials } from "./ViewRedisCredentialsRotationGeneratedCredentials"; type Props = { secretRotation?: TSecretRotationV2; @@ -107,6 +108,13 @@ const Content = ({ secretRotation }: ContentProps) => { /> ); break; + case SecretRotation.RedisCredentials: + Component = ( + + ); + break; default: throw new Error("Unhandled View Generated Credential Rotation Type"); } diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/RedisCredentialsRotationParametersFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/RedisCredentialsRotationParametersFields.tsx new file mode 100644 index 000000000..2c15b3ca8 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/RedisCredentialsRotationParametersFields.tsx @@ -0,0 +1,197 @@ +import { Controller, useFormContext } from "react-hook-form"; + +import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas"; +import { FormControl, Input } from "@app/components/v2"; +import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; +import { DEFAULT_PASSWORD_REQUIREMENTS } from "../schemas/shared"; + +export const RedisCredentialsRotationParametersFields = () => { + const { control } = useFormContext< + TSecretRotationV2Form & { + type: SecretRotation.RedisCredentials; + } + >(); + + return ( + <> +
    + } + label="Permission Scope" + isError={Boolean(error)} + errorText={error?.message} + > + + + )} + /> +
    + +
    +
    + Password Requirements +
    +
    + ( + + field.onChange(Number(e.target.value))} + /> + + )} + /> + ( + + field.onChange(Number(e.target.value))} + /> + + )} + /> + ( + + field.onChange(Number(e.target.value))} + /> + + )} + /> + ( + + field.onChange(Number(e.target.value))} + /> + + )} + /> + ( + + field.onChange(Number(e.target.value))} + /> + + )} + /> + ( + + field.onChange(e.target.value)} + /> + + )} + /> +
    +
    + + ); +}; diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/SecretRotationV2ParametersFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/SecretRotationV2ParametersFields.tsx index 3f489b04e..f8f2685ec 100644 --- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/SecretRotationV2ParametersFields.tsx +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ParametersFields/SecretRotationV2ParametersFields.tsx @@ -9,6 +9,7 @@ import { AzureClientSecretRotationParametersFields } from "./AzureClientSecretRo import { LdapPasswordRotationParametersFields } from "./LdapPasswordRotationParametersFields"; import { OktaClientSecretRotationParametersFields } from "./OktaClientSecretRotationParametersFields"; import { SqlCredentialsRotationParametersFields } from "./shared"; +import { RedisCredentialsRotationParametersFields } from "./RedisCredentialsRotationParametersFields"; const COMPONENT_MAP: Record = { [SecretRotation.PostgresCredentials]: SqlCredentialsRotationParametersFields, @@ -19,7 +20,8 @@ const COMPONENT_MAP: Record = { [SecretRotation.AzureClientSecret]: AzureClientSecretRotationParametersFields, [SecretRotation.LdapPassword]: LdapPasswordRotationParametersFields, [SecretRotation.AwsIamUserSecret]: AwsIamUserSecretRotationParametersFields, - [SecretRotation.OktaClientSecret]: OktaClientSecretRotationParametersFields + [SecretRotation.OktaClientSecret]: OktaClientSecretRotationParametersFields, + [SecretRotation.RedisCredentials]: RedisCredentialsRotationParametersFields }; export const SecretRotationV2ParametersFields = () => { diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/RedisCredentialsRotationReviewFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/RedisCredentialsRotationReviewFields.tsx new file mode 100644 index 000000000..871faf8b6 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/RedisCredentialsRotationReviewFields.tsx @@ -0,0 +1,50 @@ +import { useFormContext } from "react-hook-form"; + +import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas"; +import { GenericFieldLabel } from "@app/components/v2"; +import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; + +import { SecretRotationReviewSection } from "./shared"; + +export const RedisCredentialsRotationReviewFields = () => { + const { watch } = useFormContext< + TSecretRotationV2Form & { + type: SecretRotation.RedisCredentials; + } + >(); + + const [parameters, { username, password }] = watch(["parameters", "secretsMapping"]); + + const { passwordRequirements, permissionScope } = parameters; + return ( + <> + + {permissionScope} + + {passwordRequirements && ( + + {passwordRequirements.length} + + {passwordRequirements.required.digits} + + + {passwordRequirements.required.lowercase} + + + {passwordRequirements.required.uppercase} + + + {passwordRequirements.required.symbols} + + + {passwordRequirements.allowedSymbols} + + + )} + + {username} + {password} + + + ); +}; diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx index 636cc98cc..05b6ad63c 100644 --- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2ReviewFields/SecretRotationReviewFields.tsx @@ -12,6 +12,7 @@ import { AzureClientSecretRotationReviewFields } from "./AzureClientSecretRotati import { LdapPasswordRotationReviewFields } from "./LdapPasswordRotationReviewFields"; import { OktaClientSecretRotationReviewFields } from "./OktaClientSecretRotationReviewFields"; import { SqlCredentialsRotationReviewFields } from "./shared"; +import { RedisCredentialsRotationReviewFields } from "./RedisCredentialsRotationReviewFields"; const COMPONENT_MAP: Record = { [SecretRotation.PostgresCredentials]: SqlCredentialsRotationReviewFields, @@ -22,7 +23,8 @@ const COMPONENT_MAP: Record = { [SecretRotation.AzureClientSecret]: AzureClientSecretRotationReviewFields, [SecretRotation.LdapPassword]: LdapPasswordRotationReviewFields, [SecretRotation.AwsIamUserSecret]: AwsIamUserSecretRotationReviewFields, - [SecretRotation.OktaClientSecret]: OktaClientSecretRotationReviewFields + [SecretRotation.OktaClientSecret]: OktaClientSecretRotationReviewFields, + [SecretRotation.RedisCredentials]: RedisCredentialsRotationReviewFields }; export const SecretRotationV2ReviewFields = () => { diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/RedisCredentialsRotationSecretsMappingFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/RedisCredentialsRotationSecretsMappingFields.tsx new file mode 100644 index 000000000..2ffac6a62 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/RedisCredentialsRotationSecretsMappingFields.tsx @@ -0,0 +1,58 @@ +import { Controller, useFormContext } from "react-hook-form"; + +import { TSecretRotationV2Form } from "@app/components/secret-rotations-v2/forms/schemas"; +import { FormControl, Input } from "@app/components/v2"; +import { SecretRotation, useSecretRotationV2Option } from "@app/hooks/api/secretRotationsV2"; + +import { SecretsMappingTable } from "./shared"; + +export const RedisCredentialsRotationSecretsMappingFields = () => { + const { control } = useFormContext< + TSecretRotationV2Form & { + type: SecretRotation.RedisCredentials; + } + >(); + + const { rotationOption } = useSecretRotationV2Option(SecretRotation.RedisCredentials); + + const items = [ + { + name: "Username", + input: ( + ( + + + + )} + control={control} + name="secretsMapping.username" + /> + ) + }, + { + name: "Password", + input: ( + ( + + + + )} + control={control} + name="secretsMapping.password" + /> + ) + } + ]; + + return ; +}; diff --git a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx index dd0ce9cab..15338c48a 100644 --- a/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx +++ b/frontend/src/components/secret-rotations-v2/forms/SecretRotationV2SecretsMappingFields/SecretRotationV2SecretsMappingFields.tsx @@ -9,6 +9,7 @@ import { AzureClientSecretRotationSecretsMappingFields } from "./AzureClientSecr import { LdapPasswordRotationSecretsMappingFields } from "./LdapPasswordRotationSecretsMappingFields"; import { OktaClientSecretRotationSecretsMappingFields } from "./OktaClientSecretRotationSecretsMappingFields"; import { SqlCredentialsRotationSecretsMappingFields } from "./shared"; +import { RedisCredentialsRotationSecretsMappingFields } from "./RedisCredentialsRotationSecretsMappingFields"; const COMPONENT_MAP: Record = { [SecretRotation.PostgresCredentials]: SqlCredentialsRotationSecretsMappingFields, @@ -19,7 +20,8 @@ const COMPONENT_MAP: Record = { [SecretRotation.AzureClientSecret]: AzureClientSecretRotationSecretsMappingFields, [SecretRotation.LdapPassword]: LdapPasswordRotationSecretsMappingFields, [SecretRotation.AwsIamUserSecret]: AwsIamUserSecretRotationSecretsMappingFields, - [SecretRotation.OktaClientSecret]: OktaClientSecretRotationSecretsMappingFields + [SecretRotation.OktaClientSecret]: OktaClientSecretRotationSecretsMappingFields, + [SecretRotation.RedisCredentials]: RedisCredentialsRotationSecretsMappingFields }; export const SecretRotationV2SecretsMappingFields = () => { diff --git a/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts b/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts index a6ebe2f64..199036a8f 100644 --- a/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts +++ b/frontend/src/components/secret-rotations-v2/forms/schemas/index.ts @@ -12,6 +12,7 @@ import { LdapPasswordRotationMethod } from "@app/hooks/api/secretRotationsV2/typ import { OktaClientSecretRotationSchema } from "./okta-client-secret-rotation-schema"; import { OracleDBCredentialsRotationSchema } from "./oracledb-credentials-rotation-schema"; +import { RedisCredentialsRotationSchema } from "./redis-credentials-rotation-schema"; export const SecretRotationV2FormSchema = (isUpdate: boolean) => z @@ -25,7 +26,8 @@ export const SecretRotationV2FormSchema = (isUpdate: boolean) => OracleDBCredentialsRotationSchema, LdapPasswordRotationSchema, AwsIamUserSecretRotationSchema, - OktaClientSecretRotationSchema + OktaClientSecretRotationSchema, + RedisCredentialsRotationSchema ]), z.object({ id: z.string().optional() }) ) diff --git a/frontend/src/components/secret-rotations-v2/forms/schemas/redis-credentials-rotation-schema.ts b/frontend/src/components/secret-rotations-v2/forms/schemas/redis-credentials-rotation-schema.ts new file mode 100644 index 000000000..8e4dad117 --- /dev/null +++ b/frontend/src/components/secret-rotations-v2/forms/schemas/redis-credentials-rotation-schema.ts @@ -0,0 +1,20 @@ +import { z } from "zod"; + +import { BaseSecretRotationSchema } from "@app/components/secret-rotations-v2/forms/schemas/base-secret-rotation-v2-schema"; +import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; + +import { PasswordRequirementsSchema } from "./shared"; + +export const RedisCredentialsRotationSchema = z + .object({ + type: z.literal(SecretRotation.RedisCredentials), + parameters: z.object({ + passwordRequirements: PasswordRequirementsSchema.optional(), + permissionScope: z.string().optional() + }), + secretsMapping: z.object({ + username: z.string().trim().min(1, "Username required"), + password: z.string().trim().min(1, "Password required") + }) + }) + .merge(BaseSecretRotationSchema); diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index 99103c794..ab7ee7c90 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -113,7 +113,8 @@ export const APP_CONNECTION_MAP: Record< name: "Netlify", image: "Netlify.png" }, - [AppConnection.Okta]: { name: "Okta", image: "Okta.png" } + [AppConnection.Okta]: { name: "Okta", image: "Okta.png" }, + [AppConnection.Redis]: { name: "Redis", image: "Redis.png" } }; export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) => { diff --git a/frontend/src/helpers/secretRotationsV2.ts b/frontend/src/helpers/secretRotationsV2.ts index 2979a7623..d3bb83f19 100644 --- a/frontend/src/helpers/secretRotationsV2.ts +++ b/frontend/src/helpers/secretRotationsV2.ts @@ -49,6 +49,11 @@ export const SECRET_ROTATION_MAP: Record< name: "Okta Client Secret", image: "Okta.png", size: 50 + }, + [SecretRotation.RedisCredentials]: { + name: "Redis Credentials", + image: "Redis.png", + size: 50 } }; @@ -61,7 +66,8 @@ export const SECRET_ROTATION_CONNECTION_MAP: Record = { [SecretRotation.AzureClientSecret]: true, [SecretRotation.LdapPassword]: false, [SecretRotation.AwsIamUserSecret]: true, - [SecretRotation.OktaClientSecret]: true + [SecretRotation.OktaClientSecret]: true, + [SecretRotation.RedisCredentials]: true }; export const getRotateAtLocal = ({ hours, minutes }: TSecretRotationV2["rotateAtUtc"]) => { diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index 7b041b797..e897cf0f0 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -36,5 +36,6 @@ export enum AppConnection { Supabase = "supabase", DigitalOcean = "digital-ocean", Netlify = "netlify", - Okta = "okta" + Okta = "okta", + Redis = "redis" } diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts index 67d8feb48..fdaae2c74 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -168,6 +168,10 @@ export type TAzureAdCsConnectionOption = TAppConnectionOptionBase & { app: AppConnection.AzureADCS; }; +export type TRedisConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.Redis; +}; + export type TAppConnectionOption = | TAwsConnectionOption | TGitHubConnectionOption @@ -247,4 +251,5 @@ export type TAppConnectionOptionMap = { [AppConnection.Netlify]: TNetlifyConnectionOption; [AppConnection.Okta]: TOktaConnectionOption; [AppConnection.AzureADCS]: TAzureAdCsConnectionOption; + [AppConnection.Redis]: TRedisConnectionOption; }; diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index 8c1d86ca3..9f8df7cfa 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -31,6 +31,7 @@ import { TOktaConnection } from "./okta-connection"; import { TOracleDBConnection } from "./oracledb-connection"; import { TPostgresConnection } from "./postgres-connection"; import { TRailwayConnection } from "./railway-connection"; +import { TRedisConnection } from "./redis-connection"; import { TRenderConnection } from "./render-connection"; import { TSupabaseConnection } from "./supabase-connection"; import { TTeamCityConnection } from "./teamcity-connection"; @@ -68,6 +69,7 @@ export * from "./okta-connection"; export * from "./oracledb-connection"; export * from "./postgres-connection"; export * from "./railway-connection"; +export * from "./redis-connection"; export * from "./render-connection"; export * from "./supabase-connection"; export * from "./teamcity-connection"; @@ -114,7 +116,8 @@ export type TAppConnection = | TSupabaseConnection | TDigitalOceanConnection | TNetlifyConnection - | TOktaConnection; + | TOktaConnection + | TRedisConnection; export type TAvailableAppConnection = Pick; diff --git a/frontend/src/hooks/api/appConnections/types/redis-connection.ts b/frontend/src/hooks/api/appConnections/types/redis-connection.ts new file mode 100644 index 000000000..efbb78b07 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/redis-connection.ts @@ -0,0 +1,21 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum RedisConnectionMethod { + UsernameAndPassword = "username-and-password" +} + +export type TRedisConnectionCredentials = { + host: string; + port: number; + username: string; + password?: string; + sslEnabled: boolean; + sslRejectUnauthorized: boolean; + sslCertificate?: string; +}; + +export type TRedisConnection = TRootAppConnection & { app: AppConnection.Redis } & { + method: RedisConnectionMethod.UsernameAndPassword; + credentials: TRedisConnectionCredentials; +}; diff --git a/frontend/src/hooks/api/secretRotationsV2/enums.ts b/frontend/src/hooks/api/secretRotationsV2/enums.ts index be692cee3..264a6a4a4 100644 --- a/frontend/src/hooks/api/secretRotationsV2/enums.ts +++ b/frontend/src/hooks/api/secretRotationsV2/enums.ts @@ -7,7 +7,8 @@ export enum SecretRotation { AzureClientSecret = "azure-client-secret", LdapPassword = "ldap-password", AwsIamUserSecret = "aws-iam-user-secret", - OktaClientSecret = "okta-client-secret" + OktaClientSecret = "okta-client-secret", + RedisCredentials = "redis-credentials" } export enum SecretRotationStatus { diff --git a/frontend/src/hooks/api/secretRotationsV2/types/index.ts b/frontend/src/hooks/api/secretRotationsV2/types/index.ts index 06783944b..a04b0e020 100644 --- a/frontend/src/hooks/api/secretRotationsV2/types/index.ts +++ b/frontend/src/hooks/api/secretRotationsV2/types/index.ts @@ -44,6 +44,11 @@ import { TOracleDBCredentialsRotation, TOracleDBCredentialsRotationGeneratedCredentialsResponse } from "./oracledb-credentials-rotation"; +import { + TRedisCredentialsRotation, + TRedisCredentialsRotationGeneratedCredentialsResponse, + TRedisCredentialsRotationOption +} from "./redis-credentials-rotation"; export type TSecretRotationV2 = ( | TPostgresCredentialsRotation @@ -55,6 +60,7 @@ export type TSecretRotationV2 = ( | TLdapPasswordRotation | TAwsIamUserSecretRotation | TOktaClientSecretRotation + | TRedisCredentialsRotation ) & { secrets: (SecretV3RawSanitized | null)[]; }; @@ -65,7 +71,8 @@ export type TSecretRotationV2Option = | TAzureClientSecretRotationOption | TLdapPasswordRotationOption | TAwsIamUserSecretRotationOption - | TOktaClientSecretRotationOption; + | TOktaClientSecretRotationOption + | TRedisCredentialsRotationOption; export type TListSecretRotationV2Options = { secretRotationOptions: TSecretRotationV2Option[] }; @@ -80,7 +87,8 @@ export type TViewSecretRotationGeneratedCredentialsResponse = | TAzureClientSecretRotationGeneratedCredentialsResponse | TLdapPasswordRotationGeneratedCredentialsResponse | TAwsIamUserSecretRotationGeneratedCredentialsResponse - | TOktaClientSecretRotationGeneratedCredentialsResponse; + | TOktaClientSecretRotationGeneratedCredentialsResponse + | TRedisCredentialsRotationGeneratedCredentialsResponse; export type TCreateSecretRotationV2DTO = DiscriminativePick< TSecretRotationV2, @@ -133,6 +141,7 @@ export type TSecretRotationOptionMap = { [SecretRotation.LdapPassword]: TLdapPasswordRotationOption; [SecretRotation.AwsIamUserSecret]: TAwsIamUserSecretRotationOption; [SecretRotation.OktaClientSecret]: TOktaClientSecretRotationOption; + [SecretRotation.RedisCredentials]: TRedisCredentialsRotationOption; }; export type TSecretRotationGeneratedCredentialsResponseMap = { @@ -145,4 +154,5 @@ export type TSecretRotationGeneratedCredentialsResponseMap = { [SecretRotation.LdapPassword]: TLdapPasswordRotationGeneratedCredentialsResponse; [SecretRotation.AwsIamUserSecret]: TAwsIamUserSecretRotationGeneratedCredentialsResponse; [SecretRotation.OktaClientSecret]: TOktaClientSecretRotationGeneratedCredentialsResponse; + [SecretRotation.RedisCredentials]: TRedisCredentialsRotationGeneratedCredentialsResponse; }; diff --git a/frontend/src/hooks/api/secretRotationsV2/types/redis-credentials-rotation.ts b/frontend/src/hooks/api/secretRotationsV2/types/redis-credentials-rotation.ts new file mode 100644 index 000000000..bcbc7a3f4 --- /dev/null +++ b/frontend/src/hooks/api/secretRotationsV2/types/redis-credentials-rotation.ts @@ -0,0 +1,39 @@ +import { TPasswordRequirements } from "@app/components/secret-rotations-v2/forms/schemas/shared"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { SecretRotation } from "@app/hooks/api/secretRotationsV2"; +import { + TSecretRotationV2Base, + TSecretRotationV2GeneratedCredentialsResponseBase +} from "@app/hooks/api/secretRotationsV2/types/shared"; + +export type TRedisCredentialsRotation = TSecretRotationV2Base & { + type: SecretRotation.RedisCredentials; + parameters: { + passwordRequirements?: TPasswordRequirements; + permissionScope?: string; + }; + secretsMapping: { + username: string; + password: string; + }; +}; + +export type TRedisCredentialsRotationGeneratedCredentials = { + username: string; + password: string; +}; + +export type TRedisCredentialsRotationGeneratedCredentialsResponse = + TSecretRotationV2GeneratedCredentialsResponseBase< + SecretRotation.RedisCredentials, + TRedisCredentialsRotationGeneratedCredentials + >; + +export type TRedisCredentialsRotationOption = { + name: string; + type: SecretRotation.RedisCredentials; + connection: AppConnection.Redis; + template: { + secretsMapping: TRedisCredentialsRotation["secretsMapping"]; + }; +}; diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx index f82e4107d..fdb820962 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx @@ -47,6 +47,7 @@ import { TerraformCloudConnectionForm } from "./TerraformCloudConnectionForm"; import { VercelConnectionForm } from "./VercelConnectionForm"; import { WindmillConnectionForm } from "./WindmillConnectionForm"; import { ZabbixConnectionForm } from "./ZabbixConnectionForm"; +import { RedisConnectionForm } from "./RedisConnectionForm"; type FormProps = { onComplete: (appConnection: TAppConnection) => void; @@ -167,6 +168,8 @@ const CreateForm = ({ app, onComplete, projectId }: CreateFormProps) => { return ; case AppConnection.Okta: return ; + case AppConnection.Redis: + return ; default: throw new Error(`Unhandled App ${app}`); } diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/RedisConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/RedisConnectionForm.tsx new file mode 100644 index 000000000..602b9486c --- /dev/null +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/RedisConnectionForm.tsx @@ -0,0 +1,316 @@ +import { useState } from "react"; +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { Tab } from "@headlessui/react"; +import { + Button, + FormControl, + Input, + ModalClose, + SecretInput, + Select, + SelectItem, + Switch, + TextArea, + Tooltip +} from "@app/components/v2"; +import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; +import { RedisConnectionMethod, TRedisConnection } from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +import { + genericAppConnectionFieldsSchema, + GenericAppConnectionsFields +} from "./GenericAppConnectionFields"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; + +type Props = { + appConnection?: TRedisConnection; + onSubmit: (formData: FormData) => Promise; +}; + +const rootSchema = genericAppConnectionFieldsSchema.extend({ + app: z.literal(AppConnection.Redis) +}); + +const formSchema = z.discriminatedUnion("method", [ + rootSchema.extend({ + method: z.literal(RedisConnectionMethod.UsernameAndPassword), + credentials: z.object({ + host: z.string().trim().min(1, "Host required"), + port: z.coerce.number().default(6379), + username: z.string().trim().min(1, "Username required"), + password: z.string().trim().optional(), + sslEnabled: z.boolean().default(false), + sslRejectUnauthorized: z.boolean().default(true), + sslCertificate: z + .string() + .trim() + .transform((value) => value || undefined) + .optional() + }) + }) +]); + +type FormData = z.infer; + +export const RedisConnectionForm = ({ appConnection, onSubmit }: Props) => { + const isUpdate = Boolean(appConnection); + const [selectedTabIndex, setSelectedTabIndex] = useState(0); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: appConnection ?? { + app: AppConnection.Redis, + method: RedisConnectionMethod.UsernameAndPassword, + credentials: { + host: "", + port: 6379, + username: "", + password: "", + sslEnabled: false, + sslRejectUnauthorized: true, + sslCertificate: undefined + } + } + }); + + const { + handleSubmit, + watch, + control, + formState: { isSubmitting, isDirty } + } = form; + + const sslEnabled = watch("credentials.sslEnabled"); + + return ( + +
    + {!isUpdate && } + ( + + + + )} + /> + + <> + + + + `w-30 -mb-[0.14rem] px-4 py-2 text-sm font-medium outline-none disabled:opacity-60 ${ + selected + ? "border-b-2 border-mineshaft-300 text-mineshaft-200" + : "text-bunker-300" + }` + } + > + Configuration + + + `w-30 -mb-[0.14rem] px-4 py-2 text-sm font-medium outline-none disabled:opacity-60 ${ + selected + ? "border-b-2 border-mineshaft-300 text-mineshaft-200" + : "text-bunker-300" + }` + } + > + SSL ({sslEnabled ? "Enabled" : "Disabled"}) + + + + +
    + ( + + + + )} + /> + ( + + + + )} + /> +
    +
    + ( + + + + )} + /> + ( + + onChange(e.target.value)} + /> + + )} + /> +
    +
    + + ( + + + Enable SSL + + + )} + /> + ( + +