diff --git a/.env.example b/.env.example index 059ec124f..5220d5a03 100644 --- a/.env.example +++ b/.env.example @@ -122,7 +122,7 @@ INF_APP_CONNECTION_GITHUB_RADAR_APP_WEBHOOK_SECRET= #gcp app connection INF_APP_CONNECTION_GCP_SERVICE_ACCOUNT_CREDENTIAL= -# azure app connection +# azure app connections INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID= INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_SECRET= @@ -135,6 +135,10 @@ INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_SECRET= INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_ID= INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_SECRET= +# heroku app connection +INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_ID= +INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_SECRET= + # datadog SHOULD_USE_DATADOG_TRACER= DATADOG_PROFILING_ENABLED= diff --git a/.github/workflows/release-standalone-docker-img-postgres-offical.yml b/.github/workflows/release-standalone-docker-img-postgres-offical.yml index b17d5e50c..486ff12b2 100644 --- a/.github/workflows/release-standalone-docker-img-postgres-offical.yml +++ b/.github/workflows/release-standalone-docker-img-postgres-offical.yml @@ -117,3 +117,28 @@ jobs: build-args: | POSTHOG_API_KEY=${{ secrets.PUBLIC_POSTHOG_API_KEY }} INFISICAL_PLATFORM_VERSION=${{ steps.extract_version.outputs.version }} + trigger-binary-release: + runs-on: ubuntu-latest + needs: [infisical-standalone, infisical-fips-standalone] + steps: + - name: Create tag if it doesn't exist + run: | + TAG_NAME="${{ github.ref_name }}" + echo "Checking for tag: $TAG_NAME" + + if gh api repos/Infisical/infisical-omnibus/git/refs/tags/$TAG_NAME --silent 2>/dev/null; then + echo "Tag $TAG_NAME already exists, skipping..." + else + echo "Creating tag in Infisical/infisical-omnibus: $TAG_NAME" + LATEST_SHA=$(gh api repos/Infisical/infisical-omnibus/git/refs/heads/main --jq '.object.sha') + echo "Latest SHA: $LATEST_SHA" + + gh api repos/Infisical/infisical-omnibus/git/refs \ + --method POST \ + --field ref="refs/tags/$TAG_NAME" \ + --field sha="$LATEST_SHA" + + echo "Successfully created tag $TAG_NAME" + fi + env: + GH_TOKEN: ${{ secrets.OMNIBUS_RELEASE_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/validate-upgrade-path.yml b/.github/workflows/validate-upgrade-path.yml new file mode 100644 index 000000000..dabdccae0 --- /dev/null +++ b/.github/workflows/validate-upgrade-path.yml @@ -0,0 +1,39 @@ +name: "Validate Upgrade Path Configuration" + +on: + pull_request: + types: [opened, synchronize] + paths: + - "backend/upgrade-path.yaml" + - "backend/scripts/validate-upgrade-path-file.ts" + - "backend/src/services/upgrade-path/upgrade-path-schemas.ts" + + workflow_call: + +jobs: + validate-upgrade-path: + name: Validate upgrade-path.yaml + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout source + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: 'backend/package-lock.json' + + - name: Install minimal dependencies + working-directory: backend + run: | + npm install --no-package-lock js-yaml@^4.1.0 zod@^3.22.0 tsx@^4.0.0 @types/js-yaml@^4.0.0 re2@^1.20.0 + + - name: Validate upgrade-path.yaml format + working-directory: backend + run: npx tsx ./scripts/validate-upgrade-path-file.ts \ No newline at end of file diff --git a/.infisicalignore b/.infisicalignore index 66e2fb635..b935763c8 100644 --- a/.infisicalignore +++ b/.infisicalignore @@ -51,3 +51,4 @@ docs/integrations/app-connections/bitbucket.mdx:generic-api-key:123 docs/integrations/app-connections/railway.mdx:generic-api-key:156 .github/workflows/validate-db-schemas.yml:generic-api-key:21 k8-operator/config/samples/universalAuthIdentitySecret.yaml:generic-api-key:8 +docs/integrations/app-connections/redis.mdx:generic-api-key:80 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..f06c1d69f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -73,7 +73,8 @@ "seed": "knex --knexfile ./dist/db/knexfile.ts --client pg seed:run", "seed-dev": "knex --knexfile ./src/db/knexfile.ts --client pg seed:run", "db:reset": "npm run migration:rollback -- --all && npm run migration:latest", - "email:dev": "email dev --dir src/services/smtp/emails" + "email:dev": "email dev --dir src/services/smtp/emails", + "validate-upgrade-path": "tsx ./scripts/validate-upgrade-path-file.ts" }, "keywords": [], "author": "", @@ -87,6 +88,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 +205,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/scripts/validate-upgrade-path-file.ts b/backend/scripts/validate-upgrade-path-file.ts new file mode 100644 index 000000000..566300cde --- /dev/null +++ b/backend/scripts/validate-upgrade-path-file.ts @@ -0,0 +1,107 @@ +/* eslint-disable no-console */ +import { readFile } from "fs/promises"; +import * as yaml from "js-yaml"; +import * as path from "path"; +import { z } from "zod"; + +import { upgradePathConfigSchema } from "../src/services/upgrade-path/upgrade-path-schemas"; + +async function validateUpgradePathConfig(): Promise { + try { + const yamlPath = path.join(__dirname, "..", "upgrade-path.yaml"); + const resolvedPath = path.resolve(yamlPath); + const expectedBaseDir = path.resolve(__dirname, ".."); + + if (!resolvedPath.startsWith(expectedBaseDir)) { + throw new Error("Invalid configuration file path"); + } + + try { + await readFile(yamlPath, "utf8"); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") { + console.log("Warning: No upgrade-path.yaml file found"); + return; + } + throw error; + } + + const yamlContent = await readFile(yamlPath, "utf8"); + + if (yamlContent.length > 1024 * 1024) { + throw new Error("Config file too large (>1MB)"); + } + + let config: unknown; + try { + config = yaml.load(yamlContent, { + schema: yaml.FAILSAFE_SCHEMA, + filename: yamlPath, + onWarning: (warning) => { + console.log(`YAML Warning: ${warning.message}`); + } + }); + } catch (yamlError) { + if (yamlError instanceof yaml.YAMLException) { + throw new Error( + `YAML parsing failed: ${yamlError.message} at line ${yamlError.mark?.line}, column ${yamlError.mark?.column}` + ); + } + throw new Error(`YAML parsing failed: ${yamlError instanceof Error ? yamlError.message : "Unknown YAML error"}`); + } + + if (!config) { + console.log("Warning: Empty configuration file"); + return; + } + + if (typeof config !== "object" || config === null) { + throw new Error("Configuration must be a valid YAML object"); + } + + const result = upgradePathConfigSchema.safeParse(config); + + if (!result.success) { + console.log("Validation failed with the following errors:"); + result.error.issues.forEach((issue: z.ZodIssue) => { + const issuePath = issue.path.length > 0 ? `[${issue.path.join(".")}]` : ""; + console.log(` - ${issuePath}: ${issue.message}`); + }); + throw new Error("Schema validation failed"); + } + + const validatedConfig = result.data; + const versions = validatedConfig?.versions || {}; + const versionCount = Object.keys(versions).length; + + if (versionCount === 0) { + console.log("Warning: No versions found in the configuration"); + } else { + console.log(`Validated ${versionCount} version configuration(s)`); + + const commonPatterns = [ + /^v?\d+\.\d+\.\d+$/, + /^v?\d+\.\d+\.\d+\.\d+$/, + /^infisical\/v?\d+\.\d+\.\d+$/, + /^infisical\/v?\d+\.\d+\.\d+-\w+$/ + ]; + + for (const versionKey of Object.keys(versions)) { + const isCommonPattern = commonPatterns.some((pattern) => pattern.test(versionKey)); + if (!isCommonPattern) { + console.log(`Warning: Version key '${versionKey}' doesn't match common patterns. This may be intentional.`); + } + } + } + + console.log("upgrade-path.yaml format is valid"); + } catch (error) { + console.error(`Validation failed: ${error instanceof Error ? error.message : "Unknown error"}`); + process.exit(1); + } +} + +validateUpgradePathConfig().catch((error) => { + console.error("Unexpected error:", error); + process.exit(1); +}); diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 958a3f6f2..26da53898 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -118,6 +118,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"; @@ -320,6 +321,7 @@ declare module "fastify" { pamFolder: TPamFolderServiceFactory; pamResource: TPamResourceServiceFactory; pamSession: TPamSessionServiceFactory; + 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/ee/routes/v1/secret-router.ts b/backend/src/ee/routes/v1/secret-router.ts index a964eb1b8..b9eeb7729 100644 --- a/backend/src/ee/routes/v1/secret-router.ts +++ b/backend/src/ee/routes/v1/secret-router.ts @@ -34,7 +34,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { secretName: z.string().trim().describe(RAW_SECRETS.GET_ACCESS_LIST.secretName) }), querystring: z.object({ - workspaceId: z.string().trim().describe(RAW_SECRETS.GET_ACCESS_LIST.workspaceId), + projectId: z.string().trim().describe(RAW_SECRETS.GET_ACCESS_LIST.projectId), environment: z.string().trim().describe(RAW_SECRETS.GET_ACCESS_LIST.environment), secretPath: z .string() @@ -54,7 +54,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { const { secretName } = req.params; - const { secretPath, environment, workspaceId: projectId } = req.query; + const { secretPath, environment, projectId } = req.query; return server.services.secret.getSecretAccessList({ actorId: req.permission.id, 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/dynamic-secret/providers/azure-sql-database.ts b/backend/src/ee/services/dynamic-secret/providers/azure-sql-database.ts new file mode 100644 index 000000000..965964883 --- /dev/null +++ b/backend/src/ee/services/dynamic-secret/providers/azure-sql-database.ts @@ -0,0 +1,541 @@ +import handlebars from "handlebars"; +import knex from "knex"; +import RE2 from "re2"; +import { z } from "zod"; + +import { crypto } from "@app/lib/crypto/cryptography"; +import { BadRequestError } from "@app/lib/errors"; +import { sanitizeString } from "@app/lib/fn"; +import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; +import { withGatewayV2Proxy } from "@app/lib/gateway-v2/gateway-v2"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { validateHandlebarTemplate } from "@app/lib/template/validate-handlebars"; + +import { TGatewayServiceFactory } from "../../gateway/gateway-service"; +import { TGatewayV2ServiceFactory } from "../../gateway-v2/gateway-v2-service"; +import { verifyHostInputValidity } from "../dynamic-secret-fns"; +import { DynamicSecretAzureSqlDBSchema, PasswordRequirements, SqlProviders, TDynamicProviderFns } from "./models"; +import { compileUsernameTemplate } from "./templateUtils"; + +const EXTERNAL_REQUEST_TIMEOUT = 10 * 1000; + +const DEFAULT_PASSWORD_REQUIREMENTS = { + length: 48, + required: { + lowercase: 1, + uppercase: 1, + digits: 1, + symbols: 0 + }, + allowedSymbols: "-_.~!*" +}; + +const generatePassword = (requirements?: PasswordRequirements) => { + const finalReqs = requirements || DEFAULT_PASSWORD_REQUIREMENTS; + + try { + const { length, required, allowedSymbols } = finalReqs; + + const chars = { + lowercase: "abcdefghijklmnopqrstuvwxyz", + uppercase: "ABCDEFGHIJKLMNOPQRSTUVWXYZ", + digits: "0123456789", + symbols: allowedSymbols || "-_.~!*" + }; + + const parts: string[] = []; + + if (required.lowercase > 0) { + parts.push( + ...Array(required.lowercase) + .fill(0) + .map(() => chars.lowercase[crypto.randomInt(chars.lowercase.length)]) + ); + } + + if (required.uppercase > 0) { + parts.push( + ...Array(required.uppercase) + .fill(0) + .map(() => chars.uppercase[crypto.randomInt(chars.uppercase.length)]) + ); + } + + if (required.digits > 0) { + parts.push( + ...Array(required.digits) + .fill(0) + .map(() => chars.digits[crypto.randomInt(chars.digits.length)]) + ); + } + + if (required.symbols > 0) { + parts.push( + ...Array(required.symbols) + .fill(0) + .map(() => chars.symbols[crypto.randomInt(chars.symbols.length)]) + ); + } + + const requiredTotal = Object.values(required).reduce((a, b) => a + b, 0); + const remainingLength = Math.max(length - requiredTotal, 0); + + const allowedChars = Object.entries(chars) + .filter(([key]) => required[key as keyof typeof required] > 0) + .map(([, value]) => value) + .join(""); + + parts.push( + ...Array(remainingLength) + .fill(0) + .map(() => allowedChars[crypto.randomInt(allowedChars.length)]) + ); + + // shuffle the array to mix up the characters + for (let i = parts.length - 1; i > 0; i -= 1) { + const j = crypto.randomInt(i + 1); + [parts[i], parts[j]] = [parts[j], parts[i]]; + } + + return parts.join(""); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Unknown error"; + throw new Error(`Failed to generate password: ${message}`); + } +}; + +const generateUsername = (usernameTemplate?: string | null, identity?: { name: string }) => { + const randomUsername = alphaNumericNanoId(32); + if (!usernameTemplate) return randomUsername; + return compileUsernameTemplate({ + usernameTemplate, + randomUsername, + identity + }); +}; + +type TAzureSqlDatabaseProviderDTO = { + gatewayService: Pick; + gatewayV2Service: Pick; +}; + +export const AzureSqlDatabaseProvider = ({ + gatewayService, + gatewayV2Service +}: TAzureSqlDatabaseProviderDTO): TDynamicProviderFns => { + const validateProviderInputs = async (inputs: unknown) => { + const providerInputs = await DynamicSecretAzureSqlDBSchema.parseAsync(inputs); + + const [hostIp] = await verifyHostInputValidity(providerInputs.host, Boolean(providerInputs.gatewayId)); + validateHandlebarTemplate("Azure SQL master creation", providerInputs.masterCreationStatement, { + allowedExpressions: (val) => ["username", "password", "expiration", "database"].includes(val) + }); + validateHandlebarTemplate("Azure SQL creation", providerInputs.creationStatement, { + allowedExpressions: (val) => ["username", "password", "expiration", "database"].includes(val) + }); + if (providerInputs.renewStatement) { + validateHandlebarTemplate("Azure SQL renew", providerInputs.renewStatement, { + allowedExpressions: (val) => ["username", "expiration", "database"].includes(val) + }); + } + validateHandlebarTemplate("Azure SQL revoke", providerInputs.revocationStatement, { + allowedExpressions: (val) => ["username", "database"].includes(val) + }); + + return { ...providerInputs, hostIp }; + }; + + const $getClient = async ( + providerInputs: z.infer & { hostIp: string; originalHost: string }, + targetDatabase?: string + ) => { + const ssl = providerInputs.ca + ? { rejectUnauthorized: false, ca: providerInputs.ca, servername: providerInputs.host } + : undefined; + + /* + We route through the gateway by setting connection.host = "localhost". + Azure SQL identifies the logical server from the TDS login name when the host + isn't the Azure FQDN. Therefore, when using the gateway, ensure username is + "user@" so Azure opens the correct logical server. + Direct connections to the Azure FQDN usually don't require this suffix. + */ + const isAzureSql = new RE2(/\.database\.windows\.net$/i).test(providerInputs.originalHost); + const azureServerLabel = + isAzureSql && providerInputs.gatewayId ? providerInputs.originalHost?.split(".")[0] : undefined; + const effectiveUser = + isAzureSql && !providerInputs.username.includes("@") && azureServerLabel + ? `${providerInputs.username}@${azureServerLabel}` + : providerInputs.username; + + const db = knex({ + client: SqlProviders.MsSQL, + connection: { + database: targetDatabase || providerInputs.database, + port: providerInputs.port, + host: providerInputs.host, + user: effectiveUser, + password: providerInputs.password, + ssl, + // @ts-expect-error this is because of knexjs type signature issue. This is directly passed to driver + // https://github.com/knex/knex/blob/b6507a7129d2b9fafebf5f831494431e64c6a8a0/lib/dialects/mssql/index.js#L66 + // https://github.com/tediousjs/tedious/blob/ebb023ed90969a7ec0e4b036533ad52739d921f7/test/config.ci.ts#L19 + options: { + ...(providerInputs.sslEnabled !== undefined ? { encrypt: providerInputs.sslEnabled } : {}), + trustServerCertificate: !providerInputs.ca, + cryptoCredentialsDetails: providerInputs.ca ? { ca: providerInputs.ca } : {} + } + }, + acquireConnectionTimeout: EXTERNAL_REQUEST_TIMEOUT, + pool: { min: 0, max: 7 } + }); + return db; + }; + + const gatewayProxyWrapper = async ( + providerInputs: z.infer, + gatewayCallback: (host: string, port: number) => Promise + ) => { + const gatewayV2ConnectionDetails = await gatewayV2Service.getPlatformConnectionDetailsByGatewayId({ + gatewayId: providerInputs.gatewayId as string, + targetHost: providerInputs.host, + targetPort: providerInputs.port + }); + + if (gatewayV2ConnectionDetails) { + return withGatewayV2Proxy( + async (port) => { + await gatewayCallback("localhost", port); + }, + { + relayHost: gatewayV2ConnectionDetails.relayHost, + gateway: gatewayV2ConnectionDetails.gateway, + relay: gatewayV2ConnectionDetails.relay, + protocol: GatewayProxyProtocol.Tcp + } + ); + } + + const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(providerInputs.gatewayId as string); + const [relayHost, relayPort] = relayDetails.relayAddress.split(":"); + await withGatewayProxy( + async (port) => { + await gatewayCallback("localhost", port); + }, + { + protocol: GatewayProxyProtocol.Tcp, + targetHost: providerInputs.host, + targetPort: providerInputs.port, + relayHost, + relayPort: Number(relayPort), + identityId: relayDetails.identityId, + orgId: relayDetails.orgId, + tlsOptions: { + ca: relayDetails.certChain, + cert: relayDetails.certificate, + key: relayDetails.privateKey.toString() + } + } + ); + }; + + const validateConnection = async (inputs: unknown) => { + const providerInputs = await validateProviderInputs(inputs); + let isConnected = false; + const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => { + const db = await $getClient({ + ...providerInputs, + port, + host, + hostIp: providerInputs.hostIp, + originalHost: providerInputs.host + }); + + try { + isConnected = await db.raw("SELECT 1").then(() => true); + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [providerInputs.username] + }); + throw new BadRequestError({ + message: `Failed to connect with provider: ${sanitizedErrorMessage}` + }); + } finally { + await db.destroy(); + } + }; + + if (providerInputs.gatewayId) { + await gatewayProxyWrapper(providerInputs, gatewayCallback); + } else { + await gatewayCallback(); + } + return isConnected; + }; + + const create = async (data: { + inputs: unknown; + expireAt: number; + usernameTemplate?: string | null; + identity?: { name: string }; + }) => { + const { inputs, expireAt, usernameTemplate, identity } = data; + + const providerInputs = await validateProviderInputs(inputs); + const { database, masterDatabase } = providerInputs; + const username = generateUsername(usernameTemplate, identity); + const password = generatePassword(providerInputs.passwordRequirements); + + const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => { + const expiration = new Date(expireAt).toISOString(); + + const masterDb = await $getClient( + { + ...providerInputs, + port, + host, + originalHost: providerInputs.host + }, + masterDatabase + ); + + try { + const masterCreationStatement = handlebars.compile(providerInputs.masterCreationStatement, { noEscape: true })({ + username, + password, + expiration, + database + }); + + const masterQueries = masterCreationStatement.toString().split(";").filter(Boolean); + await masterDb.transaction(async (tx) => { + for (const query of masterQueries) { + // eslint-disable-next-line + await tx.raw(query); + } + }); + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [username, password, database] + }); + throw new BadRequestError({ + message: `Failed to create login in master database: ${sanitizedErrorMessage}` + }); + } finally { + await masterDb.destroy(); + } + + const targetDb = await $getClient({ + ...providerInputs, + port, + host, + originalHost: providerInputs.host + }); + + try { + const creationStatement = handlebars.compile(providerInputs.creationStatement, { noEscape: true })({ + username, + password, + expiration, + database + }); + + const queries = creationStatement.toString().split(";").filter(Boolean); + await targetDb.transaction(async (tx) => { + for (const query of queries) { + // eslint-disable-next-line + await tx.raw(query); + } + }); + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [username, password, database] + }); + throw new BadRequestError({ + message: `Failed to create user in target database: ${sanitizedErrorMessage}` + }); + } finally { + await targetDb.destroy(); + } + }; + + if (providerInputs.gatewayId) { + await gatewayProxyWrapper(providerInputs, gatewayCallback); + } else { + await gatewayCallback(); + } + return { entityId: username, data: { DB_USERNAME: username, DB_PASSWORD: password } }; + }; + + const revoke = async (inputs: unknown, entityId: string) => { + const providerInputs = await validateProviderInputs(inputs); + const username = entityId; + const { database, masterDatabase } = providerInputs; + + const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => { + const revokeStatement = handlebars.compile(providerInputs.revocationStatement)({ username, database }); + const queries = revokeStatement.toString().split(";").filter(Boolean); + + const userDropQueries = queries.filter((query) => query.toLowerCase().includes("drop user")); + const loginDropQueries = queries.filter((query) => query.toLowerCase().includes("drop login")); + + if (userDropQueries.length > 0) { + const targetDb = await $getClient({ + ...providerInputs, + port, + host, + originalHost: providerInputs.host + }); + + try { + await targetDb.transaction(async (tx) => { + for (const query of userDropQueries) { + // eslint-disable-next-line + await tx.raw(query.trim()); + } + }); + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [username, database] + }); + throw new BadRequestError({ + message: `Failed to drop user from target database: ${sanitizedErrorMessage}` + }); + } finally { + await targetDb.destroy(); + } + } + + if (loginDropQueries.length > 0) { + const masterDb = await $getClient( + { + ...providerInputs, + port, + host, + originalHost: providerInputs.host + }, + masterDatabase + ); + + try { + await masterDb.transaction(async (tx) => { + for (const query of loginDropQueries) { + // eslint-disable-next-line + await tx.raw(query.trim()); + } + }); + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [username, database] + }); + throw new BadRequestError({ + message: `Failed to drop login from master database: ${sanitizedErrorMessage}` + }); + } finally { + await masterDb.destroy(); + } + } + + const otherQueries = queries.filter( + (query) => !query.toLowerCase().includes("drop user") && !query.toLowerCase().includes("drop login") + ); + + if (otherQueries.length > 0) { + const targetDb = await $getClient({ + ...providerInputs, + port, + host, + originalHost: providerInputs.host + }); + + try { + await targetDb.transaction(async (tx) => { + for (const query of otherQueries) { + // eslint-disable-next-line + await tx.raw(query.trim()); + } + }); + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [username, database] + }); + throw new BadRequestError({ + message: `Failed to execute revocation statement: ${sanitizedErrorMessage}` + }); + } finally { + await targetDb.destroy(); + } + } + }; + + if (providerInputs.gatewayId) { + await gatewayProxyWrapper(providerInputs, gatewayCallback); + } else { + await gatewayCallback(); + } + return { entityId: username }; + }; + + const renew = async (inputs: unknown, entityId: string, expireAt: number) => { + const providerInputs = await validateProviderInputs(inputs); + if (!providerInputs.renewStatement) return { entityId }; + + const gatewayCallback = async (host = providerInputs.host, port = providerInputs.port) => { + const db = await $getClient({ + ...providerInputs, + port, + host, + originalHost: providerInputs.host + }); + const expiration = new Date(expireAt).toISOString(); + const { database } = providerInputs; + + const renewStatement = handlebars.compile(providerInputs.renewStatement)({ + username: entityId, + expiration, + database + }); + try { + if (renewStatement) { + const queries = renewStatement.toString().split(";").filter(Boolean); + await db.transaction(async (tx) => { + for (const query of queries) { + // eslint-disable-next-line + await tx.raw(query); + } + }); + } + } catch (err) { + const sanitizedErrorMessage = sanitizeString({ + unsanitizedString: (err as Error)?.message, + tokens: [database] + }); + throw new BadRequestError({ + message: `Failed to renew lease from provider: ${sanitizedErrorMessage}` + }); + } finally { + await db.destroy(); + } + }; + if (providerInputs.gatewayId) { + await gatewayProxyWrapper(providerInputs, gatewayCallback); + } else { + await gatewayCallback(); + } + return { entityId }; + }; + + return { + validateProviderInputs, + validateConnection, + create, + revoke, + renew + }; +}; diff --git a/backend/src/ee/services/dynamic-secret/providers/index.ts b/backend/src/ee/services/dynamic-secret/providers/index.ts index 3ec0f795e..0259f2d2d 100644 --- a/backend/src/ee/services/dynamic-secret/providers/index.ts +++ b/backend/src/ee/services/dynamic-secret/providers/index.ts @@ -5,6 +5,7 @@ import { TGatewayV2ServiceFactory } from "../../gateway-v2/gateway-v2-service"; import { AwsElastiCacheDatabaseProvider } from "./aws-elasticache"; import { AwsIamProvider } from "./aws-iam"; import { AzureEntraIDProvider } from "./azure-entra-id"; +import { AzureSqlDatabaseProvider } from "./azure-sql-database"; import { CassandraProvider } from "./cassandra"; import { CouchbaseProvider } from "./couchbase"; import { ElasticSearchProvider } from "./elastic-search"; @@ -42,6 +43,7 @@ export const buildDynamicSecretProviders = ({ [DynamicSecretProviders.ElasticSearch]: ElasticSearchProvider(), [DynamicSecretProviders.RabbitMq]: RabbitMqProvider(), [DynamicSecretProviders.AzureEntraID]: AzureEntraIDProvider(), + [DynamicSecretProviders.AzureSqlDatabase]: AzureSqlDatabaseProvider({ gatewayService, gatewayV2Service }), [DynamicSecretProviders.Ldap]: LdapProvider(), [DynamicSecretProviders.SapHana]: SapHanaProvider(), [DynamicSecretProviders.Snowflake]: SnowflakeProvider(), diff --git a/backend/src/ee/services/dynamic-secret/providers/models.ts b/backend/src/ee/services/dynamic-secret/providers/models.ts index 3586fa0d9..8baf178a9 100644 --- a/backend/src/ee/services/dynamic-secret/providers/models.ts +++ b/backend/src/ee/services/dynamic-secret/providers/models.ts @@ -327,6 +327,44 @@ export const AzureEntraIDSchema = z.object({ clientSecret: z.string().trim().min(1) }); +export const DynamicSecretAzureSqlDBSchema = z.object({ + host: z.string().trim().toLowerCase(), + port: z.number(), + database: z.string().trim(), + masterDatabase: z.string().trim().optional().default("master"), + username: z.string().trim(), + password: z.string().trim(), + passwordRequirements: z + .object({ + length: z.number().min(1).max(250), + required: z + .object({ + lowercase: z.number().min(0), + uppercase: z.number().min(0), + digits: z.number().min(0), + symbols: z.number().min(0) + }) + .refine((data) => { + const total = Object.values(data).reduce((sum, count) => sum + count, 0); + return total <= 250; + }, "Sum of required characters cannot exceed 250"), + allowedSymbols: z.string().optional() + }) + .refine((data) => { + const total = Object.values(data.required).reduce((sum, count) => sum + count, 0); + return total <= data.length; + }, "Sum of required characters cannot exceed the total length") + .optional() + .describe("Password generation requirements"), + masterCreationStatement: z.string().trim(), + creationStatement: z.string().trim(), + revocationStatement: z.string().trim(), + renewStatement: z.string().trim().optional(), + ca: z.string().optional(), + sslEnabled: z.boolean().optional(), + gatewayId: z.string().nullable().optional() +}); + export const LdapSchema = z.union([ z.object({ url: z.string().trim().min(1), @@ -610,6 +648,7 @@ export enum DynamicSecretProviders { MongoDB = "mongo-db", RabbitMq = "rabbit-mq", AzureEntraID = "azure-entra-id", + AzureSqlDatabase = "azure-sql-database", Ldap = "ldap", SapHana = "sap-hana", Snowflake = "snowflake", @@ -635,6 +674,7 @@ export const DynamicSecretProviderSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal(DynamicSecretProviders.MongoDB), inputs: DynamicSecretMongoDBSchema }), z.object({ type: z.literal(DynamicSecretProviders.RabbitMq), inputs: DynamicSecretRabbitMqSchema }), z.object({ type: z.literal(DynamicSecretProviders.AzureEntraID), inputs: AzureEntraIDSchema }), + z.object({ type: z.literal(DynamicSecretProviders.AzureSqlDatabase), inputs: DynamicSecretAzureSqlDBSchema }), z.object({ type: z.literal(DynamicSecretProviders.Ldap), inputs: LdapSchema }), z.object({ type: z.literal(DynamicSecretProviders.Snowflake), inputs: DynamicSecretSnowflakeSchema }), z.object({ type: z.literal(DynamicSecretProviders.Totp), inputs: DynamicSecretTotpSchema }), diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts index 7847a59a6..affec39ad 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts @@ -285,13 +285,10 @@ export const secretApprovalRequestServiceFactory = ({ ) { throw new ForbiddenRequestError({ message: "User has insufficient privileges" }); } - const getHasSecretReadAccess = ( - shouldCheckSecretPermission: boolean | null | undefined, - environment: string, - tags: { slug: string }[], - secretPath?: string - ) => { - if (shouldCheckSecretPermission) { + const getHasSecretReadAccess = (environment: string, tags: { slug: string }[], secretPath?: string) => { + const isReviewer = policy.approvers.some(({ userId }) => userId === actorId); + + if (!isReviewer) { const canRead = hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { environment, secretPath: secretPath || "/", @@ -322,18 +319,8 @@ export const secretApprovalRequestServiceFactory = ({ version: el.version, secretMetadata: el.secretMetadata as ResourceMetadataDTO, isRotatedSecret: el.secret?.isRotatedSecret ?? false, - secretValueHidden: !getHasSecretReadAccess( - secretApprovalRequest.policy.shouldCheckSecretPermission, - secretApprovalRequest.environment, - el.tags, - secretPath?.[0]?.path - ), - secretValue: !getHasSecretReadAccess( - secretApprovalRequest.policy.shouldCheckSecretPermission, - secretApprovalRequest.environment, - el.tags, - secretPath?.[0]?.path - ) + secretValueHidden: !getHasSecretReadAccess(secretApprovalRequest.environment, el.tags, secretPath?.[0]?.path), + secretValue: !getHasSecretReadAccess(secretApprovalRequest.environment, el.tags, secretPath?.[0]?.path) ? INFISICAL_SECRET_VALUE_HIDDEN_MASK : el.secret && el.secret.isRotatedSecret ? undefined @@ -354,17 +341,11 @@ export const secretApprovalRequestServiceFactory = ({ id: el.secret.id, version: el.secret.version, secretValueHidden: !getHasSecretReadAccess( - secretApprovalRequest.policy.shouldCheckSecretPermission, secretApprovalRequest.environment, el.tags, secretPath?.[0]?.path ), - secretValue: !getHasSecretReadAccess( - secretApprovalRequest.policy.shouldCheckSecretPermission, - secretApprovalRequest.environment, - el.tags, - secretPath?.[0]?.path - ) + secretValue: !getHasSecretReadAccess(secretApprovalRequest.environment, el.tags, secretPath?.[0]?.path) ? INFISICAL_SECRET_VALUE_HIDDEN_MASK : el.secret.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.secret.encryptedValue }).toString() @@ -380,17 +361,11 @@ export const secretApprovalRequestServiceFactory = ({ id: el.secretVersion.id, version: el.secretVersion.version, secretValueHidden: !getHasSecretReadAccess( - secretApprovalRequest.policy.shouldCheckSecretPermission, secretApprovalRequest.environment, el.tags, secretPath?.[0]?.path ), - secretValue: !getHasSecretReadAccess( - secretApprovalRequest.policy.shouldCheckSecretPermission, - secretApprovalRequest.environment, - el.tags, - secretPath?.[0]?.path - ) + secretValue: !getHasSecretReadAccess(secretApprovalRequest.environment, el.tags, secretPath?.[0]?.path) ? INFISICAL_SECRET_VALUE_HIDDEN_MASK : el.secretVersion.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.secretVersion.encryptedValue }).toString() @@ -409,12 +384,7 @@ export const secretApprovalRequestServiceFactory = ({ const encryptedSecrets = await secretApprovalRequestSecretDAL.findByRequestId(secretApprovalRequest.id); secrets = encryptedSecrets.map((el) => ({ ...el, - secretValueHidden: !getHasSecretReadAccess( - secretApprovalRequest.policy.shouldCheckSecretPermission, - secretApprovalRequest.environment, - el.tags, - secretPath?.[0]?.path - ), + secretValueHidden: !getHasSecretReadAccess(secretApprovalRequest.environment, el.tags, secretPath?.[0]?.path), ...decryptSecretWithBot(el, botKey), secret: el.secret ? { 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..41e23a617 --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-fns.ts @@ -0,0 +1,194 @@ +/* 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 { verifyHostInputValidity } from "../../dynamic-secret/dynamic-secret-fns"; +import { DEFAULT_PASSWORD_REQUIREMENTS, generatePassword } from "../shared/utils"; +import { + TRedisCredentialsRotationGeneratedCredentials, + TRedisCredentialsRotationWithConnection +} from "./redis-credentials-rotation-types"; + +const redactPasswords = (e: unknown, credentials: TRedisCredentialsRotationGeneratedCredentials) => { + const error = e as Error; + + if (!error?.message) return "Unknown error"; + + let redactedMessage = error.message; + + credentials.forEach(({ password }) => { + redactedMessage = redactedMessage.replaceAll(password, "*******************"); + }); + + return redactedMessage; +}; + +export const redisCredentialsRotationFactory: TRotationFactory< + TRedisCredentialsRotationWithConnection, + TRedisCredentialsRotationGeneratedCredentials +> = (secretRotation) => { + const { connection, secretsMapping, parameters } = secretRotation; + + const $getClient = async () => { + const [hostIp] = await verifyHostInputValidity(connection.credentials.host); + + let conn: Redis | null = null; + try { + conn = new Redis({ + username: connection.credentials.username, + host: hostIp, + 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 () => { + let client: Redis | null = null; + + const username = generatePassword({ + length: 32, + required: { + symbols: 0, + digits: 5, + uppercase: 5, + lowercase: 5 + } + }); + + const password = generatePassword(parameters.passwordRequirements || DEFAULT_PASSWORD_REQUIREMENTS); + + try { + client = await $getClient(); + + // 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.split(" "); + await client.call("ACL", "SETUSER", username, `>${password}`, "on", ...permissionParts); + + return { + username, + password + }; + } catch (error: unknown) { + throw new BadRequestError({ + message: `Unable to rotate credentials: ${redactPasswords(error, [{ username, password }])}` + }); + } finally { + if (client) await client.quit(); + } + }; + + /** + * Revokes a ACL password from the Redis server using its username and password. + */ + const revokeCredential = async (username: string) => { + let client: Redis | null = null; + + try { + client = await $getClient(); + await client.call("ACL", "DELUSER", username); + } catch (error: unknown) { + throw new BadRequestError({ + message: `Unable to revoke credential: ${redactPasswords(error, [{ username, password: username }])}` + }); + } finally { + if (client) await client.quit(); + } + }; + + /** + * 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..c4948a26d --- /dev/null +++ b/backend/src/ee/services/secret-rotation-v2/redis-credentials/redis-credentials-rotation-schemas.ts @@ -0,0 +1,70 @@ +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() + .trim() + .min(1, "Permission scope is required") + .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: RedisCredentialsRotationParametersSchema, + secretsMapping: RedisCredentialsRotationSecretsMappingSchema +}); + +export const CreateRedisCredentialsRotationSchema = BaseCreateSecretRotationSchema( + SecretRotation.RedisCredentials +).extend({ + parameters: RedisCredentialsRotationParametersSchema, + secretsMapping: RedisCredentialsRotationSecretsMappingSchema +}); + +export const UpdateRedisCredentialsRotationSchema = BaseUpdateSecretRotationSchema( + SecretRotation.RedisCredentials +).extend({ + parameters: RedisCredentialsRotationParametersSchema.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>; @@ -736,6 +745,19 @@ export const overwriteSchema: { description: "The Client Secret of your GCP OAuth2 application." } ] + }, + heroku: { + name: "Heroku", + fields: [ + { + key: "INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_ID", + description: "The Client ID of your Heroku application." + }, + { + key: "INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_SECRET", + description: "The Client Secret of your Heroku application." + } + ] } }; diff --git a/backend/src/server/lib/schemas.ts b/backend/src/server/lib/schemas.ts index 00651d2cc..d0750926d 100644 --- a/backend/src/server/lib/schemas.ts +++ b/backend/src/server/lib/schemas.ts @@ -43,8 +43,6 @@ export const GenericResourceNameSchema = z export const BaseSecretNameSchema = z.string().trim().min(1); export const SecretNameSchema = BaseSecretNameSchema.refine( - (el) => !el.includes(" "), - "Secret name cannot contain spaces." -) - .refine((el) => !el.includes(":"), "Secret name cannot contain colon.") - .refine((el) => !el.includes("/"), "Secret name cannot contain forward slash."); + (el) => !el.includes(":"), + "Secret name cannot contain colon." +).refine((el) => !el.includes("/"), "Secret name cannot contain forward slash."); diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index e62f0a947..8f968070d 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -320,6 +320,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"; @@ -771,6 +772,8 @@ export const registerRoutes = async ( userAliasDAL }); + const upgradePathService = upgradePathServiceFactory({ keyStore }); + const totpService = totpServiceFactory({ totpConfigDAL, userDAL, @@ -792,6 +795,7 @@ export const registerRoutes = async ( smtpService, authDAL, userDAL, + orgMembershipDAL, totpConfigDAL }); @@ -2277,7 +2281,8 @@ export const registerRoutes = async ( notification: notificationService, pamFolder: pamFolderService, pamResource: pamResourceService, - pamSession: pamSessionService + pamSession: pamSessionService, + upgradePath: upgradePathService }); const cronJobs: CronJob[] = []; diff --git a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts index 37558b817..e5549f9fe 100644 --- a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts @@ -93,6 +93,7 @@ import { RailwayConnectionListItemSchema, SanitizedRailwayConnectionSchema } from "@app/services/app-connection/railway"; +import { RedisConnectionListItemSchema, SanitizedRedisConnectionSchema } from "@app/services/app-connection/redis"; import { RenderConnectionListItemSchema, SanitizedRenderConnectionSchema @@ -156,7 +157,8 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedDigitalOceanConnectionSchema.options, ...SanitizedNetlifyConnectionSchema.options, ...SanitizedOktaConnectionSchema.options, - ...SanitizedAzureADCSConnectionSchema.options + ...SanitizedAzureADCSConnectionSchema.options, + ...SanitizedRedisConnectionSchema.options ]); const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ @@ -197,7 +199,8 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ DigitalOceanConnectionListItemSchema, NetlifyConnectionListItemSchema, OktaConnectionListItemSchema, - AzureADCSConnectionListItemSchema + AzureADCSConnectionListItemSchema, + RedisConnectionListItemSchema ]); export const registerAppConnectionRouter = async (server: FastifyZodProvider) => { 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/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 0332d27a9..84b1442f6 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -58,6 +58,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"; @@ -217,4 +218,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..481bee5e6 --- /dev/null +++ b/backend/src/server/routes/v1/upgrade-path-router.ts @@ -0,0 +1,117 @@ +import { z } from "zod"; + +import { BadRequestError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; +import { publicEndpointLimit } from "@app/server/config/rateLimiter"; +import { versionSchema } from "@app/services/upgrade-path/upgrade-path-schemas"; + +export const registerUpgradePathRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/versions", + config: { + rateLimit: publicEndpointLimit + }, + schema: { + 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 versions = await req.server.services.upgradePath.getGitHubReleases(); + + return { + versions + }; + } catch (error) { + logger.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 + }), + 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 } = req.body; + + const result = await req.server.services.upgradePath.calculateUpgradePath(fromVersion, toVersion); + + logger.info( + { pathLength: result.path.length, hasBreaking: result.breakingChanges.length > 0 }, + "Upgrade path calculated" + ); + + return result; + } catch (error) { + logger.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/server/routes/v1/user-router.ts b/backend/src/server/routes/v1/user-router.ts index 7ef2e0d33..40fbcf5a2 100644 --- a/backend/src/server/routes/v1/user-router.ts +++ b/backend/src/server/routes/v1/user-router.ts @@ -255,7 +255,9 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { totp: z.string() }), response: { - 200: z.object({}) + 200: z.object({ + recoveryCodes: z.string().array() + }) } }, onRequest: verifyAuth([AuthMode.JWT], { diff --git a/backend/src/server/routes/v2/mfa-router.ts b/backend/src/server/routes/v2/mfa-router.ts index d8a57d29a..e8c2cea69 100644 --- a/backend/src/server/routes/v2/mfa-router.ts +++ b/backend/src/server/routes/v2/mfa-router.ts @@ -1,5 +1,7 @@ +import { FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; +import { TUsers } from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; @@ -7,11 +9,54 @@ import { mfaRateLimit } from "@app/server/config/rateLimiter"; import { addAuthOriginDomainCookie } from "@app/server/lib/cookie"; import { AuthModeMfaJwtTokenPayload, AuthTokenType, MfaMethod } from "@app/services/auth/auth-type"; +const handleMfaVerification = async ( + req: FastifyRequest & { mfa: { userId: string; orgId?: string; user: TUsers } }, + res: FastifyReply, + server: FastifyZodProvider, + mfaToken: string, + mfaMethod: MfaMethod, + isRecoveryCode?: boolean +) => { + const userAgent = req.headers["user-agent"]; + const mfaJwtToken = req.headers.authorization?.replace("Bearer ", ""); + if (!userAgent) throw new Error("user agent header is required"); + if (!mfaJwtToken) throw new Error("authorization header is required"); + const appCfg = getConfig(); + + const { user, token } = await server.services.login.verifyMfaToken({ + userAgent, + mfaJwtToken, + ip: req.realIp, + userId: req.mfa.userId, + orgId: req.mfa.orgId, + mfaToken, + mfaMethod, + isRecoveryCode + }); + + void res.setCookie("jid", token.refresh, { + httpOnly: true, + path: "/", + sameSite: "strict", + secure: appCfg.HTTPS_ENABLED + }); + + addAuthOriginDomainCookie(res); + + return { + ...user, + token: token.access, + protectedKey: user.protectedKey || null, + protectedKeyIV: user.protectedKeyIV || null, + protectedKeyTag: user.protectedKeyTag || null + }; +}; + export const registerMfaRouter = async (server: FastifyZodProvider) => { const cfg = getConfig(); server.decorateRequest("mfa", null); - server.addHook("preParsing", async (req, res) => { + server.addHook("preValidation", async (req, res) => { const authorizationHeader = req.headers.authorization; if (!authorizationHeader || !authorizationHeader.startsWith("Bearer ")) { @@ -109,38 +154,36 @@ export const registerMfaRouter = async (server: FastifyZodProvider) => { } }, handler: async (req, res) => { - const userAgent = req.headers["user-agent"]; - const mfaJwtToken = req.headers.authorization?.replace("Bearer ", ""); - if (!userAgent) throw new Error("user agent header is required"); - if (!mfaJwtToken) throw new Error("authorization header is required"); - const appCfg = getConfig(); + return handleMfaVerification(req, res, server, req.body.mfaToken, req.body.mfaMethod); + } + }); - const { user, token } = await server.services.login.verifyMfaToken({ - userAgent, - mfaJwtToken, - ip: req.realIp, - userId: req.mfa.userId, - orgId: req.mfa.orgId, - mfaToken: req.body.mfaToken, - mfaMethod: req.body.mfaMethod - }); - - void res.setCookie("jid", token.refresh, { - httpOnly: true, - path: "/", - sameSite: "strict", - secure: appCfg.HTTPS_ENABLED - }); - - addAuthOriginDomainCookie(res); - - return { - ...user, - token: token.access, - protectedKey: user.protectedKey || null, - protectedKeyIV: user.protectedKeyIV || null, - protectedKeyTag: user.protectedKeyTag || null - }; + server.route({ + url: "/mfa/verify/recovery-code", + method: "POST", + config: { + rateLimit: mfaRateLimit + }, + schema: { + body: z.object({ + recoveryCode: z.string().trim().length(8, "Recovery code must be 8 characters") + }), + response: { + 200: z.object({ + encryptionVersion: z.number().default(1).nullable().optional(), + protectedKey: z.string().nullish(), + protectedKeyIV: z.string().nullish(), + protectedKeyTag: z.string().nullish(), + publicKey: z.string().nullish(), + encryptedPrivateKey: z.string().nullish(), + iv: z.string().nullish(), + tag: z.string().nullish(), + token: z.string() + }) + } + }, + handler: async (req, res) => { + return handleMfaVerification(req, res, server, req.body.recoveryCode, MfaMethod.TOTP, true); } }); }; 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 f502821d2..73abef78d 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 { @@ -196,7 +197,8 @@ export const listAppConnectionOptions = (projectType?: ProjectType) => { getSupabaseConnectionListItem(), getDigitalOceanConnectionListItem(), getNetlifyConnectionListItem(), - getOktaConnectionListItem() + getOktaConnectionListItem(), + getRedisConnectionListItem() ] .filter((option) => { switch (projectType) { @@ -324,7 +326,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); @@ -371,6 +374,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: @@ -458,7 +462,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/heroku/heroku-connection-fns.ts b/backend/src/services/app-connection/heroku/heroku-connection-fns.ts index adbc5cd2b..bfb22cbc4 100644 --- a/backend/src/services/app-connection/heroku/heroku-connection-fns.ts +++ b/backend/src/services/app-connection/heroku/heroku-connection-fns.ts @@ -22,13 +22,13 @@ interface HerokuOAuthTokenResponse { } export const getHerokuConnectionListItem = () => { - const { CLIENT_ID_HEROKU } = getConfig(); + const { INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_ID } = getConfig(); return { name: "Heroku" as const, app: AppConnection.Heroku as const, methods: Object.values(HerokuConnectionMethod) as [HerokuConnectionMethod.AuthToken, HerokuConnectionMethod.OAuth], - oauthClientId: CLIENT_ID_HEROKU + oauthClientId: INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_ID }; }; @@ -40,12 +40,12 @@ export const refreshHerokuToken = async ( appConnectionDAL: Pick, kmsService: Pick ): Promise => { - const { CLIENT_SECRET_HEROKU } = getConfig(); + const { INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_SECRET } = getConfig(); const payload = { grant_type: "refresh_token", refresh_token: refreshToken, - client_secret: CLIENT_SECRET_HEROKU + client_secret: INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_SECRET }; const { data } = await request.post<{ access_token: string; expires_in: number }>( @@ -75,7 +75,7 @@ export const refreshHerokuToken = async ( }; export const exchangeHerokuOAuthCode = async (code: string): Promise => { - const { CLIENT_SECRET_HEROKU } = getConfig(); + const { INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_SECRET } = getConfig(); try { const response = await request.post( @@ -83,7 +83,7 @@ export const exchangeHerokuOAuthCode = async (code: string): Promise { + 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) => { + const [hostIp] = await verifyHostInputValidity(config.credentials.host); + + let connection: Redis | null = null; + try { + connection = new Redis({ + username: config.credentials.username, + host: hostIp, + 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..f29a2d036 --- /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: false, + supportsGateways: false + }) +); + +export const UpdateRedisConnectionSchema = z + .object({ + credentials: RedisConnectionAccessTokenCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.Redis).credentials + ) + }) + .and( + GenericUpdateAppConnectionFieldsSchema(AppConnection.Redis, { + supportsPlatformManagedCredentials: false, + supportsGateways: false + }) + ); + +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/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index 2a680b9b8..d69c836e9 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -684,7 +684,8 @@ export const authLoginServiceFactory = ({ mfaJwtToken, ip, userAgent, - orgId + orgId, + isRecoveryCode = false }: TVerifyMfaTokenDTO) => { const appCfg = getConfig(); const user = await userDAL.findById(userId); @@ -698,16 +699,21 @@ export const authLoginServiceFactory = ({ code: mfaToken }); } else if (mfaMethod === MfaMethod.TOTP) { - if (mfaToken.length === 6) { - await totpService.verifyUserTotp({ - userId, - totp: mfaToken - }); - } else { + if (isRecoveryCode) { await totpService.verifyWithUserRecoveryCode({ userId, recoveryCode: mfaToken }); + } else { + if (mfaToken.length !== 6) { + throw new BadRequestError({ + message: "Please use a valid TOTP code." + }); + } + await totpService.verifyUserTotp({ + userId, + totp: mfaToken + }); } } } catch (err) { diff --git a/backend/src/services/auth/auth-login-type.ts b/backend/src/services/auth/auth-login-type.ts index 09d81033f..9b010a860 100644 --- a/backend/src/services/auth/auth-login-type.ts +++ b/backend/src/services/auth/auth-login-type.ts @@ -24,6 +24,7 @@ export type TVerifyMfaTokenDTO = { ip: string; userAgent: string; orgId?: string; + isRecoveryCode?: boolean; }; export type TOauthLoginDTO = { diff --git a/backend/src/services/auth/auth-password-service.ts b/backend/src/services/auth/auth-password-service.ts index efc8b3cc0..21a51ef3f 100644 --- a/backend/src/services/auth/auth-password-service.ts +++ b/backend/src/services/auth/auth-password-service.ts @@ -6,6 +6,7 @@ import { OrgServiceActor } from "@app/lib/types"; import { TAuthTokenServiceFactory } from "../auth-token/auth-token-service"; import { TokenType } from "../auth-token/auth-token-types"; +import { TOrgMembershipDALFactory } from "../org-membership/org-membership-dal"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { TTotpConfigDALFactory } from "../totp/totp-config-dal"; import { TUserDALFactory } from "../user/user-dal"; @@ -22,6 +23,7 @@ import { ActorType, AuthMethod, AuthTokenType } from "./auth-type"; type TAuthPasswordServiceFactoryDep = { authDAL: TAuthDALFactory; userDAL: TUserDALFactory; + orgMembershipDAL: Pick; tokenService: TAuthTokenServiceFactory; smtpService: TSmtpService; totpConfigDAL: Pick; @@ -31,6 +33,7 @@ export type TAuthPasswordFactory = ReturnType; export const authPaswordServiceFactory = ({ authDAL, userDAL, + orgMembershipDAL, tokenService, smtpService, totpConfigDAL @@ -47,21 +50,46 @@ export const authPaswordServiceFactory = ({ if (user && user.isAccepted) { const cfg = getConfig(); - const token = await tokenService.createTokenForUser({ - type: TokenType.TOKEN_EMAIL_PASSWORD_RESET, - userId: user.id - }); - await smtpService.sendMail({ - template: SmtpTemplates.ResetPassword, - recipients: [email], - subjectLine: "Infisical password reset", - substitutions: { + const hasEmailAuth = user.authMethods?.includes(AuthMethod.EMAIL); + + if (!hasEmailAuth) { + const orgMemberships = await orgMembershipDAL.find({ userId: user.id }); + const lastLoginMethod = + orgMemberships + .filter((membership) => membership.lastLoginAuthMethod) + .sort((a, b) => (b.updatedAt || new Date(0)).getTime() - (a.updatedAt || new Date(0)).getTime())[0] + ?.lastLoginAuthMethod || null; + const substitutions = { email, - token, - callback_url: cfg.SITE_URL ? `${cfg.SITE_URL}/password-reset` : "" - } - }); + lastLoginMethod, + isCloud: cfg.isCloud, + siteUrl: cfg.SITE_URL || "" + }; + + await smtpService.sendMail({ + template: SmtpTemplates.OAuthPasswordReset, + recipients: [email], + subjectLine: "Password reset not available", + substitutions + }); + } else { + const token = await tokenService.createTokenForUser({ + type: TokenType.TOKEN_EMAIL_PASSWORD_RESET, + userId: user.id + }); + + await smtpService.sendMail({ + template: SmtpTemplates.ResetPassword, + recipients: [email], + subjectLine: "Infisical password reset", + substitutions: { + email, + token, + callback_url: cfg.SITE_URL ? `${cfg.SITE_URL}/password-reset` : "" + } + }); + } } }; diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts index 6dba0150f..6b284ddee 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-fns.ts @@ -782,7 +782,7 @@ export const expandSecretReferencesFactory = ({ }; export const reshapeBridgeSecret = ( - workspaceId: string, + projectId: string, environment: string, secretPath: string, secret: Omit & { @@ -809,7 +809,8 @@ export const reshapeBridgeSecret = ( ) => ({ secretKey: secret.key, secretPath, - workspace: workspaceId, + workspace: projectId, + projectId, environment, secretComment: secret.comment || "", version: secret.version, diff --git a/backend/src/services/smtp/emails/OAuthPasswordResetTemplate.tsx b/backend/src/services/smtp/emails/OAuthPasswordResetTemplate.tsx new file mode 100644 index 000000000..97fc8d7e3 --- /dev/null +++ b/backend/src/services/smtp/emails/OAuthPasswordResetTemplate.tsx @@ -0,0 +1,80 @@ +import { Heading, Section, Text } from "@react-email/components"; +import React from "react"; + +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; +import { BaseLink } from "./BaseLink"; + +interface OAuthPasswordResetTemplateProps extends Omit { + email: string; + lastLoginMethod?: string | null; + isCloud: boolean; +} + +export const OAuthPasswordResetTemplate = ({ + email, + lastLoginMethod, + isCloud, + siteUrl +}: OAuthPasswordResetTemplateProps) => { + const getAuthMethodDisplayName = (method: string) => { + return method + .split("-") + .map((word) => { + const upperWord = word.toUpperCase(); + if (["SAML", "LDAP", "OIDC", "SSO"].includes(upperWord)) { + return upperWord; + } + return word.charAt(0).toUpperCase() + word.slice(1); + }) + .join(" "); + }; + + const getAuthMethodMessage = () => { + if (lastLoginMethod) { + const displayName = getAuthMethodDisplayName(lastLoginMethod); + return `Please continue by signing in with ${displayName}.`; + } + return "Please continue using the same authentication method you previously used to sign in (e.g., SSO, SAML, OAuth, or another configured provider)."; + }; + return ( + + + Password Reset Not Available + +
+ + Password reset is not available for this account. + + + A password reset was requested for your Infisical account ({email}), but password login has not been enabled + for your account. + + {getAuthMethodMessage()} + + If you did not initiate this request, please contact{" "} + {isCloud ? ( + <> + us immediately at support@infisical.com + + ) : ( + "your administrator immediately" + )} + . + +
+
+ ); +}; + +export default OAuthPasswordResetTemplate; + +OAuthPasswordResetTemplate.PreviewProps = { + email: "user@example.com", + lastLoginMethod: "github", + isCloud: true, + siteUrl: "https://infisical.com" +} as OAuthPasswordResetTemplateProps; diff --git a/backend/src/services/smtp/emails/index.ts b/backend/src/services/smtp/emails/index.ts index 71c338def..066744596 100644 --- a/backend/src/services/smtp/emails/index.ts +++ b/backend/src/services/smtp/emails/index.ts @@ -7,6 +7,7 @@ export * from "./ExternalImportStartedTemplate"; export * from "./ExternalImportSucceededTemplate"; export * from "./IntegrationSyncFailedTemplate"; export * from "./NewDeviceLoginTemplate"; +export * from "./OAuthPasswordResetTemplate"; export * from "./OrgAdminBreakglassAccessTemplate"; export * from "./OrgAdminProjectGrantAccessTemplate"; export * from "./OrganizationAssignmentTemplate"; diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index 224f78265..d64582fe0 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -16,6 +16,7 @@ import { ExternalImportSucceededTemplate, IntegrationSyncFailedTemplate, NewDeviceLoginTemplate, + OAuthPasswordResetTemplate, OrgAdminBreakglassAccessTemplate, OrgAdminProjectGrantAccessTemplate, OrganizationAssignmentTemplate, @@ -63,6 +64,7 @@ export enum SmtpTemplates { NewDeviceJoin = "newDevice", OrgInvite = "organizationInvitation", OrgAssignment = "organizationAssignment", + OAuthPasswordReset = "oAuthPasswordReset", ResetPassword = "passwordReset", SetupPassword = "passwordSetup", SecretLeakIncident = "secretLeakIncident", @@ -121,6 +123,7 @@ const EmailTemplateMap: Record> = { [SmtpTemplates.OrgAdminProjectDirectAccess]: OrgAdminProjectGrantAccessTemplate, [SmtpTemplates.ProjectAccessRequest]: ProjectAccessRequestTemplate, [SmtpTemplates.SecretApprovalRequestNeedsReview]: SecretApprovalRequestNeedsReviewTemplate, + [SmtpTemplates.OAuthPasswordReset]: OAuthPasswordResetTemplate, [SmtpTemplates.ResetPassword]: PasswordResetTemplate, [SmtpTemplates.SetupPassword]: PasswordSetupTemplate, [SmtpTemplates.PkiExpirationAlert]: PkiExpirationAlertTemplate, diff --git a/backend/src/services/totp/totp-service.ts b/backend/src/services/totp/totp-service.ts index 591a66ed6..193a27d90 100644 --- a/backend/src/services/totp/totp-service.ts +++ b/backend/src/services/totp/totp-service.ts @@ -131,15 +131,20 @@ export const totpServiceFactory = ({ totpConfigDAL, kmsService, userDAL }: TTotp secret }); - if (isValid) { - await totpConfigDAL.updateById(totpConfig.id, { - isVerified: true - }); - } else { + if (!isValid) { throw new BadRequestError({ message: "Invalid TOTP token" }); } + + await totpConfigDAL.updateById(totpConfig.id, { + isVerified: true + }); + + const recoveryCodes = decryptWithRoot(totpConfig.encryptedRecoveryCodes).toString().split(","); + return { + recoveryCodes + }; }; const verifyUserTotp = async ({ userId, totp }: TVerifyUserTotpDTO) => { 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..44aacca6d --- /dev/null +++ b/backend/src/services/upgrade-path/github-client.ts @@ -0,0 +1,242 @@ +/* eslint-disable no-await-in-loop */ +import RE2 from "re2"; + +import { getConfig } from "@app/lib/config/env"; + +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: getConfig().GITHUB_API_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; + } + + const patterns = [ + new RE2(/^v\d+\.\d+\.\d+/), + new RE2(/^\d+\.\d+\.\d+/), + new RE2(/^infisical\/v?\d+\.\d+\.\d+/), + new RE2(/^infisical\/v?\d+\.\d+\.\d+[-\w]*/) + ]; + + return patterns.some((pattern) => pattern.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 compareVersions = (v1: string, v2: string): number => { + const normalize = (v: string) => { + const versionMatch = v.match(new RE2(/(\d+\.\d+\.\d+(?:\.\d+)?)/)); + if (versionMatch) { + return versionMatch[1]; + } + if (v.startsWith("infisical/")) { + return v.replace(new RE2(/^infisical\/v?/), "").replace(new RE2(/-[a-zA-Z]+$/), ""); + } + return v.replace(new RE2(/^v/), "").replace(new RE2(/-[a-zA-Z]+$/), ""); + }; + + const clean1 = normalize(v1); + const clean2 = normalize(v2); + + const parts1 = clean1.split(".").map(Number); + const parts2 = clean2.split(".").map(Number); + + const maxLength = Math.max(parts1.length, parts2.length); + while (parts1.length < maxLength) parts1.push(0); + while (parts2.length < maxLength) parts2.push(0); + + for (let i = 0; i < maxLength; i += 1) { + if (parts1[i] > parts2[i]) return 1; + if (parts1[i] < parts2[i]) return -1; + } + return 0; +}; + +const isVersionAtLeastMinimum = (tagName: string, minimumVersion = "0.147.0"): boolean => { + return compareVersions(tagName, minimumVersion) >= 0; +}; + +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") { + if (retryCount < config.maxRetries) { + await delay(config.retryDelay * 2 ** retryCount); + return await makeRequest(url, config, retryCount + 1); + } + 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; + let reachedMinimumVersion = false; + + const maxConcurrentRequests = Math.min(3, config.maxPagesPerRequest); + + while (hasMorePages && page <= config.maxPagesPerRequest && !reachedMinimumVersion) { + 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) { + for (const release of data) { + if (!release.draft && isMainInfisicalRelease(release.tag_name)) { + if (isVersionAtLeastMinimum(release.tag_name)) { + allReleases.push(release); + } else { + reachedMinimumVersion = true; + break; + } + } + } + hasData = true; + } + } + } + + if (!hasData || results.every((r) => r.status === "fulfilled" && r.value.data.length < config.perPage)) { + hasMorePages = false; + } + } + + const formattedReleases = allReleases + .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-schemas.ts b/backend/src/services/upgrade-path/upgrade-path-schemas.ts new file mode 100644 index 000000000..a283505c7 --- /dev/null +++ b/backend/src/services/upgrade-path/upgrade-path-schemas.ts @@ -0,0 +1,24 @@ +import RE2 from "re2"; +import { z } from "zod"; + +export const versionSchema = z + .string() + .min(1) + .max(50) + .regex(new RE2(/^[a-zA-Z0-9._/-]+$/), "Invalid version format"); + +export const breakingChangeSchema = z.object({ + title: z.string().min(1).max(200), + description: z.string().min(1).max(1000), + action: z.string().min(1).max(500) +}); + +export const versionConfigSchema = z.object({ + breaking_changes: z.array(breakingChangeSchema).optional(), + db_schema_changes: z.string().max(1000).optional(), + notes: z.string().max(2000).optional() +}); + +export const upgradePathConfigSchema = z.object({ + versions: z.record(versionSchema, versionConfigSchema).optional().nullable() +}); 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..45c602d78 --- /dev/null +++ b/backend/src/services/upgrade-path/upgrade-path-service.ts @@ -0,0 +1,259 @@ +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 { logger } from "@app/lib/logger"; + +import { fetchReleases } from "./github-client"; +import { BreakingChange, FormattedRelease, UpgradePathConfig, UpgradePathResult, VersionConfig } from "./types"; +import { versionConfigSchema, versionSchema } from "./upgrade-path-schemas"; + +export type TUpgradePathServiceFactory = { + keyStore: TKeyStoreFactory; +}; +export type TUpgradePathService = ReturnType; + +interface CalculateUpgradePathParams { + fromVersion: string; + toVersion: string; +} + +export const upgradePathServiceFactory = ({ keyStore }: TUpgradePathServiceFactory) => { + const sanitizeCacheKey = (key: string): string => { + return key.replace(new RE2(/[^a-zA-Z0-9\-:._]/g), "_"); + }; + const getGitHubReleases = async (): Promise => { + const cacheKey = "upgrade-path:releases"; + + try { + const cached = await keyStore.getItem(cacheKey); + if (cached) { + const cachedReleases = JSON.parse(cached) as FormattedRelease[]; + if (cachedReleases.length > 0) { + return cachedReleases; + } + } + } catch (error) { + logger.error(error, "Failed to retrieve releases from cache"); + } + + try { + const releases = await fetchReleases(false); + 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) { + logger.error(error, "Failed to retrieve config from cache"); + } + + try { + const yamlPath = path.join(__dirname, "..", "..", "..", "upgrade-path.yaml"); + const resolvedPath = path.resolve(yamlPath); + const expectedBaseDir = path.resolve(__dirname, "..", "..", ".."); + if (!resolvedPath.startsWith(expectedBaseDir)) { + throw new Error("Invalid configuration file path"); + } + + const yamlContent = await readFile(yamlPath, "utf8"); + + if (yamlContent.length > 1024 * 1024) { + throw new Error("Config file too large"); + } + + const config = yaml.load(yamlContent, { schema: yaml.FAILSAFE_SCHEMA }) 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 => { + const versionRegex = new RE2(/(\d+\.\d+\.\d+(?:\.\d+)?)/); + const versionMatch = version.match(versionRegex); + if (versionMatch) { + return versionMatch[1]; + } + + 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 validateParams = (params: CalculateUpgradePathParams) => { + const { fromVersion, toVersion } = 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 }; + }; + + const calculateUpgradePath = async (params: CalculateUpgradePathParams): Promise => { + const { fromVersion, toVersion } = validateParams(params); + const cacheKey = sanitizeCacheKey(`upgrade-path:${fromVersion}:${toVersion}`); + + try { + const cached = await keyStore.getItem(cacheKey); + if (cached) return JSON.parse(cached) as UpgradePathResult; + } catch (error) { + logger.error(error, "Failed to retrieve upgrade path from cache"); + } + + const [releases, config] = await Promise.all([getGitHubReleases(), getUpgradePathConfig()]); + + const cleanFrom = normalizeVersion(fromVersion); + const cleanTo = normalizeVersion(toVersion); + + const compareVersions = (v1: string, v2: string): number => { + const normalize = (v: string) => normalizeVersion(v); + const clean1 = normalize(v1); + const clean2 = normalize(v2); + + const parts1 = clean1.split(".").map(Number); + const parts2 = clean2.split(".").map(Number); + + const maxLength = Math.max(parts1.length, parts2.length); + while (parts1.length < maxLength) parts1.push(0); + while (parts2.length < maxLength) parts2.push(0); + + for (let i = 0; i < maxLength; i += 1) { + if (parts1[i] > parts2[i]) return 1; + if (parts1[i] < parts2[i]) return -1; + } + return 0; + }; + + if (compareVersions(cleanFrom, cleanTo) >= 0) { + throw new Error("fromVersion must be older than toVersion"); + } + + const fromIdx = releases.findIndex((r) => normalizeVersion(r.normalizedTagName) === cleanFrom); + const toIdx = releases.findIndex((r) => normalizeVersion(r.normalizedTagName) === cleanTo); + + let upgradePath: FormattedRelease[] = []; + const filteredPath: FormattedRelease[] = []; + + if (fromIdx !== -1 && toIdx !== -1) { + if (fromIdx <= toIdx) throw new Error("Invalid version order"); + upgradePath = releases.slice(toIdx, fromIdx + 1).reverse(); + const [first, last] = [upgradePath[0], upgradePath[upgradePath.length - 1]]; + + filteredPath.push(first); + 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; + + const isVersionInRange = (version: string, fromVer: string, toVer: string): boolean => { + const versionComp = compareVersions(version, fromVer); + const toVersionComp = compareVersions(version, toVer); + return versionComp > 0 && toVersionComp < 0; + }; + + Object.keys(config).forEach((configVersion) => { + const versionConfig = config[configVersion]; + if (versionConfig?.breaking_changes?.length) { + if (isVersionInRange(configVersion, cleanFrom, cleanTo)) { + breakingChanges.push({ + version: configVersion, + changes: versionConfig.breaking_changes + }); + } + } + }); + for (let i = 0; i < upgradePath.length; i += 1) { + const version = upgradePath[i]; + const isFromVersion = normalizeVersion(version.normalizedTagName) === cleanFrom; + + 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) => calculateUpgradePath({ fromVersion, toVersion }) + }; +}; diff --git a/backend/src/services/webhook/webhook-fns.ts b/backend/src/services/webhook/webhook-fns.ts index d7e07ae28..e04b5097b 100644 --- a/backend/src/services/webhook/webhook-fns.ts +++ b/backend/src/services/webhook/webhook-fns.ts @@ -94,6 +94,7 @@ export const getWebhookPayload = (event: TWebhookPayloads) => { event: event.type, project: { workspaceId: projectId, + projectId, projectName, environment, secretPath @@ -147,6 +148,7 @@ export const getWebhookPayload = (event: TWebhookPayloads) => { event: event.type, project: { workspaceId: projectId, + projectId, projectName, environment, secretPath, diff --git a/backend/upgrade-path.yaml b/backend/upgrade-path.yaml new file mode 100644 index 000000000..4ccb7e775 --- /dev/null +++ b/backend/upgrade-path.yaml @@ -0,0 +1,26 @@ +# 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." + +versions: \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/redis/available.mdx b/docs/api-reference/endpoints/app-connections/redis/available.mdx new file mode 100644 index 000000000..6b0389d3f --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/redis/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/redis/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/redis/create.mdx b/docs/api-reference/endpoints/app-connections/redis/create.mdx new file mode 100644 index 000000000..b203048d6 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/redis/create.mdx @@ -0,0 +1,9 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/redis" +--- + + + Check out the configuration docs for [Redis Connections](/integrations/app-connections/redis) to learn how to obtain + the required credentials. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/redis/delete.mdx b/docs/api-reference/endpoints/app-connections/redis/delete.mdx new file mode 100644 index 000000000..bf8178a23 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/redis/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/redis/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/redis/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/redis/get-by-id.mdx new file mode 100644 index 000000000..9879fff7f --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/redis/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/redis/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/redis/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/redis/get-by-name.mdx new file mode 100644 index 000000000..42807f221 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/redis/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/redis/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/redis/list.mdx b/docs/api-reference/endpoints/app-connections/redis/list.mdx new file mode 100644 index 000000000..913da1679 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/redis/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/redis" +--- diff --git a/docs/api-reference/endpoints/app-connections/redis/update.mdx b/docs/api-reference/endpoints/app-connections/redis/update.mdx new file mode 100644 index 000000000..e2414b971 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/redis/update.mdx @@ -0,0 +1,9 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/redis/{connectionId}" +--- + + + Check out the configuration docs for [Redis Connections](/integrations/app-connections/redis) to learn how to obtain + the required credentials. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/secret-rotations/redis-credentials/create.mdx b/docs/api-reference/endpoints/secret-rotations/redis-credentials/create.mdx new file mode 100644 index 000000000..8bce0dc4f --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/redis-credentials/create.mdx @@ -0,0 +1,10 @@ +--- +title: "Create" +openapi: "POST /api/v2/secret-rotations/redis-credentials" +--- + + + Check out the configuration docs for [Redis + Credentials Rotations](/documentation/platform/secret-rotation/redis-credentials) to learn how to obtain the + required parameters. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/secret-rotations/redis-credentials/delete.mdx b/docs/api-reference/endpoints/secret-rotations/redis-credentials/delete.mdx new file mode 100644 index 000000000..28d9e3a6b --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/redis-credentials/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v2/secret-rotations/redis-credentials/{rotationId}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/redis-credentials/get-by-id.mdx b/docs/api-reference/endpoints/secret-rotations/redis-credentials/get-by-id.mdx new file mode 100644 index 000000000..a3ec932e0 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/redis-credentials/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v2/secret-rotations/redis-credentials/{rotationId}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/redis-credentials/get-by-name.mdx b/docs/api-reference/endpoints/secret-rotations/redis-credentials/get-by-name.mdx new file mode 100644 index 000000000..2dc50d581 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/redis-credentials/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v2/secret-rotations/redis-credentials/rotation-name/{rotationName}" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/redis-credentials/get-generated-credentials-by-id.mdx b/docs/api-reference/endpoints/secret-rotations/redis-credentials/get-generated-credentials-by-id.mdx new file mode 100644 index 000000000..c0002edd6 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/redis-credentials/get-generated-credentials-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Credentials by ID" +openapi: "GET /api/v2/secret-rotations/redis-credentials/{rotationId}/generated-credentials" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/redis-credentials/list.mdx b/docs/api-reference/endpoints/secret-rotations/redis-credentials/list.mdx new file mode 100644 index 000000000..bc72d101e --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/redis-credentials/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/secret-rotations/redis-credentials" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/redis-credentials/rotate-secrets.mdx b/docs/api-reference/endpoints/secret-rotations/redis-credentials/rotate-secrets.mdx new file mode 100644 index 000000000..53c2c7651 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/redis-credentials/rotate-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Rotate Secrets" +openapi: "POST /api/v2/secret-rotations/redis-credentials/{rotationId}/rotate-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-rotations/redis-credentials/update.mdx b/docs/api-reference/endpoints/secret-rotations/redis-credentials/update.mdx new file mode 100644 index 000000000..4817a0f65 --- /dev/null +++ b/docs/api-reference/endpoints/secret-rotations/redis-credentials/update.mdx @@ -0,0 +1,10 @@ +--- +title: "Update" +openapi: "PATCH /api/v2/secret-rotations/redis-credentials/{rotationId}" +--- + + + Check out the configuration docs for [Redis + Credentials Rotations](/documentation/platform/secret-rotation/redis-credentials) to learn how to obtain the + required parameters. + \ No newline at end of file diff --git a/docs/docs.json b/docs/docs.json index d71b16f7f..210c5145c 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -134,6 +134,7 @@ "integrations/app-connections/oracledb", "integrations/app-connections/postgres", "integrations/app-connections/railway", + "integrations/app-connections/redis", "integrations/app-connections/render", "integrations/app-connections/supabase", "integrations/app-connections/teamcity", @@ -442,7 +443,8 @@ "documentation/platform/secret-rotation/mysql-credentials", "documentation/platform/secret-rotation/okta-client-secret", "documentation/platform/secret-rotation/oracledb-credentials", - "documentation/platform/secret-rotation/postgres-credentials" + "documentation/platform/secret-rotation/postgres-credentials", + "documentation/platform/secret-rotation/redis-credentials" ] }, { @@ -453,6 +455,7 @@ "documentation/platform/dynamic-secrets/aws-elasticache", "documentation/platform/dynamic-secrets/aws-iam", "documentation/platform/dynamic-secrets/azure-entra-id", + "documentation/platform/dynamic-secrets/azure-sql-database", "documentation/platform/dynamic-secrets/cassandra", "documentation/platform/dynamic-secrets/couchbase", "documentation/platform/dynamic-secrets/elastic-search", @@ -1389,6 +1392,19 @@ "api-reference/endpoints/secret-rotations/postgres-credentials/rotate-secrets", "api-reference/endpoints/secret-rotations/postgres-credentials/update" ] + }, + { + "group": "Redis Credentials", + "pages": [ + "api-reference/endpoints/secret-rotations/redis-credentials/create", + "api-reference/endpoints/secret-rotations/redis-credentials/delete", + "api-reference/endpoints/secret-rotations/redis-credentials/get-by-id", + "api-reference/endpoints/secret-rotations/redis-credentials/get-by-name", + "api-reference/endpoints/secret-rotations/redis-credentials/get-generated-credentials-by-id", + "api-reference/endpoints/secret-rotations/redis-credentials/list", + "api-reference/endpoints/secret-rotations/redis-credentials/rotate-secrets", + "api-reference/endpoints/secret-rotations/redis-credentials/update" + ] } ] }, @@ -1866,6 +1882,18 @@ "api-reference/endpoints/app-connections/railway/delete" ] }, + { + "group": "Redis", + "pages": [ + "api-reference/endpoints/app-connections/redis/list", + "api-reference/endpoints/app-connections/redis/available", + "api-reference/endpoints/app-connections/redis/get-by-id", + "api-reference/endpoints/app-connections/redis/get-by-name", + "api-reference/endpoints/app-connections/redis/create", + "api-reference/endpoints/app-connections/redis/update", + "api-reference/endpoints/app-connections/redis/delete" + ] + }, { "group": "Render", "pages": [ diff --git a/docs/documentation/platform/dynamic-secrets/azure-sql-database.mdx b/docs/documentation/platform/dynamic-secrets/azure-sql-database.mdx new file mode 100644 index 000000000..f92ea7232 --- /dev/null +++ b/docs/documentation/platform/dynamic-secrets/azure-sql-database.mdx @@ -0,0 +1,184 @@ +--- +title: "Azure SQL Database" +description: "Learn how to dynamically generate Azure SQL Database user credentials." +--- + +The Infisical Azure SQL Database dynamic secret allows you to generate Azure SQL Database user credentials on demand based on configured roles. + +## How Azure SQL Database Authentication Works + +Azure SQL Database uses a two-tier authentication system that differs from traditional SQL Server: + +1. **Master Database**: Contains server-level logins that can authenticate to the Azure SQL Database server +2. **User Databases**: Individual databases that contain database users mapped to server logins + +When creating dynamic credentials for Azure SQL Database, Infisical performs a two-step process: +1. **Create Login in Master Database**: Creates a server-level login with the specified password +2. **Create User in Target Database**: Creates a database user mapped to the login and grants the necessary permissions + +This architecture ensures proper security isolation and follows Azure SQL Database best practices. + +## Prerequisite + +Create a user with the required permissions in your Azure SQL Database instance. This user will be used to create new accounts on-demand. + +The user needs: +- `loginmanager` role in the master database (to create logins) +- `db_owner` role in the target database (to create users and grant permissions) + +## Set up Dynamic Secrets with Azure SQL Database + + + + Open the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret. + + + ![Add Dynamic Secret Button](../../../images/platform/dynamic-secrets/add-dynamic-secret-button.png) + + + ![Dynamic Secret Modal](../../../images/platform/dynamic-secrets/azure-sql-database/add-dynamic-secret-button.png) + + + + Name by which you want the secret to be referenced + + + + Default time-to-live for a generated secret (it is possible to modify this value after a secret is generated) + + + + Maximum time-to-live for a generated secret + + + + List of key/value metadata pairs + + + + Azure SQL Database server hostname (e.g., myserver.database.windows.net) + + + + Database port (typically 1433 for Azure SQL Database) + + + + Username that will be used to create dynamic secrets (must have loginmanager role in master and db_owner in target database) + + + + Password that will be used to create dynamic secrets + + + + Name of the target database where users will be created and granted permissions + + + + Enable SSL encryption for the database connection (recommended for Azure SQL Database) + + + + SSL certificate authority certificate. For Azure SQL Database, this is typically not required as Azure manages the certificates. + + + ![Dynamic Secret Setup Modal](../../../images/platform/dynamic-secrets/azure-sql-database/create-dynamic-secret-form.png) + + + + ![Modify SQL Statements Modal](../../../images/platform/dynamic-secrets/azure-sql-database/modify-sql-statements-azure-sql.png) + + Azure SQL Database dynamic secrets use predefined SQL statements that follow Azure's security best practices: + + + SQL statement executed in the master database to create a server-level login. This login allows authentication to the Azure SQL Database server. + + + + SQL statement executed in the target database to create a database user and grant permissions. The user is mapped to the login created in the master database. + + + + SQL statements executed when a lease expires or is manually revoked. The system intelligently routes DROP USER commands to the target database and DROP LOGIN commands to the master database for proper cleanup. + + + + Specifies a template for generating usernames. This field allows customization of how usernames are automatically created. + + Allowed template variables are: + - `{{randomUsername}}`: Random username string + - `{{unixTimestamp}}`: Current Unix timestamp + - `{{identity.name}}`: Name of the identity that is generating the secret + - `{{random N}}`: Random string of N characters + + Allowed template functions are: + - `truncate`: Truncates a string to a specified length + - `replace`: Replaces a substring with another value + + Examples: + ``` + {{randomUsername}} // 3POnzeFyK9gW2nioK0q2gMjr6CZqsRiX + {{unixTimestamp}} // 17490641580 + {{identity.name}} // testuser + {{random-5}} // x9k2m + {{truncate identity.name 4}} // test + {{replace identity.name 'user' 'replace'}} // testreplace + ``` + + + + + After submitting the form, you will see a dynamic secret created in the dashboard. + + + If this step fails, ensure your user has the proper permissions in both the master database (`loginmanager` role) and target database (`db_owner` role). + + + ![Dynamic Secret](../../../images/platform/dynamic-secrets/dynamic-secret.png) + + + + Once you've successfully configured the dynamic secret, you're ready to generate on-demand credentials. + To do this, simply click on the 'Generate' button which appears when hovering over the dynamic secret item. + Alternatively, you can initiate the creation of a new lease by selecting 'New Lease' from the dynamic secret lease list section. + + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-generate.png) + ![Dynamic Secret](/images/platform/dynamic-secrets/dynamic-secret-lease-empty.png) + + When generating these secrets, it's important to specify a Time-to-Live (TTL) duration. This will dictate how long the credentials are valid for. + + ![Provision Lease](/images/platform/dynamic-secrets/provision-lease.png) + + + Ensure that the TTL for the lease falls within the maximum TTL defined when configuring the dynamic secret. + + + Once you click the `Submit` button, a new secret lease will be generated and the credentials for it will be shown to you. + + ![Provision Lease](/images/platform/dynamic-secrets/lease-values.png) + + + + +## Audit or Revoke Leases + +Once you have created one or more leases, you will be able to access them by clicking on the respective dynamic secret item on the dashboard. +This will allow you to see the expiration time of the lease or delete the lease before its set time to live. + +When a lease is revoked or expires, Infisical automatically: +1. **Drops the user** from the target database +2. **Drops the login** from the master database + +This ensures complete cleanup and prevents orphaned credentials. + +![Provision Lease](/images/platform/dynamic-secrets/lease-data.png) + +## Renew Leases + +To extend the life of the generated dynamic secret leases past its initial time to live, simply click on the **Renew** button as illustrated below. +![Provision Lease](/images/platform/dynamic-secrets/dynamic-secret-lease-renew.png) + + + Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret + diff --git a/docs/documentation/platform/secret-rotation/redis-credentials.mdx b/docs/documentation/platform/secret-rotation/redis-credentials.mdx new file mode 100644 index 000000000..577d0e956 --- /dev/null +++ b/docs/documentation/platform/secret-rotation/redis-credentials.mdx @@ -0,0 +1,158 @@ +--- +title: "Redis Credentials Rotation" +description: "Learn how to automatically rotate Redis credentials." +--- + +## Prerequisites + +1. Create a [Redis Connection](/integrations/app-connections/redis) with the required **Secret Rotation** permissions +2. Ensure your network security policies allow incoming requests from Infisical to this rotation provider, if network restrictions apply. + +Create a Redis Credentials Rotation in Infisical + + + + 1. Navigate to your Secret Manager Project's Dashboard and select **Add Secret Rotation** from the actions dropdown. + ![Secret Manager Dashboard](/images/secret-rotations-v2/generic/add-secret-rotation.png) + + 2. Select the **Redis Credentials** option. + ![Select Redis Credentials](/images/secret-rotations-v2/redis-credentials/select-redis-credentials-option.png) + + 3. Select the **Redis Connection** to use and configure the rotation behavior. Then click **Next**. + ![Rotation Configuration](/images/secret-rotations-v2/redis-credentials/redis-credentials-configuration.png) + + - **Redis Connection** - the connection that will perform the rotation of the configured database user credentials. + - **Rotation Interval** - the interval, in days, that once elapsed will trigger a rotation. + - **Rotate At** - the local time of day when rotation should occur once the interval has elapsed. + - **Auto-Rotation Enabled** - whether secrets should automatically be rotated once the rotation interval has elapsed. Disable this option to manually rotate secrets or pause secret rotation. + + 4. Input the password requirements and permission scope for the Redis users that will be created for the rotation. Then click **Next**. + ![Rotation Parameters](/images/secret-rotations-v2/redis-credentials/redis-credentials-parameters.png) + + - **Permission Scope** - The scope of the Redis users that will be created for the rotation. This will default to `~* +@all` if not specified. + - **Password Requirements** - The requirements for the password of the Redis users that will be created for the rotation. + + 5. Specify the secret names that the active credentials should be mapped to. Then click **Next**. + ![Rotation Secrets Mapping](/images/secret-rotations-v2/redis-credentials/redis-credentials-secrets-mapping.png) + + - **Username** - the name of the secret that the active username will be mapped to. + - **Password** - the name of the secret that the active password will be mapped to. + + 6. Give your rotation a name and description (optional). Then click **Next**. + ![Rotation Details](/images/secret-rotations-v2/redis-credentials/redis-credentials-details.png) + + - **Name** - the name of the secret rotation configuration. Must be slug-friendly. + - **Description** (optional) - a description of this rotation configuration. + + 7. Review your configuration, then click **Create Secret Rotation**. + ![Rotation Review](/images/secret-rotations-v2/redis-credentials/redis-credentials-confirm.png) + + 8. Your **Redis Credentials** are now available for use via the mapped secrets. + ![Rotation Created](/images/secret-rotations-v2/redis-credentials/redis-credentials-created.png) + + + To create a Redis Credentials Rotation, make an API request to the [Create Redis + Credentials Rotation](/api-reference/endpoints/secret-rotations/redis-credentials/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://us.infisical.com/api/v2/secret-rotations/redis-credentials \ + --header 'Content-Type: application/json' \ + --data '{ + "name": my-redis-rotation", + "projectId": "", + "description": "", + "connectionId": "", + "environment": "dev|staging|prod", + "secretPath": "", + "isAutoRotationEnabled": true, + "rotationInterval": 2, + "rotateAtUtc": { + "hours": 11.5, + "minutes": 29.5 + }, + "parameters": { + "passwordRequirements": { + "length": 64, + "required": { + "digits": 1, + "lowercase": 1, + "uppercase": 1, + "symbols": 1 + }, + "allowedSymbols": "@!+" + }, + "permissionScope": "~* +@all" + }, + "secretsMapping": { + "username": "REDIS_USERNAME", + "password": "REDIS_PASSWORD" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "secretRotation": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-redis-rotation", + "description": "my database credentials rotation", + "isAutoRotationEnabled": true, + "activeIndex": 0, + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2023-11-07T05:31:56Z", + "updatedAt": "2023-11-07T05:31:56Z", + "rotationInterval": 30, + "rotationStatus": "success", + "lastRotationAttemptedAt": "2023-11-07T05:31:56Z", + "lastRotatedAt": "2023-11-07T05:31:56Z", + "lastRotationJobId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "nextRotationAt": "2023-11-07T05:31:56Z", + "connection": { + "app": "redis", + "name": "my-redis-connection", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "environment": { + "slug": "dev", + "name": "Development", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "folder": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "path": "/" + }, + "rotateAtUtc": { + "hours": 0, + "minutes": 0 + }, + "lastRotationMessage": null, + "type": "redis-credentials", + "parameters": { + "passwordRequirements": { + "length": 64, + "required": { + "digits": 1, + "lowercase": 1, + "uppercase": 1, + "symbols": 1 + }, + "allowedSymbols": "@!+" + }, + "permissionScope": "~* +@all" + }, + "secretsMapping": { + "username": "REDIS_USERNAME", + "password": "REDIS_PASSWORD" + } + } + } + ``` + + diff --git a/docs/images/app-connections/redis/redis-app-connection-form.png b/docs/images/app-connections/redis/redis-app-connection-form.png new file mode 100644 index 000000000..05db36fd5 Binary files /dev/null and b/docs/images/app-connections/redis/redis-app-connection-form.png differ diff --git a/docs/images/app-connections/redis/redis-app-connection-generated.png b/docs/images/app-connections/redis/redis-app-connection-generated.png new file mode 100644 index 000000000..b3eb5af72 Binary files /dev/null and b/docs/images/app-connections/redis/redis-app-connection-generated.png differ diff --git a/docs/images/app-connections/redis/redis-app-connection-option.png b/docs/images/app-connections/redis/redis-app-connection-option.png new file mode 100644 index 000000000..46f3dea6d Binary files /dev/null and b/docs/images/app-connections/redis/redis-app-connection-option.png differ diff --git a/docs/images/integrations/heroku/integrations-heroku-config-credentials.png b/docs/images/integrations/heroku/integrations-heroku-config-credentials.png index e84aaca01..82588e7f1 100644 Binary files a/docs/images/integrations/heroku/integrations-heroku-config-credentials.png and b/docs/images/integrations/heroku/integrations-heroku-config-credentials.png differ diff --git a/docs/images/integrations/heroku/integrations-heroku-config-new-app-form.png b/docs/images/integrations/heroku/integrations-heroku-config-new-app-form.png index dee5a5336..bbc62f70a 100644 Binary files a/docs/images/integrations/heroku/integrations-heroku-config-new-app-form.png and b/docs/images/integrations/heroku/integrations-heroku-config-new-app-form.png differ diff --git a/docs/images/platform/dynamic-secrets/azure-sql-database/add-dynamic-secret-button.png b/docs/images/platform/dynamic-secrets/azure-sql-database/add-dynamic-secret-button.png new file mode 100644 index 000000000..3adbc8a9e Binary files /dev/null and b/docs/images/platform/dynamic-secrets/azure-sql-database/add-dynamic-secret-button.png differ diff --git a/docs/images/platform/dynamic-secrets/azure-sql-database/create-dynamic-secret-form.png b/docs/images/platform/dynamic-secrets/azure-sql-database/create-dynamic-secret-form.png new file mode 100644 index 000000000..3092ab7b3 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/azure-sql-database/create-dynamic-secret-form.png differ diff --git a/docs/images/platform/dynamic-secrets/azure-sql-database/modify-sql-statements-azure-sql.png b/docs/images/platform/dynamic-secrets/azure-sql-database/modify-sql-statements-azure-sql.png new file mode 100644 index 000000000..e04aae8c4 Binary files /dev/null and b/docs/images/platform/dynamic-secrets/azure-sql-database/modify-sql-statements-azure-sql.png differ diff --git a/docs/images/secret-rotations-v2/redis-credentials/redis-credentials-configuration.png b/docs/images/secret-rotations-v2/redis-credentials/redis-credentials-configuration.png new file mode 100644 index 000000000..280a8bed6 Binary files /dev/null and b/docs/images/secret-rotations-v2/redis-credentials/redis-credentials-configuration.png differ diff --git a/docs/images/secret-rotations-v2/redis-credentials/redis-credentials-confirm.png b/docs/images/secret-rotations-v2/redis-credentials/redis-credentials-confirm.png new file mode 100644 index 000000000..efff5b168 Binary files /dev/null and b/docs/images/secret-rotations-v2/redis-credentials/redis-credentials-confirm.png differ diff --git a/docs/images/secret-rotations-v2/redis-credentials/redis-credentials-created.png b/docs/images/secret-rotations-v2/redis-credentials/redis-credentials-created.png new file mode 100644 index 000000000..2e6dd994c Binary files /dev/null and b/docs/images/secret-rotations-v2/redis-credentials/redis-credentials-created.png differ diff --git a/docs/images/secret-rotations-v2/redis-credentials/redis-credentials-details.png b/docs/images/secret-rotations-v2/redis-credentials/redis-credentials-details.png new file mode 100644 index 000000000..9a7552f7c Binary files /dev/null and b/docs/images/secret-rotations-v2/redis-credentials/redis-credentials-details.png differ diff --git a/docs/images/secret-rotations-v2/redis-credentials/redis-credentials-parameters.png b/docs/images/secret-rotations-v2/redis-credentials/redis-credentials-parameters.png new file mode 100644 index 000000000..4d9db7dd4 Binary files /dev/null and b/docs/images/secret-rotations-v2/redis-credentials/redis-credentials-parameters.png differ diff --git a/docs/images/secret-rotations-v2/redis-credentials/redis-credentials-secrets-mapping.png b/docs/images/secret-rotations-v2/redis-credentials/redis-credentials-secrets-mapping.png new file mode 100644 index 000000000..10d02a191 Binary files /dev/null and b/docs/images/secret-rotations-v2/redis-credentials/redis-credentials-secrets-mapping.png differ diff --git a/docs/images/secret-rotations-v2/redis-credentials/select-redis-credentials-option.png b/docs/images/secret-rotations-v2/redis-credentials/select-redis-credentials-option.png new file mode 100644 index 000000000..979a77b43 Binary files /dev/null and b/docs/images/secret-rotations-v2/redis-credentials/select-redis-credentials-option.png differ 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 000000000..a8a538aaf Binary files /dev/null and b/docs/images/self-hosting/helper/upgrade-path-tool.png differ diff --git a/docs/integrations/app-connections/heroku.mdx b/docs/integrations/app-connections/heroku.mdx index 9c3397e02..d0824741c 100644 --- a/docs/integrations/app-connections/heroku.mdx +++ b/docs/integrations/app-connections/heroku.mdx @@ -24,7 +24,7 @@ Infisical supports two methods for connecting to Heroku: **OAuth** and **Auth To ![Heroku config applications](/images/integrations/heroku/integrations-heroku-config-applications.png) ![Heroku config new app](/images/integrations/heroku/integrations-heroku-config-new-app.png) - Create the API client. As part of the form, set the **OAuth callback URL** to `https://your-domain.com/integrations/heroku/oauth2/callback`. + Create the API client. As part of the form, set the **OAuth callback URL** to `https://your-domain.com/organization/app-connections/heroku/oauth/callback`. The domain you defined in the OAuth callback URL should be equivalent to the `SITE_URL` configured in your Infisical instance. @@ -39,8 +39,8 @@ Infisical supports two methods for connecting to Heroku: **OAuth** and **Auth To Back in your Infisical instance, add two new environment variables for the credentials of your Heroku API client: - - `CLIENT_ID_HEROKU`: The **Client ID** of your Heroku API client. - - `CLIENT_SECRET_HEROKU`: The **Client Secret** of your Heroku API client. + - `INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_ID`: The **Client ID** of your Heroku API client. + - `INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_SECRET`: The **Client Secret** of your Heroku API client. Once added, restart your Infisical instance and use the Heroku Connection. diff --git a/docs/integrations/app-connections/redis.mdx b/docs/integrations/app-connections/redis.mdx new file mode 100644 index 000000000..224abbea8 --- /dev/null +++ b/docs/integrations/app-connections/redis.mdx @@ -0,0 +1,126 @@ +--- +title: "Redis Connection" +description: "Learn how to configure a Redis Connection for Infisical." +--- + +Infisical supports the use of Username & Password authentication to connect with Redis databases + +## Configure a Redis user for Infisical + + + + Infisical recommends creating a designated user in your Redis database for your connection. + + ```bash + ACL SETUSER user_manager on >[ENTER-YOUR-USER-PASSWORD] + ``` + + + + Depending on how you intend to use your Redis connection, you'll need to grant one or more of the following permissions. + + + To learn more about Redis's permission system, please visit their [documentation](https://redis.io/docs/latest/operate/oss_and_stack/management/security/acl/). + + + + + For Secret Rotations, your Infisical user will require the ability to set and delete users: + + ```bash + ACL SETUSER user_manager +acl|setuser +acl|deluser ~* + ``` + + + + + + +## Create Redis Connection in Infisical + + + + + + In your Infisical dashboard, navigate to the **App Connections** page in the desired project. + + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + + Click the **+ Add Connection** button and select the **Redis Connection** option from the available integrations. + + ![Select Redis Connection](/images/app-connections/redis/redis-app-connection-option.png) + + + Complete the Redis Connection form by entering: + - A descriptive name for the connection + - An optional description for future reference + - The Redis host URL for your database + - The Redis port for your Redis database + - The Redis username for your Redis database + - The Redis password for your Redis database + + You can optionally configure SSL/TLS for your Redis connection in the **SSL** section. + + + ![Redis Connection Modal](/images/app-connections/redis/redis-app-connection-form.png) + + + After clicking Create, your **Redis Connection** is established and ready to use with your Infisical project. + + ![Redis Connection Created](/images/app-connections/redis/redis-app-connection-generated.png) + + + + + To create a Redis Connection, make an API request to the [Create Redis Connection](/api-reference/endpoints/app-connections/redis/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/redis \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-redis-connection", + "method": "username-and-password", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", + "credentials": { + "host": "[REDIS HOST]", + "port": 6379, + "username": "[REDIS USERNAME]", + "password": "[REDIS PASSWORD]", + } + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "e5d18aca-86f7-4026-a95e-efb8aeb0d8e6", + "name": "my-redis-connection", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", + "description": null, + "version": 1, + "orgId": "6f03caa1-a5de-43ce-b127-95a145d3464c", + "createdAt": "2025-04-23T19:46:34.831Z", + "updatedAt": "2025-04-23T19:46:34.831Z", + "isPlatformManagedCredentials": false, + "credentialsHash": "7c2d371dec195f82a6a0d5b41c970a229cfcaf88e894a5b6395e2dbd0280661f", + "app": "redis", + "method": "username-and-password", + credentials: { + "host": "", + "port": 6379, + "username": "", + "sslEnabled": true, + "sslRejectUnauthorized": false, + "sslCertificate": "" + } + } + } + ``` + + diff --git a/docs/integrations/platforms/kubernetes/infisical-secret-crd.mdx b/docs/integrations/platforms/kubernetes/infisical-secret-crd.mdx index 5368aeb3f..d7fb6249a 100644 --- a/docs/integrations/platforms/kubernetes/infisical-secret-crd.mdx +++ b/docs/integrations/platforms/kubernetes/infisical-secret-crd.mdx @@ -68,6 +68,22 @@ When `hostAPI` is not defined the operator fetches secrets from Infisical Cloud. available on paid plans. Default re-sync interval is every 1 minute. + + This property enables instant updates from Infisical. When set to true, + changes made to secrets in Infisical will be immediately pushed to the + operator, triggering a configuration update. This reduces the need for + periodic re-syncs. + + + Note that `Instant Updates` is a paid feature. + + If you're using Infisical Cloud, then it is available under the **Pro**, + and **Enterprise Tier** with varying retention periods. If you're self-hosting Infisical, + then you should contact sales@infisical.com to purchase an enterprise license to use it. + + + + This block defines the TLS settings to use for connecting to the Infisical instance. diff --git a/docs/self-hosting/configuration/envars.mdx b/docs/self-hosting/configuration/envars.mdx index 8d351d7f2..551c79184 100644 --- a/docs/self-hosting/configuration/envars.mdx +++ b/docs/self-hosting/configuration/envars.mdx @@ -672,6 +672,16 @@ You can configure third-party app connections for re-use across Infisical Projec + + + The Application ID of your Heroku OAuth application. + + + + The Secret of your Heroku OAuth application. + + + ## Native Secret Integrations To help you sync secrets from Infisical to services such as Github and Gitlab, Infisical provides native integrations out of the box. diff --git a/docs/self-hosting/guides/upgrading-infisical.mdx b/docs/self-hosting/guides/upgrading-infisical.mdx index cae8193bf..0ffe32346 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/docs/snippets/AppConnectionsBrowser.jsx b/docs/snippets/AppConnectionsBrowser.jsx index 72ad40cff..951a643dd 100644 --- a/docs/snippets/AppConnectionsBrowser.jsx +++ b/docs/snippets/AppConnectionsBrowser.jsx @@ -42,6 +42,7 @@ export const AppConnectionsBrowser = () => { {"name": "PostgreSQL", "slug": "postgres", "path": "/integrations/app-connections/postgres", "description": "Learn how to connect your PostgreSQL database to pull secrets from Infisical.", "category": "Databases"}, {"name": "Microsoft SQL Server", "slug": "mssql", "path": "/integrations/app-connections/mssql", "description": "Learn how to connect your SQL Server database to pull secrets from Infisical.", "category": "Databases"}, {"name": "Oracle Database", "slug": "oracledb", "path": "/integrations/app-connections/oracledb", "description": "Learn how to connect your Oracle database to pull secrets from Infisical.", "category": "Databases"}, + {"name": "Redis", "slug": "redis", "path": "/integrations/app-connections/redis", "description": "Learn how to connect Redis to pull secrets from Infisical.", "category": "Databases"}, {"name": "LDAP", "slug": "ldap", "path": "/integrations/app-connections/ldap", "description": "Learn how to connect your LDAP to pull secrets from Infisical.", "category": "Directory Services"}, {"name": "Auth0", "slug": "auth0", "path": "/integrations/app-connections/auth0", "description": "Learn how to connect your Auth0 to pull secrets from Infisical.", "category": "Identity & Auth"}, {"name": "Okta", "slug": "okta", "path": "/integrations/app-connections/okta", "description": "Learn how to connect your Okta to pull secrets from Infisical.", "category": "Identity & Auth"} diff --git a/docs/snippets/DynamicSecretsBrowser.jsx b/docs/snippets/DynamicSecretsBrowser.jsx index 6438deea0..650e47c3c 100644 --- a/docs/snippets/DynamicSecretsBrowser.jsx +++ b/docs/snippets/DynamicSecretsBrowser.jsx @@ -10,6 +10,7 @@ export const DynamicSecretsBrowser = () => { {"name": "AWS IAM", "slug": "aws-iam", "path": "/documentation/platform/dynamic-secrets/aws-iam", "description": "Learn how to generate dynamic AWS IAM credentials on-demand.", "category": "Cloud Providers"}, {"name": "AWS ElastiCache", "slug": "aws-elasticache", "path": "/documentation/platform/dynamic-secrets/aws-elasticache", "description": "Learn how to generate dynamic AWS ElastiCache credentials on-demand.", "category": "Caches"}, {"name": "Azure Entra ID", "slug": "azure-entra-id", "path": "/documentation/platform/dynamic-secrets/azure-entra-id", "description": "Learn how to generate dynamic Azure Entra ID credentials on-demand.", "category": "Cloud Providers"}, + {"name": "Azure SQL Database", "slug": "azure-sql-database", "path": "/documentation/platform/dynamic-secrets/azure-sql-database", "description": "Learn how to generate dynamic Azure SQL Database credentials on-demand.", "category": "Databases"}, {"name": "GCP IAM", "slug": "gcp-iam", "path": "/documentation/platform/dynamic-secrets/gcp-iam", "description": "Learn how to generate dynamic GCP IAM credentials on-demand.", "category": "Cloud Providers"}, {"name": "Cassandra", "slug": "cassandra", "path": "/documentation/platform/dynamic-secrets/cassandra", "description": "Learn how to generate dynamic Cassandra database credentials on-demand.", "category": "Databases"}, {"name": "Couchbase", "slug": "couchbase", "path": "/documentation/platform/dynamic-secrets/couchbase", "description": "Learn how to generate dynamic Couchbase database credentials on-demand.", "category": "Databases"}, diff --git a/docs/snippets/RotationsBrowser.jsx b/docs/snippets/RotationsBrowser.jsx index 3d50656b6..3dbede698 100644 --- a/docs/snippets/RotationsBrowser.jsx +++ b/docs/snippets/RotationsBrowser.jsx @@ -14,6 +14,7 @@ export const RotationsBrowser = () => { {"name": "LDAP Password", "slug": "ldap-password", "path": "/documentation/platform/secret-rotation/ldap-password", "description": "Learn how to automatically rotate LDAP user passwords.", "category": "Identity & Auth"}, {"name": "MySQL", "slug": "mysql-credentials", "path": "/documentation/platform/secret-rotation/mysql-credentials", "description": "Learn how to automatically rotate MySQL database credentials.", "category": "Databases"}, {"name": "PostgreSQL", "slug": "postgres-credentials", "path": "/documentation/platform/secret-rotation/postgres-credentials", "description": "Learn how to automatically rotate PostgreSQL database credentials.", "category": "Databases"}, + {"name": "Redis", "slug": "redis-credentials", "path": "/documentation/platform/secret-rotation/redis-credentials", "description": "Learn how to automatically rotate Redis database credentials.", "category": "Databases"}, {"name": "Microsoft SQL Server", "slug": "mssql-credentials", "path": "/documentation/platform/secret-rotation/mssql-credentials", "description": "Learn how to automatically rotate Microsoft SQL Server credentials.", "category": "Databases"}, {"name": "Oracle Database", "slug": "oracledb-credentials", "path": "/documentation/platform/secret-rotation/oracledb-credentials", "description": "Learn how to automatically rotate Oracle Database credentials.", "category": "Databases"} ].sort(function(a, b) { diff --git a/frontend/public/images/integrations/Redis.png b/frontend/public/images/integrations/Redis.png new file mode 100644 index 000000000..3ef8adffd Binary files /dev/null and b/frontend/public/images/integrations/Redis.png differ diff --git a/frontend/src/components/auth/Mfa.tsx b/frontend/src/components/auth/Mfa.tsx index 64c8a5ee6..efc33d52b 100644 --- a/frontend/src/components/auth/Mfa.tsx +++ b/frontend/src/components/auth/Mfa.tsx @@ -5,10 +5,12 @@ import { t } from "i18next"; import Error from "@app/components/basic/Error"; import TotpRegistration from "@app/components/mfa/TotpRegistration"; +import { createNotification } from "@app/components/notifications"; import SecurityClient from "@app/components/utilities/SecurityClient"; -import { Button, Input } from "@app/components/v2"; -import { useSendMfaToken } from "@app/hooks/api"; -import { checkUserTotpMfa, verifyMfaToken } from "@app/hooks/api/auth/queries"; +import { Button, Tooltip } from "@app/components/v2"; +import { isInfisicalCloud } from "@app/helpers/platform"; +import { useLogoutUser, useSendMfaToken } from "@app/hooks/api"; +import { checkUserTotpMfa, verifyMfaToken, verifyRecoveryCode } from "@app/hooks/api/auth/queries"; import { MfaMethod } from "@app/hooks/api/auth/types"; // The style for the verification code input @@ -17,10 +19,10 @@ const codeInputProps = { fontFamily: "monospace", margin: "4px", MozAppearance: "textfield", - width: "48px", + width: "55px", borderRadius: "5px", fontSize: "24px", - height: "48px", + height: "55px", paddingLeft: "7", backgroundColor: "#0d1117", color: "white", @@ -60,11 +62,13 @@ type Props = { export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Props) => { const [mfaCode, setMfaCode] = useState(""); + const [showRecoveryCodeInput, setShowRecoveryCodeInput] = useState(false); const navigate = useNavigate(); const [isLoading, setIsLoading] = useState(false); const [isLoadingResend, setIsLoadingResend] = useState(false); const [triesLeft, setTriesLeft] = useState(undefined); const [shouldShowTotpRegistration, setShouldShowTotpRegistration] = useState(false); + const logout = useLogoutUser(true); const sendMfaToken = useSendMfaToken(); @@ -79,35 +83,57 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Prop } }, []); + const getExpectedCodeLength = () => { + if (method === MfaMethod.EMAIL) return 6; + if (method === MfaMethod.TOTP) return showRecoveryCodeInput ? 8 : 6; + return 6; + }; + + const isCodeComplete = mfaCode.length === getExpectedCodeLength(); + const verifyMfa = async (event: React.FormEvent) => { event.preventDefault(); + if (!mfaCode.trim() || !isCodeComplete) return; + setIsLoading(true); try { - const { token } = await verifyMfaToken({ - email, - mfaCode, - mfaMethod: method - }); + let result; + + if (method === MfaMethod.TOTP && showRecoveryCodeInput) { + result = await verifyRecoveryCode(mfaCode.trim()); + } else { + result = await verifyMfaToken({ + email, + mfaCode: mfaCode.trim(), + mfaMethod: method + }); + } SecurityClient.setMfaToken(""); - SecurityClient.setToken(token); + SecurityClient.setToken(result.token); await successCallback(); if (closeMfa) { closeMfa(); } } catch { - if (triesLeft) { - setTriesLeft((left) => { - if (triesLeft === 1) { - navigate({ to: "/" }); - - SecurityClient.setMfaToken(""); - SecurityClient.setToken(""); - } - return (left as number) - 1; - }); + if (typeof triesLeft === "number") { + const newTriesLeft = triesLeft - 1; + setTriesLeft(newTriesLeft); + if (newTriesLeft <= 0) { + createNotification({ + text: "User is temporary locked due to multiple failed login attempts. Try again later. You can also reset your password now to proceed.", + type: "error" + }); + setIsLoading(false); + SecurityClient.setMfaToken(""); + SecurityClient.setToken(""); + SecurityClient.setSignupToken(""); + await logout.mutateAsync(); + navigate({ to: "/login" }); + return; + } } else { setTriesLeft(2); } @@ -147,7 +173,7 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Prop } return ( -
+
{!hideLogo && (
@@ -162,79 +188,134 @@ export const Mfa = ({ successCallback, closeMfa, hideLogo, email, method }: Prop )} {method === MfaMethod.TOTP && ( - <> -

- Authenticator MFA Required +

+

Two-Factor Authentication

+

+ {showRecoveryCodeInput + ? "Enter one of your backup recovery codes" + : "Enter the verification code from your authenticator app"}

-

- Open the authenticator app on your mobile device to get your verification code or enter - a recovery code. -

- +
)}
-
+
{method === MfaMethod.EMAIL && ( - +
+ +
)} {method === MfaMethod.TOTP && ( -
- setMfaCode(e.target.value)} /> +
+
)}
-
+
{method === MfaMethod.EMAIL && ( - +
+ +
)} {method === MfaMethod.TOTP && ( -
- setMfaCode(e.target.value)} /> +
+
)}
{typeof triesLeft === "number" && ( )} -
-
- -
+
+
{method === MfaMethod.TOTP && ( -
- - - Lost your recovery codes? Reset your account - - +
+ +
+ + {isInfisicalCloud() ? ( + <> +
Account Recovery Required
+
+ Contact support with valid proof of account ownership to initiate recovery +
+
support@infisical.com
+ + ) : ( + <> +
Account Recovery Required
+
+ Contact your instance administrator with valid proof of account ownership to + initiate recovery +
+ + )} +
+ } + > + + Lost your recovery codes? + + +
)} {method === MfaMethod.EMAIL && ( diff --git a/frontend/src/components/mfa/RecoveryCodesDownload.tsx b/frontend/src/components/mfa/RecoveryCodesDownload.tsx new file mode 100644 index 000000000..50219f7dc --- /dev/null +++ b/frontend/src/components/mfa/RecoveryCodesDownload.tsx @@ -0,0 +1,111 @@ +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 = [...recoveryCodes].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..0b79bfd98 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 && result.recoveryCodes.length > 0) { + 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/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..0aeffef21 --- /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 ( + <> +
+ ( + +

+ This is the access control permissions that will be set for the issued Redis + users. The format must be a valid Redis ACL pattern. +

+

+ The default value is{" "} + + ~* +@all + + . You can modify it to suit your needs. +

+

+ For more information, please refer to the{" "} + + Redis ACL documentation + + . +

+
+ } + 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..26fccf963 --- /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().trim().min(1, "Permission scope is required") + }), + 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/components/v2/Input/Input.tsx b/frontend/src/components/v2/Input/Input.tsx index d325719e6..fa3982abd 100644 --- a/frontend/src/components/v2/Input/Input.tsx +++ b/frontend/src/components/v2/Input/Input.tsx @@ -12,6 +12,7 @@ type Props = { isReadOnly?: boolean; autoCapitalization?: boolean; containerClassName?: string; + warning?: ReactNode; }; const inputVariants = cva( @@ -84,6 +85,7 @@ export const Input = forwardRef( size = "md", isReadOnly, autoCapitalization, + warning, ...props }, ref @@ -116,10 +118,11 @@ export const Input = forwardRef( onInput={handleInput} className={twMerge( leftIcon ? "pl-10" : "pl-2.5", - rightIcon ? "pr-10" : "pr-2.5", + rightIcon || warning ? "pr-10" : "pr-2.5", inputVariants({ className, isError, size, isRounded, variant }) )} /> + {Boolean(warning) && !rightIcon && warning} {rightIcon && {rightIcon}}
); diff --git a/frontend/src/config/request.ts b/frontend/src/config/request.ts index 9a0629619..a37b38cb6 100644 --- a/frontend/src/config/request.ts +++ b/frontend/src/config/request.ts @@ -1,7 +1,15 @@ import axios from "axios"; +import { addSeconds, formatISO } from "date-fns"; +import { createNotification } from "@app/components/notifications"; import SecurityClient from "@app/components/utilities/SecurityClient"; -import { getAuthToken, getMfaTempToken, getSignupTempToken } from "@app/hooks/api/reactQuery"; +import { SessionStorageKeys } from "@app/const"; +import { + getAuthToken, + getMfaTempToken, + getSignupTempToken, + setAuthToken +} from "@app/hooks/api/reactQuery"; export const apiRequest = axios.create({ baseURL: "/", @@ -34,3 +42,68 @@ apiRequest.interceptors.request.use((config) => { return config; }); + +let isRedirecting = false; + +const resetRedirectingFlag = () => { + isRedirecting = false; +}; + +apiRequest.interceptors.response.use( + (response) => response, + async (error) => { + const { response } = error; + + if (response && (response.status === 401 || response.status === 403)) { + const currentToken = getAuthToken(); + const isAuthenticatedRequest = Boolean(currentToken); + + if (isAuthenticatedRequest && !isRedirecting) { + // Check if the error indicates token expiration + const errorMessage = response.data?.message || ""; + const isTokenExpired = errorMessage + .toLowerCase() + .includes("your token has expired. please re-authenticate."); + + if (isTokenExpired) { + isRedirecting = true; + + try { + setAuthToken(""); + SecurityClient.setToken(""); + } catch (err) { + console.warn("Error clearing tokens:", err); + } + + createNotification({ + type: "error", + title: "Session Expired", + text: "Your session has expired. Redirecting to login page..." + }); + + try { + sessionStorage.setItem( + SessionStorageKeys.ORG_LOGIN_SUCCESS_REDIRECT_URL, + JSON.stringify({ + expiry: formatISO(addSeconds(new Date(), 300)), // 5 minutes + data: window.location.href + }) + ); + } catch (err) { + console.warn("Could not save redirect URL to sessionStorage:", err); + } + + setTimeout(() => { + window.location.href = "/login"; + }, 2000); // 2 seconds to read the notification + + setTimeout(resetRedirectingFlag, 3000); + + return Promise.reject(new Error("Session expired - redirecting to login")); + } + } + } + + return Promise.reject(error); + } +); diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index 99103c794..d0e3dcba1 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -36,6 +36,7 @@ import { OnePassConnectionMethod, OracleDBConnectionMethod, PostgresConnectionMethod, + RedisConnectionMethod, TAppConnection, TeamCityConnectionMethod, TerraformCloudConnectionMethod, @@ -113,7 +114,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"]) => { @@ -155,6 +157,7 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) case MySqlConnectionMethod.UsernameAndPassword: case OracleDBConnectionMethod.UsernameAndPassword: case AzureADCSConnectionMethod.UsernamePassword: + case RedisConnectionMethod.UsernameAndPassword: return { name: "Username & Password", icon: faLock }; case HCVaultConnectionMethod.AccessToken: case TeamCityConnectionMethod.AccessToken: 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/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/dynamicSecret/types.ts b/frontend/src/hooks/api/dynamicSecret/types.ts index 03e018dae..8584d6947 100644 --- a/frontend/src/hooks/api/dynamicSecret/types.ts +++ b/frontend/src/hooks/api/dynamicSecret/types.ts @@ -29,6 +29,7 @@ export enum DynamicSecretProviders { MongoDB = "mongo-db", RabbitMq = "rabbit-mq", AzureEntraId = "azure-entra-id", + AzureSqlDatabase = "azure-sql-database", Ldap = "ldap", SapHana = "sap-hana", Snowflake = "snowflake", @@ -242,6 +243,34 @@ export type TDynamicSecretProvider = clientSecret: string; }; } + | { + type: DynamicSecretProviders.AzureSqlDatabase; + inputs: { + host: string; + port: number; + database: string; + masterDatabase?: string; + username: string; + password: string; + passwordRequirements?: { + length: number; + required: { + lowercase: number; + uppercase: number; + digits: number; + symbols: number; + }; + allowedSymbols?: string; + }; + masterCreationStatement: string; + creationStatement: string; + revocationStatement: string; + renewStatement?: string; + ca?: string; + sslEnabled?: boolean; + gatewayId?: string; + }; + } | { type: DynamicSecretProviders.Ldap; inputs: { 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..58ef1e01a --- /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/hooks/api/upgradePath/index.ts b/frontend/src/hooks/api/upgradePath/index.ts new file mode 100644 index 000000000..d0eaefdb5 --- /dev/null +++ b/frontend/src/hooks/api/upgradePath/index.ts @@ -0,0 +1,2 @@ +export type { CalculateUpgradePathParams, 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..d7e8d705e --- /dev/null +++ b/frontend/src/hooks/api/upgradePath/queries.tsx @@ -0,0 +1,75 @@ +import { useMutation, 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 CalculateUpgradePathParams { + fromVersion: string; + toVersion: string; +} + +const upgradePathKeys = { + all: ["upgrade-path"] as const, + versions: () => [...upgradePathKeys.all, "versions"] as const, + calculate: (params: CalculateUpgradePathParams) => + [...upgradePathKeys.all, "calculate", params] as const +}; + +export const useGetUpgradePathVersions = ( + options?: Omit, "queryKey" | "queryFn"> +) => { + return useQuery({ + queryKey: upgradePathKeys.versions(), + queryFn: async () => { + const { data } = await apiRequest.get<{ versions: GitHubVersion[] }>( + "/api/v1/upgrade-path/versions" + ); + return data; + }, + ...options + }); +}; + +export const useCalculateUpgradePath = () => { + return useMutation({ + mutationFn: async (params: CalculateUpgradePathParams): Promise => { + const { data } = await apiRequest.post( + "/api/v1/upgrade-path/calculate", + params + ); + 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; } }); }; diff --git a/frontend/src/hooks/api/users/queries.tsx b/frontend/src/hooks/api/users/queries.tsx index 2c0125361..22d4f39d8 100644 --- a/frontend/src/hooks/api/users/queries.tsx +++ b/frontend/src/hooks/api/users/queries.tsx @@ -508,7 +508,7 @@ export const useListUserGroupMemberships = (username: string) => { }); }; -export const useGetUserTotpRegistration = () => { +export const useGetUserTotpRegistration = (options?: { enabled?: boolean }) => { return useQuery({ queryKey: userKeys.totpRegistration, queryFn: async () => { @@ -517,7 +517,8 @@ export const useGetUserTotpRegistration = () => { ); return data; - } + }, + enabled: options?.enabled ?? true }); }; diff --git a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx index 71be38b11..128760ab4 100644 --- a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx @@ -13,6 +13,7 @@ import { faInfoCircle, faServer, faSignOut, + faToolbox, faUser, faUsers } from "@fortawesome/free-solid-svg-icons"; @@ -104,6 +105,11 @@ export const INFISICAL_SUPPORT_OPTIONS = [ , "Instance Admins", () => "server-admins" + ], + [ + , + "Version Upgrade Tool", + () => "/upgrade-path" ] ] as const; @@ -345,6 +351,9 @@ export const Navbar = () => { if (url === "server-admins" && isInfisicalCloud()) { return null; } + if (url === "upgrade-path" && isInfisicalCloud()) { + return null; + } return ( {url === "server-admins" ? ( diff --git a/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx b/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx index 4dd0bbe24..1fe0d881f 100644 --- a/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx +++ b/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx @@ -117,12 +117,28 @@ export const SelectOrganizationSection = () => { } } - const { token, isMfaEnabled, mfaMethod } = await selectOrg - .mutateAsync({ + let token; + let isMfaEnabled; + let mfaMethod; + + try { + const result = await selectOrg.mutateAsync({ organizationId: organization.id, userAgent: callbackPort ? UserAgentType.CLI : undefined - }) - .finally(() => setIsInitialOrgCheckLoading(false)); + }); + token = result.token; + isMfaEnabled = result.isMfaEnabled; + mfaMethod = result.mfaMethod; + } catch (error: any) { + setIsInitialOrgCheckLoading(false); + if (error?.response?.status === 403) { + await handleLogout(); + return; + } + throw error; + } finally { + setIsInitialOrgCheckLoading(false); + } await router.invalidate(); 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..bb331bf3d 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; @@ -142,7 +143,7 @@ const CreateForm = ({ app, onComplete, projectId }: CreateFormProps) => { case AppConnection.OnePass: return ; case AppConnection.Heroku: - return ; + return ; case AppConnection.Render: return ; case AppConnection.Flyio: @@ -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}`); } @@ -285,7 +288,13 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { case AppConnection.OnePass: return ; case AppConnection.Heroku: - return ; + return ( + + ); case AppConnection.Render: return ; case AppConnection.Flyio: @@ -314,6 +323,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { return ; case AppConnection.Okta: return ; + case AppConnection.Redis: + return ; default: throw new Error(`Unhandled App ${(appConnection as TAppConnection).app}`); } diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/GitHubConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/GitHubConnectionForm.tsx index c69f662c6..5faa7d5b0 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/GitHubConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/GitHubConnectionForm.tsx @@ -39,7 +39,7 @@ import { } from "@app/hooks/api/appConnections"; import { AppConnection } from "@app/hooks/api/appConnections/enums"; -import { GithubFormData } from "../../../OauthCallbackPage/OauthCallbackPage.types"; +import { GitHubFormData } from "../../../OauthCallbackPage/OauthCallbackPage.types"; import { genericAppConnectionFieldsSchema, GenericAppConnectionsFields @@ -118,7 +118,7 @@ export const GitHubConnectionForm = ({ appConnection, projectId }: Props) => { connectionId: appConnection?.id, projectId, returnUrl - } as GithubFormData) + } as GitHubFormData) ); const githubHost = diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/GitHubRadarConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/GitHubRadarConnectionForm.tsx index 446eeb4cc..5cb559410 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/GitHubRadarConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/GitHubRadarConnectionForm.tsx @@ -19,7 +19,7 @@ import { } from "@app/hooks/api/appConnections"; import { AppConnection } from "@app/hooks/api/appConnections/enums"; -import { GithubRadarFormData } from "../../../OauthCallbackPage/OauthCallbackPage.types"; +import { GitHubRadarFormData } from "../../../OauthCallbackPage/OauthCallbackPage.types"; import { genericAppConnectionFieldsSchema, GenericAppConnectionsFields @@ -76,7 +76,7 @@ export const GitHubRadarConnectionForm = ({ appConnection, projectId }: Props) = connectionId: appConnection?.id, projectId, returnUrl - } as GithubRadarFormData) + } as GitHubRadarFormData) ); switch (formData.method) { diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/HerokuAppConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/HerokuAppConnectionForm.tsx index 085ef49c5..ebc81e138 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/HerokuAppConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/HerokuAppConnectionForm.tsx @@ -15,7 +15,11 @@ import { Select, SelectItem } from "@app/components/v2"; -import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; +import { + APP_CONNECTION_MAP, + getAppConnectionMethodDetails, + useGetAppConnectionOauthReturnUrl +} from "@app/helpers/appConnections"; import { isInfisicalCloud } from "@app/helpers/platform"; import { useGetAppConnectionOption } from "@app/hooks/api/appConnections"; import { AppConnection } from "@app/hooks/api/appConnections/enums"; @@ -32,6 +36,7 @@ import { type Props = { appConnection?: THerokuConnection; onSubmit: (formData: FormData) => Promise; + projectId: string | undefined | null; }; const formSchema = z.discriminatedUnion("method", [ @@ -53,10 +58,12 @@ const formSchema = z.discriminatedUnion("method", [ type FormData = z.infer; -export const HerokuConnectionForm = ({ appConnection, onSubmit: formSubmit }: Props) => { +export const HerokuConnectionForm = ({ appConnection, onSubmit: formSubmit, projectId }: Props) => { const isUpdate = Boolean(appConnection); const [isRedirecting, setIsRedirecting] = useState(false); + const returnUrl = useGetAppConnectionOauthReturnUrl(); + const { option: { oauthClientId }, isLoading @@ -110,7 +117,8 @@ export const HerokuConnectionForm = ({ appConnection, onSubmit: formSubmit }: Pr JSON.stringify({ ...formData, connectionId: appConnection?.id, - isUpdate + returnUrl, + projectId }) ); 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 + + + )} + /> + ( + +