Merge branch 'main' into ENG-3723
@@ -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=
|
||||
|
||||
@@ -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 }}
|
||||
39
.github/workflows/validate-upgrade-path.yml
vendored
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
10
backend/package-lock.json
generated
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
107
backend/scripts/validate-upgrade-path-file.ts
Normal file
@@ -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<void> {
|
||||
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);
|
||||
});
|
||||
2
backend/src/@types/fastify.d.ts
vendored
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<number>((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<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">;
|
||||
gatewayV2Service: Pick<TGatewayV2ServiceFactory, "getPlatformConnectionDetailsByGatewayId">;
|
||||
};
|
||||
|
||||
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<typeof DynamicSecretAzureSqlDBSchema> & { 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@<azure-server-name>" 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<typeof DynamicSecretAzureSqlDBSchema>,
|
||||
gatewayCallback: (host: string, port: number) => Promise<void>
|
||||
) => {
|
||||
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
|
||||
};
|
||||
};
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
@@ -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";
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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<TRedisCredentialsRotationGeneratedCredentials> = async (
|
||||
callback
|
||||
) => {
|
||||
const credentials = await $rotateAclUser();
|
||||
|
||||
return callback(credentials);
|
||||
};
|
||||
|
||||
/**
|
||||
* Revokes a list of credentials.
|
||||
*/
|
||||
const revokeCredentials: TRotationFactoryRevokeCredentials<TRedisCredentialsRotationGeneratedCredentials> = 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<TRedisCredentialsRotationGeneratedCredentials> = 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<TRedisCredentialsRotationGeneratedCredentials> = ({
|
||||
username,
|
||||
password
|
||||
}) => [
|
||||
{ key: secretsMapping.username, value: username },
|
||||
{ key: secretsMapping.password, value: password }
|
||||
];
|
||||
|
||||
return {
|
||||
issueCredentials,
|
||||
revokeCredentials,
|
||||
rotateCredentials,
|
||||
getSecretsPayload
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
});
|
||||
@@ -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<typeof RedisCredentialsRotationSchema>;
|
||||
|
||||
export type TRedisCredentialsRotationInput = z.infer<typeof CreateRedisCredentialsRotationSchema>;
|
||||
|
||||
export type TRedisCredentialsRotationListItem = z.infer<typeof RedisCredentialsRotationListItemSchema>;
|
||||
|
||||
export type TRedisCredentialsRotationWithConnection = TRedisCredentialsRotation & {
|
||||
connection: TRedisConnection;
|
||||
};
|
||||
|
||||
export type TRedisCredentialsRotationGeneratedCredentials = z.infer<
|
||||
typeof RedisCredentialsRotationGeneratedCredentialsSchema
|
||||
>;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<SecretRotation, TSecretRotationV2List
|
||||
[SecretRotation.AzureClientSecret]: AZURE_CLIENT_SECRET_ROTATION_LIST_OPTION,
|
||||
[SecretRotation.AwsIamUserSecret]: AWS_IAM_USER_SECRET_ROTATION_LIST_OPTION,
|
||||
[SecretRotation.LdapPassword]: LDAP_PASSWORD_ROTATION_LIST_OPTION,
|
||||
[SecretRotation.OktaClientSecret]: OKTA_CLIENT_SECRET_ROTATION_LIST_OPTION
|
||||
[SecretRotation.OktaClientSecret]: OKTA_CLIENT_SECRET_ROTATION_LIST_OPTION,
|
||||
[SecretRotation.RedisCredentials]: REDIS_CREDENTIALS_ROTATION_LIST_OPTION
|
||||
};
|
||||
|
||||
export const listSecretRotationOptions = () => {
|
||||
|
||||
@@ -10,7 +10,8 @@ export const SECRET_ROTATION_NAME_MAP: Record<SecretRotation, string> = {
|
||||
[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<SecretRotation, AppConnection> = {
|
||||
@@ -22,5 +23,6 @@ export const SECRET_ROTATION_CONNECTION_MAP: Record<SecretRotation, AppConnectio
|
||||
[SecretRotation.AzureClientSecret]: AppConnection.AzureClientSecrets,
|
||||
[SecretRotation.AwsIamUserSecret]: AppConnection.AWS,
|
||||
[SecretRotation.LdapPassword]: AppConnection.LDAP,
|
||||
[SecretRotation.OktaClientSecret]: AppConnection.Okta
|
||||
[SecretRotation.OktaClientSecret]: AppConnection.Okta,
|
||||
[SecretRotation.RedisCredentials]: AppConnection.Redis
|
||||
};
|
||||
|
||||
@@ -85,6 +85,7 @@ import { TSecretVersionV2TagDALFactory } from "@app/services/secret-v2-bridge/se
|
||||
import { TGatewayV2ServiceFactory } from "../gateway-v2/gateway-v2-service";
|
||||
import { awsIamUserSecretRotationFactory } from "./aws-iam-user-secret/aws-iam-user-secret-rotation-fns";
|
||||
import { oktaClientSecretRotationFactory } from "./okta-client-secret/okta-client-secret-rotation-fns";
|
||||
import { redisCredentialsRotationFactory } from "./redis-credentials/redis-credentials-rotation-fns";
|
||||
import { TSecretRotationV2DALFactory } from "./secret-rotation-v2-dal";
|
||||
|
||||
export type TSecretRotationV2ServiceFactoryDep = {
|
||||
@@ -132,7 +133,8 @@ const SECRET_ROTATION_FACTORY_MAP: Record<SecretRotation, TRotationFactoryImplem
|
||||
[SecretRotation.AzureClientSecret]: azureClientSecretRotationFactory as TRotationFactoryImplementation,
|
||||
[SecretRotation.AwsIamUserSecret]: awsIamUserSecretRotationFactory as TRotationFactoryImplementation,
|
||||
[SecretRotation.LdapPassword]: ldapPasswordRotationFactory as TRotationFactoryImplementation,
|
||||
[SecretRotation.OktaClientSecret]: oktaClientSecretRotationFactory as TRotationFactoryImplementation
|
||||
[SecretRotation.OktaClientSecret]: oktaClientSecretRotationFactory as TRotationFactoryImplementation,
|
||||
[SecretRotation.RedisCredentials]: redisCredentialsRotationFactory as TRotationFactoryImplementation
|
||||
};
|
||||
|
||||
export const secretRotationV2ServiceFactory = ({
|
||||
|
||||
@@ -66,6 +66,13 @@ import {
|
||||
TPostgresCredentialsRotationListItem,
|
||||
TPostgresCredentialsRotationWithConnection
|
||||
} from "./postgres-credentials";
|
||||
import {
|
||||
TRedisCredentialsRotation,
|
||||
TRedisCredentialsRotationGeneratedCredentials,
|
||||
TRedisCredentialsRotationInput,
|
||||
TRedisCredentialsRotationListItem,
|
||||
TRedisCredentialsRotationWithConnection
|
||||
} from "./redis-credentials/redis-credentials-rotation-types";
|
||||
import { TSecretRotationV2DALFactory } from "./secret-rotation-v2-dal";
|
||||
import { SecretRotation } from "./secret-rotation-v2-enums";
|
||||
|
||||
@@ -78,7 +85,8 @@ export type TSecretRotationV2 =
|
||||
| TAzureClientSecretRotation
|
||||
| TLdapPasswordRotation
|
||||
| TAwsIamUserSecretRotation
|
||||
| TOktaClientSecretRotation;
|
||||
| TOktaClientSecretRotation
|
||||
| TRedisCredentialsRotation;
|
||||
|
||||
export type TSecretRotationV2WithConnection =
|
||||
| TPostgresCredentialsRotationWithConnection
|
||||
@@ -89,7 +97,8 @@ export type TSecretRotationV2WithConnection =
|
||||
| TAzureClientSecretRotationWithConnection
|
||||
| TLdapPasswordRotationWithConnection
|
||||
| TAwsIamUserSecretRotationWithConnection
|
||||
| TOktaClientSecretRotationWithConnection;
|
||||
| TOktaClientSecretRotationWithConnection
|
||||
| TRedisCredentialsRotationWithConnection;
|
||||
|
||||
export type TSecretRotationV2GeneratedCredentials =
|
||||
| TSqlCredentialsRotationGeneratedCredentials
|
||||
@@ -97,7 +106,8 @@ export type TSecretRotationV2GeneratedCredentials =
|
||||
| TAzureClientSecretRotationGeneratedCredentials
|
||||
| TLdapPasswordRotationGeneratedCredentials
|
||||
| TAwsIamUserSecretRotationGeneratedCredentials
|
||||
| TOktaClientSecretRotationGeneratedCredentials;
|
||||
| TOktaClientSecretRotationGeneratedCredentials
|
||||
| TRedisCredentialsRotationGeneratedCredentials;
|
||||
|
||||
export type TSecretRotationV2Input =
|
||||
| TPostgresCredentialsRotationInput
|
||||
@@ -108,7 +118,8 @@ export type TSecretRotationV2Input =
|
||||
| TAzureClientSecretRotationInput
|
||||
| TLdapPasswordRotationInput
|
||||
| TAwsIamUserSecretRotationInput
|
||||
| TOktaClientSecretRotationInput;
|
||||
| TOktaClientSecretRotationInput
|
||||
| TRedisCredentialsRotationInput;
|
||||
|
||||
export type TSecretRotationV2ListItem =
|
||||
| TPostgresCredentialsRotationListItem
|
||||
@@ -119,7 +130,8 @@ export type TSecretRotationV2ListItem =
|
||||
| TAzureClientSecretRotationListItem
|
||||
| TLdapPasswordRotationListItem
|
||||
| TAwsIamUserSecretRotationListItem
|
||||
| TOktaClientSecretRotationListItem;
|
||||
| TOktaClientSecretRotationListItem
|
||||
| TRedisCredentialsRotationListItem;
|
||||
|
||||
export type TSecretRotationV2TemporaryParameters = TLdapPasswordRotationInput["temporaryParameters"] | undefined;
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { MySqlCredentialsRotationSchema } from "@app/ee/services/secret-rotation
|
||||
import { OktaClientSecretRotationSchema } from "@app/ee/services/secret-rotation-v2/okta-client-secret";
|
||||
import { OracleDBCredentialsRotationSchema } from "@app/ee/services/secret-rotation-v2/oracledb-credentials";
|
||||
import { PostgresCredentialsRotationSchema } from "@app/ee/services/secret-rotation-v2/postgres-credentials";
|
||||
import { RedisCredentialsRotationSchema } from "@app/ee/services/secret-rotation-v2/redis-credentials";
|
||||
|
||||
export const SecretRotationV2Schema = z.discriminatedUnion("type", [
|
||||
PostgresCredentialsRotationSchema,
|
||||
@@ -19,5 +20,6 @@ export const SecretRotationV2Schema = z.discriminatedUnion("type", [
|
||||
AzureClientSecretRotationSchema,
|
||||
LdapPasswordRotationSchema,
|
||||
AwsIamUserSecretRotationSchema,
|
||||
OktaClientSecretRotationSchema
|
||||
OktaClientSecretRotationSchema,
|
||||
RedisCredentialsRotationSchema
|
||||
]);
|
||||
|
||||
@@ -1049,7 +1049,7 @@ export const RAW_SECRETS = {
|
||||
},
|
||||
GET_ACCESS_LIST: {
|
||||
secretName: "The name of the secret to get the access list for.",
|
||||
workspaceId: "The ID of the project where the secret is located.",
|
||||
projectId: "The ID of the project where the secret is located.",
|
||||
environment: "The slug of the environment where the the secret is located.",
|
||||
secretPath: "The folder path where the secret is located."
|
||||
}
|
||||
@@ -2686,9 +2686,16 @@ export const SecretRotations = {
|
||||
},
|
||||
OKTA_CLIENT_SECRET: {
|
||||
clientId: "The ID of the Okta Application to rotate the client secret for."
|
||||
},
|
||||
REDIS_CREDENTIALS: {
|
||||
permissionScope: "The ACL permission scope to assign to the issued Redis users."
|
||||
}
|
||||
},
|
||||
SECRETS_MAPPING: {
|
||||
REDIS_CREDENTIALS: {
|
||||
username: "The name of the secret that the username will be mapped to.",
|
||||
password: "The name of the secret that the rotated password will be mapped to."
|
||||
},
|
||||
SQL_CREDENTIALS: {
|
||||
username: "The name of the secret that the active username will be mapped to.",
|
||||
password: "The name of the secret that the generated password will be mapped to."
|
||||
|
||||
@@ -129,6 +129,8 @@ const envSchema = z
|
||||
POSTHOG_HOST: zpStr(z.string().optional().default("https://app.posthog.com")),
|
||||
POSTHOG_PROJECT_API_KEY: zpStr(z.string().optional().default("phc_nSin8j5q2zdhpFDI1ETmFNUIuTG4DwKVyIigrY10XiE")),
|
||||
LOOPS_API_KEY: zpStr(z.string().optional()),
|
||||
// GitHub API token for upgrade path tool
|
||||
GITHUB_API_TOKEN: zpStr(z.string().optional()),
|
||||
// jwt options
|
||||
AUTH_SECRET: zpStr(z.string()).default(process.env.JWT_AUTH_SECRET), // for those still using old JWT_AUTH_SECRET
|
||||
JWT_AUTH_LIFETIME: zpStr(z.string().default("10d")),
|
||||
@@ -323,6 +325,10 @@ const envSchema = z
|
||||
INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_ID: zpStr(z.string().optional()),
|
||||
INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_SECRET: zpStr(z.string().optional()),
|
||||
|
||||
// Heroku App Connection
|
||||
INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_ID: zpStr(z.string().optional()),
|
||||
INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_SECRET: zpStr(z.string().optional()),
|
||||
|
||||
// datadog
|
||||
SHOULD_USE_DATADOG_TRACER: zodStrBool.default("false"),
|
||||
DATADOG_PROFILING_ENABLED: zodStrBool.default("false"),
|
||||
@@ -433,7 +439,10 @@ const envSchema = z
|
||||
INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID:
|
||||
data.INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID || data.INF_APP_CONNECTION_AZURE_CLIENT_ID,
|
||||
INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_SECRET:
|
||||
data.INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_SECRET || data.INF_APP_CONNECTION_AZURE_CLIENT_SECRET
|
||||
data.INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_SECRET || data.INF_APP_CONNECTION_AZURE_CLIENT_SECRET,
|
||||
INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_ID: data.INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_ID || data.CLIENT_ID_HEROKU,
|
||||
INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_SECRET:
|
||||
data.INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_SECRET || data.CLIENT_SECRET_HEROKU
|
||||
}));
|
||||
|
||||
export type TEnvConfig = Readonly<z.infer<typeof envSchema>>;
|
||||
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<AppConnection, (server:
|
||||
[AppConnection.Supabase]: registerSupabaseConnectionRouter,
|
||||
[AppConnection.DigitalOcean]: registerDigitalOceanConnectionRouter,
|
||||
[AppConnection.Netlify]: registerNetlifyConnectionRouter,
|
||||
[AppConnection.Okta]: registerOktaConnectionRouter
|
||||
[AppConnection.Okta]: registerOktaConnectionRouter,
|
||||
[AppConnection.Redis]: registerRedisConnectionRouter
|
||||
};
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||
import {
|
||||
CreateRedisConnectionSchema,
|
||||
SanitizedRedisConnectionSchema,
|
||||
UpdateRedisConnectionSchema
|
||||
} from "@app/services/app-connection/redis";
|
||||
|
||||
import { registerAppConnectionEndpoints } from "./app-connection-endpoints";
|
||||
|
||||
export const registerRedisConnectionRouter = async (server: FastifyZodProvider) => {
|
||||
registerAppConnectionEndpoints({
|
||||
app: AppConnection.Redis,
|
||||
server,
|
||||
sanitizedResponseSchema: SanitizedRedisConnectionSchema,
|
||||
createSchema: CreateRedisConnectionSchema,
|
||||
updateSchema: UpdateRedisConnectionSchema
|
||||
});
|
||||
};
|
||||
@@ -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" });
|
||||
};
|
||||
|
||||
117
backend/src/server/routes/v1/upgrade-path-router.ts
Normal file
@@ -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" });
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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], {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -36,7 +36,8 @@ export enum AppConnection {
|
||||
Supabase = "supabase",
|
||||
DigitalOcean = "digital-ocean",
|
||||
Netlify = "netlify",
|
||||
Okta = "okta"
|
||||
Okta = "okta",
|
||||
Redis = "redis"
|
||||
}
|
||||
|
||||
export enum AWSRegion {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -38,7 +38,8 @@ export const APP_CONNECTION_NAME_MAP: Record<AppConnection, string> = {
|
||||
[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<AppConnection, AppConnectionPlanType> = {
|
||||
@@ -79,5 +80,6 @@ export const APP_CONNECTION_PLAN_MAP: Record<AppConnection, AppConnectionPlanTyp
|
||||
[AppConnection.Supabase]: AppConnectionPlanType.Regular,
|
||||
[AppConnection.DigitalOcean]: AppConnectionPlanType.Regular,
|
||||
[AppConnection.Netlify]: AppConnectionPlanType.Regular,
|
||||
[AppConnection.Okta]: AppConnectionPlanType.Regular
|
||||
[AppConnection.Okta]: AppConnectionPlanType.Regular,
|
||||
[AppConnection.Redis]: AppConnectionPlanType.Regular
|
||||
};
|
||||
|
||||
@@ -99,6 +99,7 @@ import { oktaConnectionService } from "./okta/okta-connection-service";
|
||||
import { ValidatePostgresConnectionCredentialsSchema } from "./postgres";
|
||||
import { ValidateRailwayConnectionCredentialsSchema } from "./railway";
|
||||
import { railwayConnectionService } from "./railway/railway-connection-service";
|
||||
import { ValidateRedisConnectionCredentialsSchema } from "./redis";
|
||||
import { ValidateRenderConnectionCredentialsSchema } from "./render/render-connection-schema";
|
||||
import { renderConnectionService } from "./render/render-connection-service";
|
||||
import { ValidateSupabaseConnectionCredentialsSchema } from "./supabase";
|
||||
@@ -166,7 +167,8 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record<AppConnection, TValidateAp
|
||||
[AppConnection.Supabase]: ValidateSupabaseConnectionCredentialsSchema,
|
||||
[AppConnection.DigitalOcean]: ValidateDigitalOceanConnectionCredentialsSchema,
|
||||
[AppConnection.Netlify]: ValidateNetlifyConnectionCredentialsSchema,
|
||||
[AppConnection.Okta]: ValidateOktaConnectionCredentialsSchema
|
||||
[AppConnection.Okta]: ValidateOktaConnectionCredentialsSchema,
|
||||
[AppConnection.Redis]: ValidateRedisConnectionCredentialsSchema
|
||||
};
|
||||
|
||||
export const appConnectionServiceFactory = ({
|
||||
|
||||
@@ -179,6 +179,12 @@ import {
|
||||
TRailwayConnectionInput,
|
||||
TValidateRailwayConnectionCredentialsSchema
|
||||
} from "./railway";
|
||||
import {
|
||||
TRedisConnection,
|
||||
TRedisConnectionConfig,
|
||||
TRedisConnectionInput,
|
||||
TValidateRedisConnectionCredentialsSchema
|
||||
} from "./redis";
|
||||
import {
|
||||
TRenderConnection,
|
||||
TRenderConnectionConfig,
|
||||
@@ -261,6 +267,7 @@ export type TAppConnection = { id: string } & (
|
||||
| TDigitalOceanConnection
|
||||
| TNetlifyConnection
|
||||
| TOktaConnection
|
||||
| TRedisConnection
|
||||
);
|
||||
|
||||
export type TAppConnectionRaw = NonNullable<Awaited<ReturnType<TAppConnectionDALFactory["findById"]>>>;
|
||||
@@ -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;
|
||||
|
||||
@@ -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<TAppConnectionDALFactory, "updateById">,
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
|
||||
): Promise<string> => {
|
||||
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<HerokuOAuthTokenResponse> => {
|
||||
const { CLIENT_SECRET_HEROKU } = getConfig();
|
||||
const { INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_SECRET } = getConfig();
|
||||
|
||||
try {
|
||||
const response = await request.post<HerokuOAuthTokenResponse>(
|
||||
@@ -83,7 +83,7 @@ export const exchangeHerokuOAuthCode = async (code: string): Promise<HerokuOAuth
|
||||
{
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
client_secret: CLIENT_SECRET_HEROKU
|
||||
client_secret: INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_SECRET
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
|
||||
4
backend/src/services/app-connection/redis/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./redis-connection-enums";
|
||||
export * from "./redis-connection-fns";
|
||||
export * from "./redis-connection-schemas";
|
||||
export * from "./redis-connection-types";
|
||||
@@ -0,0 +1,3 @@
|
||||
export enum RedisConnectionMethod {
|
||||
UsernameAndPassword = "username-and-password"
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import Redis from "ioredis";
|
||||
|
||||
import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic-secret-fns";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||
|
||||
import { RedisConnectionMethod } from "./redis-connection-enums";
|
||||
import { TRedisConnectionConfig } from "./redis-connection-types";
|
||||
|
||||
export const getRedisConnectionListItem = () => {
|
||||
return {
|
||||
name: "Redis" as const,
|
||||
app: AppConnection.Redis as const,
|
||||
methods: Object.values(RedisConnectionMethod) as [RedisConnectionMethod.UsernameAndPassword],
|
||||
supportsPlatformManagement: false as const
|
||||
};
|
||||
};
|
||||
|
||||
export const validateRedisConnectionCredentials = async (config: TRedisConnectionConfig) => {
|
||||
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();
|
||||
}
|
||||
};
|
||||
@@ -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)
|
||||
});
|
||||
@@ -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<typeof RedisConnectionSchema>;
|
||||
|
||||
export type TRedisConnectionInput = z.infer<typeof CreateRedisConnectionSchema> & {
|
||||
app: AppConnection.Redis;
|
||||
};
|
||||
|
||||
export type TValidateRedisConnectionCredentialsSchema = typeof ValidateRedisConnectionCredentialsSchema;
|
||||
|
||||
export type TRedisConnectionConfig = DiscriminativePick<TRedisConnectionInput, "method" | "app" | "credentials"> & {
|
||||
orgId: string;
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -24,6 +24,7 @@ export type TVerifyMfaTokenDTO = {
|
||||
ip: string;
|
||||
userAgent: string;
|
||||
orgId?: string;
|
||||
isRecoveryCode?: boolean;
|
||||
};
|
||||
|
||||
export type TOauthLoginDTO = {
|
||||
|
||||
@@ -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<TOrgMembershipDALFactory, "find">;
|
||||
tokenService: TAuthTokenServiceFactory;
|
||||
smtpService: TSmtpService;
|
||||
totpConfigDAL: Pick<TTotpConfigDALFactory, "delete">;
|
||||
@@ -31,6 +33,7 @@ export type TAuthPasswordFactory = ReturnType<typeof authPaswordServiceFactory>;
|
||||
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` : ""
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -782,7 +782,7 @@ export const expandSecretReferencesFactory = ({
|
||||
};
|
||||
|
||||
export const reshapeBridgeSecret = (
|
||||
workspaceId: string,
|
||||
projectId: string,
|
||||
environment: string,
|
||||
secretPath: string,
|
||||
secret: Omit<TSecretsV2, "encryptedValue" | "encryptedComment"> & {
|
||||
@@ -809,7 +809,8 @@ export const reshapeBridgeSecret = (
|
||||
) => ({
|
||||
secretKey: secret.key,
|
||||
secretPath,
|
||||
workspace: workspaceId,
|
||||
workspace: projectId,
|
||||
projectId,
|
||||
environment,
|
||||
secretComment: secret.comment || "",
|
||||
version: secret.version,
|
||||
|
||||
@@ -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<BaseEmailWrapperProps, "title" | "preview" | "children"> {
|
||||
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 (
|
||||
<BaseEmailWrapper
|
||||
title="Password Reset Not Available"
|
||||
preview="Your account doesn't have password login enabled."
|
||||
siteUrl={siteUrl}
|
||||
>
|
||||
<Heading className="text-black text-[18px] leading-[28px] text-center font-normal p-0 mx-0">
|
||||
<strong>Password Reset Not Available</strong>
|
||||
</Heading>
|
||||
<Section className="px-[24px] mb-[28px] mt-[36px] pt-[12px] pb-[8px] border border-solid border-gray-200 rounded-md bg-gray-50">
|
||||
<Text className="text-[14px]">
|
||||
<strong>Password reset is not available for this account.</strong>
|
||||
</Text>
|
||||
<Text className="text-[14px]">
|
||||
A password reset was requested for your Infisical account ({email}), but password login has not been enabled
|
||||
for your account.
|
||||
</Text>
|
||||
<Text className="text-[14px]">{getAuthMethodMessage()}</Text>
|
||||
<Text className="text-[14px]">
|
||||
If you did not initiate this request, please contact{" "}
|
||||
{isCloud ? (
|
||||
<>
|
||||
us immediately at <BaseLink href="mailto:support@infisical.com">support@infisical.com</BaseLink>
|
||||
</>
|
||||
) : (
|
||||
"your administrator immediately"
|
||||
)}
|
||||
.
|
||||
</Text>
|
||||
</Section>
|
||||
</BaseEmailWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default OAuthPasswordResetTemplate;
|
||||
|
||||
OAuthPasswordResetTemplate.PreviewProps = {
|
||||
email: "user@example.com",
|
||||
lastLoginMethod: "github",
|
||||
isCloud: true,
|
||||
siteUrl: "https://infisical.com"
|
||||
} as OAuthPasswordResetTemplateProps;
|
||||
@@ -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";
|
||||
|
||||
@@ -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, React.FC<any>> = {
|
||||
[SmtpTemplates.OrgAdminProjectDirectAccess]: OrgAdminProjectGrantAccessTemplate,
|
||||
[SmtpTemplates.ProjectAccessRequest]: ProjectAccessRequestTemplate,
|
||||
[SmtpTemplates.SecretApprovalRequestNeedsReview]: SecretApprovalRequestNeedsReviewTemplate,
|
||||
[SmtpTemplates.OAuthPasswordReset]: OAuthPasswordResetTemplate,
|
||||
[SmtpTemplates.ResetPassword]: PasswordResetTemplate,
|
||||
[SmtpTemplates.SetupPassword]: PasswordSetupTemplate,
|
||||
[SmtpTemplates.PkiExpirationAlert]: PkiExpirationAlertTemplate,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
242
backend/src/services/upgrade-path/github-client.ts
Normal file
@@ -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<string, string> => {
|
||||
const headers: Record<string, string> = {
|
||||
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<void> => {
|
||||
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 <T>(
|
||||
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<T>(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<T>(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<T>(url, config, retryCount + 1);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchReleases = async (includePrerelease = false): Promise<FormattedRelease[]> => {
|
||||
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<GitHubRelease[]>(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);
|
||||
};
|
||||
2
backend/src/services/upgrade-path/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { TUpgradePathService, TUpgradePathServiceFactory } from "./upgrade-path-service";
|
||||
export { upgradePathServiceFactory } from "./upgrade-path-service";
|
||||
66
backend/src/services/upgrade-path/types.ts
Normal file
@@ -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<string, VersionConfig>;
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
}
|
||||
|
||||
export interface GitHubApiError extends Error {
|
||||
status?: number;
|
||||
headers?: Headers;
|
||||
}
|
||||
|
||||
export interface CacheEntry<T> {
|
||||
data: T;
|
||||
timestamp: number;
|
||||
ttl: number;
|
||||
}
|
||||
24
backend/src/services/upgrade-path/upgrade-path-schemas.ts
Normal file
@@ -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()
|
||||
});
|
||||
259
backend/src/services/upgrade-path/upgrade-path-service.ts
Normal file
@@ -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<typeof upgradePathServiceFactory>;
|
||||
|
||||
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<FormattedRelease[]> => {
|
||||
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<Record<string, z.infer<typeof versionConfigSchema>>> => {
|
||||
const cacheKey = "upgrade-path:config";
|
||||
|
||||
try {
|
||||
const cached = await keyStore.getItem(cacheKey);
|
||||
if (cached) return JSON.parse(cached) as Record<string, VersionConfig>;
|
||||
} 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<UpgradePathResult> => {
|
||||
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 })
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
26
backend/upgrade-path.yaml
Normal file
@@ -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:
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Available"
|
||||
openapi: "GET /api/v1/app-connections/redis/available"
|
||||
---
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: "Create"
|
||||
openapi: "POST /api/v1/app-connections/redis"
|
||||
---
|
||||
|
||||
<Note>
|
||||
Check out the configuration docs for [Redis Connections](/integrations/app-connections/redis) to learn how to obtain
|
||||
the required credentials.
|
||||
</Note>
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Delete"
|
||||
openapi: "DELETE /api/v1/app-connections/redis/{connectionId}"
|
||||
---
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Get by ID"
|
||||
openapi: "GET /api/v1/app-connections/redis/{connectionId}"
|
||||
---
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Get by Name"
|
||||
openapi: "GET /api/v1/app-connections/redis/connection-name/{connectionName}"
|
||||
---
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "List"
|
||||
openapi: "GET /api/v1/app-connections/redis"
|
||||
---
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
title: "Update"
|
||||
openapi: "PATCH /api/v1/app-connections/redis/{connectionId}"
|
||||
---
|
||||
|
||||
<Note>
|
||||
Check out the configuration docs for [Redis Connections](/integrations/app-connections/redis) to learn how to obtain
|
||||
the required credentials.
|
||||
</Note>
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
title: "Create"
|
||||
openapi: "POST /api/v2/secret-rotations/redis-credentials"
|
||||
---
|
||||
|
||||
<Note>
|
||||
Check out the configuration docs for [Redis
|
||||
Credentials Rotations](/documentation/platform/secret-rotation/redis-credentials) to learn how to obtain the
|
||||
required parameters.
|
||||
</Note>
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Delete"
|
||||
openapi: "DELETE /api/v2/secret-rotations/redis-credentials/{rotationId}"
|
||||
---
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Get by ID"
|
||||
openapi: "GET /api/v2/secret-rotations/redis-credentials/{rotationId}"
|
||||
---
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Get by Name"
|
||||
openapi: "GET /api/v2/secret-rotations/redis-credentials/rotation-name/{rotationName}"
|
||||
---
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Get Credentials by ID"
|
||||
openapi: "GET /api/v2/secret-rotations/redis-credentials/{rotationId}/generated-credentials"
|
||||
---
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "List"
|
||||
openapi: "GET /api/v2/secret-rotations/redis-credentials"
|
||||
---
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Rotate Secrets"
|
||||
openapi: "POST /api/v2/secret-rotations/redis-credentials/{rotationId}/rotate-secrets"
|
||||
---
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
title: "Update"
|
||||
openapi: "PATCH /api/v2/secret-rotations/redis-credentials/{rotationId}"
|
||||
---
|
||||
|
||||
<Note>
|
||||
Check out the configuration docs for [Redis
|
||||
Credentials Rotations](/documentation/platform/secret-rotation/redis-credentials) to learn how to obtain the
|
||||
required parameters.
|
||||
</Note>
|
||||
@@ -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": [
|
||||
|
||||
@@ -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
|
||||
|
||||
<Steps>
|
||||
<Step title="Open Secret Overview Dashboard">
|
||||
Open the Secret Overview dashboard and select the environment in which you would like to add a dynamic secret.
|
||||
</Step>
|
||||
<Step title="Click on the 'Add Dynamic Secret' button">
|
||||

|
||||
</Step>
|
||||
<Step title="Select `Azure SQL Database`">
|
||||

|
||||
</Step>
|
||||
<Step title="Provide the inputs for dynamic secret parameters">
|
||||
<ParamField path="Secret Name" type="string" required>
|
||||
Name by which you want the secret to be referenced
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="Default TTL" type="string" required>
|
||||
Default time-to-live for a generated secret (it is possible to modify this value after a secret is generated)
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="Max TTL" type="string" required>
|
||||
Maximum time-to-live for a generated secret
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="Metadata" type="list" required>
|
||||
List of key/value metadata pairs
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="Host" type="string" required>
|
||||
Azure SQL Database server hostname (e.g., myserver.database.windows.net)
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="Port" type="number" required>
|
||||
Database port (typically 1433 for Azure SQL Database)
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="User" type="string" required>
|
||||
Username that will be used to create dynamic secrets (must have loginmanager role in master and db_owner in target database)
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="Password" type="string" required>
|
||||
Password that will be used to create dynamic secrets
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="Database" type="string" required>
|
||||
Name of the target database where users will be created and granted permissions
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="Encrypt Connection (SSL)" type="boolean">
|
||||
Enable SSL encryption for the database connection (recommended for Azure SQL Database)
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="CA(SSL)" type="string">
|
||||
SSL certificate authority certificate. For Azure SQL Database, this is typically not required as Azure manages the certificates.
|
||||
</ParamField>
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
<Step title="Configure SQL Statements">
|
||||

|
||||
|
||||
Azure SQL Database dynamic secrets use predefined SQL statements that follow Azure's security best practices:
|
||||
|
||||
<ParamField path="Master Creation Statement" type="string" default="CREATE LOGIN [{{username}}] WITH PASSWORD = '{{password}}';'">
|
||||
SQL statement executed in the master database to create a server-level login. This login allows authentication to the Azure SQL Database server.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="Creation Statement" type="string" default="CREATE USER [{{username}}] FOR LOGIN [{{username}}];\nGRANT SELECT, INSERT, UPDATE, DELETE ON SCHEMA::dbo TO [{{username}}];">
|
||||
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.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="Revocation Statement" type="string">
|
||||
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.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="Username Template" type="string" default="{{randomUsername}}">
|
||||
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
|
||||
```
|
||||
</ParamField>
|
||||
|
||||
</Step>
|
||||
<Step title="Click 'Submit'">
|
||||
After submitting the form, you will see a dynamic secret created in the dashboard.
|
||||
|
||||
<Note>
|
||||
If this step fails, ensure your user has the proper permissions in both the master database (`loginmanager` role) and target database (`db_owner` role).
|
||||
</Note>
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
<Step title="Generate dynamic secrets">
|
||||
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.
|
||||
|
||||

|
||||

|
||||
|
||||
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.
|
||||
|
||||

|
||||
|
||||
<Tip>
|
||||
Ensure that the TTL for the lease falls within the maximum TTL defined when configuring the dynamic secret.
|
||||
</Tip>
|
||||
|
||||
Once you click the `Submit` button, a new secret lease will be generated and the credentials for it will be shown to you.
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||

|
||||
|
||||
<Warning>
|
||||
Lease renewals cannot exceed the maximum TTL set when configuring the dynamic secret
|
||||
</Warning>
|
||||
@@ -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
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Infisical UI">
|
||||
1. Navigate to your Secret Manager Project's Dashboard and select **Add Secret Rotation** from the actions dropdown.
|
||||

|
||||
|
||||
2. Select the **Redis Credentials** option.
|
||||

|
||||
|
||||
3. Select the **Redis Connection** to use and configure the rotation behavior. Then click **Next**.
|
||||

|
||||
|
||||
- **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**.
|
||||

|
||||
|
||||
- **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**.
|
||||

|
||||
|
||||
- **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**.
|
||||

|
||||
|
||||
- **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**.
|
||||

|
||||
|
||||
8. Your **Redis Credentials** are now available for use via the mapped secrets.
|
||||

|
||||
</Tab>
|
||||
<Tab title="API">
|
||||
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": "<string>",
|
||||
"description": "<string>",
|
||||
"connectionId": "<redis-connection-id>",
|
||||
"environment": "dev|staging|prod",
|
||||
"secretPath": "<string>",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
BIN
docs/images/app-connections/redis/redis-app-connection-form.png
Normal file
|
After Width: | Height: | Size: 440 KiB |
|
After Width: | Height: | Size: 671 KiB |
|
After Width: | Height: | Size: 514 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 459 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 425 KiB |
|
After Width: | Height: | Size: 520 KiB |
|
After Width: | Height: | Size: 537 KiB |
|
After Width: | Height: | Size: 573 KiB |
|
After Width: | Height: | Size: 510 KiB |
|
After Width: | Height: | Size: 534 KiB |
|
After Width: | Height: | Size: 747 KiB |
|
After Width: | Height: | Size: 512 KiB |
|
After Width: | Height: | Size: 504 KiB |
|
After Width: | Height: | Size: 521 KiB |
|
After Width: | Height: | Size: 543 KiB |
BIN
docs/images/self-hosting/helper/upgrade-path-tool.png
Normal file
|
After Width: | Height: | Size: 206 KiB |
@@ -24,7 +24,7 @@ Infisical supports two methods for connecting to Heroku: **OAuth** and **Auth To
|
||||

|
||||

|
||||
|
||||
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`.
|
||||
|
||||
<Tip>
|
||||
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.
|
||||
</Step>
|
||||
|
||||
126
docs/integrations/app-connections/redis.mdx
Normal file
@@ -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
|
||||
|
||||
<Steps>
|
||||
<Step title="Create a Redis user">
|
||||
Infisical recommends creating a designated user in your Redis database for your connection.
|
||||
|
||||
```bash
|
||||
ACL SETUSER user_manager on >[ENTER-YOUR-USER-PASSWORD]
|
||||
```
|
||||
</Step>
|
||||
|
||||
<Step title="Grant Relevant Permissions">
|
||||
Depending on how you intend to use your Redis connection, you'll need to grant one or more of the following permissions.
|
||||
|
||||
<Tip>
|
||||
To learn more about Redis's permission system, please visit their [documentation](https://redis.io/docs/latest/operate/oss_and_stack/management/security/acl/).
|
||||
</Tip>
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Secret Rotation">
|
||||
For Secret Rotations, your Infisical user will require the ability to set and delete users:
|
||||
|
||||
```bash
|
||||
ACL SETUSER user_manager +acl|setuser +acl|deluser ~*
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
|
||||
## Create Redis Connection in Infisical
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Infisical UI">
|
||||
<Steps>
|
||||
<Step title="Navigate to App Connections">
|
||||
In your Infisical dashboard, navigate to the **App Connections** page in the desired project.
|
||||
|
||||

|
||||
</Step>
|
||||
<Step title="Select Redis Connection">
|
||||
Click the **+ Add Connection** button and select the **Redis Connection** option from the available integrations.
|
||||
|
||||

|
||||
</Step>
|
||||
<Step title="Fill out the Redis Connection Modal">
|
||||
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.
|
||||
|
||||
|
||||

|
||||
</Step>
|
||||
<Step title="Connection Created">
|
||||
After clicking Create, your **Redis Connection** is established and ready to use with your Infisical project.
|
||||
|
||||

|
||||
</Step>
|
||||
</Steps>
|
||||
</Tab>
|
||||
<Tab title="API">
|
||||
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": "<redis-host>",
|
||||
"port": 6379,
|
||||
"username": "<redis-username>",
|
||||
"sslEnabled": true,
|
||||
"sslRejectUnauthorized": false,
|
||||
"sslCertificate": "<redis-ssl-certificate>"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||