diff --git a/backend/package-lock.json b/backend/package-lock.json index 7534ac1bd..f22fd0b7b 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -63,6 +63,7 @@ "argon2": "^0.31.2", "aws-sdk": "^2.1553.0", "axios": "^1.11.0", + "axios-ntlm": "^1.4.4", "axios-retry": "^4.0.0", "bcrypt": "^5.1.1", "botbuilder": "^4.23.2", @@ -12956,216 +12957,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@swc/core": { - "version": "1.3.107", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.3.107.tgz", - "integrity": "sha512-zKhqDyFcTsyLIYK1iEmavljZnf4CCor5pF52UzLAz4B6Nu/4GLU+2LQVAf+oRHjusG39PTPjd2AlRT3f3QWfsQ==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "peer": true, - "dependencies": { - "@swc/counter": "^0.1.1", - "@swc/types": "^0.1.5" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/swc" - }, - "optionalDependencies": { - "@swc/core-darwin-arm64": "1.3.107", - "@swc/core-darwin-x64": "1.3.107", - "@swc/core-linux-arm-gnueabihf": "1.3.107", - "@swc/core-linux-arm64-gnu": "1.3.107", - "@swc/core-linux-arm64-musl": "1.3.107", - "@swc/core-linux-x64-gnu": "1.3.107", - "@swc/core-linux-x64-musl": "1.3.107", - "@swc/core-win32-arm64-msvc": "1.3.107", - "@swc/core-win32-ia32-msvc": "1.3.107", - "@swc/core-win32-x64-msvc": "1.3.107" - }, - "peerDependencies": { - "@swc/helpers": "^0.5.0" - }, - "peerDependenciesMeta": { - "@swc/helpers": { - "optional": true - } - } - }, - "node_modules/@swc/core-darwin-arm64": { - "version": "1.3.107", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.3.107.tgz", - "integrity": "sha512-47tD/5vSXWxPd0j/ZllyQUg4bqalbQTsmqSw0J4dDdS82MWqCAwUErUrAZPRjBkjNQ6Kmrf5rpCWaGTtPw+ngw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-darwin-x64": { - "version": "1.3.107", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.3.107.tgz", - "integrity": "sha512-hwiLJ2ulNkBGAh1m1eTfeY1417OAYbRGcb/iGsJ+LuVLvKAhU/itzsl535CvcwAlt2LayeCFfcI8gdeOLeZa9A==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm-gnueabihf": { - "version": "1.3.107", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.3.107.tgz", - "integrity": "sha512-I2wzcC0KXqh0OwymCmYwNRgZ9nxX7DWnOOStJXV3pS0uB83TXAkmqd7wvMBuIl9qu4Hfomi9aDM7IlEEn9tumQ==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm64-gnu": { - "version": "1.3.107", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.3.107.tgz", - "integrity": "sha512-HWgnn7JORYlOYnGsdunpSF8A+BCZKPLzLtEUA27/M/ZuANcMZabKL9Zurt7XQXq888uJFAt98Gy+59PU90aHKg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-arm64-musl": { - "version": "1.3.107", - "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.3.107.tgz", - "integrity": "sha512-vfPF74cWfAm8hyhS8yvYI94ucMHIo8xIYU+oFOW9uvDlGQRgnUf/6DEVbLyt/3yfX5723Ln57U8uiMALbX5Pyw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.3.107", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.3.107.tgz", - "integrity": "sha512-uBVNhIg0ip8rH9OnOsCARUFZ3Mq3tbPHxtmWk9uAa5u8jQwGWeBx5+nTHpDOVd3YxKb6+5xDEI/edeeLpha/9g==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-x64-musl": { - "version": "1.3.107", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.3.107.tgz", - "integrity": "sha512-mvACkUvzSIB12q1H5JtabWATbk3AG+pQgXEN95AmEX2ZA5gbP9+B+mijsg7Sd/3tboHr7ZHLz/q3SHTvdFJrEw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-arm64-msvc": { - "version": "1.3.107", - "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.3.107.tgz", - "integrity": "sha512-J3P14Ngy/1qtapzbguEH41kY109t6DFxfbK4Ntz9dOWNuVY3o9/RTB841ctnJk0ZHEG+BjfCJjsD2n8H5HcaOA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-ia32-msvc": { - "version": "1.3.107", - "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.3.107.tgz", - "integrity": "sha512-ZBUtgyjTHlz8TPJh7kfwwwFma+ktr6OccB1oXC8fMSopD0AxVnQasgun3l3099wIsAB9eEsJDQ/3lDkOLs1gBA==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-win32-x64-msvc": { - "version": "1.3.107", - "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.3.107.tgz", - "integrity": "sha512-Eyzo2XRqWOxqhE1gk9h7LWmUf4Bp4Xn2Ttb0ayAXFp6YSTxQIThXcT9kipXZqcpxcmDwoq8iWbbf2P8XL743EA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": ">=10" - } - }, "node_modules/@swc/counter": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", @@ -13183,14 +12974,6 @@ "tslib": "^2.8.0" } }, - "node_modules/@swc/types": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.5.tgz", - "integrity": "sha512-myfUej5naTBWnqOCc/MdVOLVjXUXtIA+NpDrDBKJtLLg2shUjBu3cZmB/85RyitKc55+lUUyl7oRfLOvkr2hsw==", - "dev": true, - "optional": true, - "peer": true - }, "node_modules/@techteamer/ocsp": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@techteamer/ocsp/-/ocsp-1.0.1.tgz", @@ -15195,6 +14978,18 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/axios-ntlm": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/axios-ntlm/-/axios-ntlm-1.4.4.tgz", + "integrity": "sha512-kpCRdzMfL8gi0Z0o96P3QPAK4XuC8iciGgxGXe+PeQ4oyjI2LZN8WSOKbu0Y9Jo3T/A7pB81n6jYVPIpglEuRA==", + "license": "MIT", + "dependencies": { + "axios": "^1.8.4", + "des.js": "^1.1.0", + "dev-null": "^0.1.1", + "js-md4": "^0.3.2" + } + }, "node_modules/axios-retry": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-4.0.0.tgz", @@ -16954,6 +16749,16 @@ "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", "integrity": "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==" }, + "node_modules/des.js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.1.0.tgz", + "integrity": "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -16981,6 +16786,12 @@ "node": ">=8" } }, + "node_modules/dev-null": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/dev-null/-/dev-null-0.1.1.tgz", + "integrity": "sha512-nMNZG0zfMgmdv8S5O0TM5cpwNbGKRGPCxVsr0SmA3NZZy9CYBbuNLL0PD3Acx9e5LIUgwONXtM9kM6RlawPxEQ==", + "license": "MIT" + }, "node_modules/diff": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", @@ -19029,49 +18840,6 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, - "node_modules/gcp-metadata": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz", - "integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==", - "optional": true, - "peer": true, - "dependencies": { - "gaxios": "^5.0.0", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/gcp-metadata/node_modules/gaxios": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-5.1.3.tgz", - "integrity": "sha512-95hVgBRgEIRQQQHIbnxBXeHbW4TqFk4ZDJW7wmVtvYar72FdhRIo1UGOLS2eRAKCPEdPBWu+M7+A33D9CdX9rA==", - "optional": true, - "peer": true, - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^5.0.0", - "is-stream": "^2.0.0", - "node-fetch": "^2.6.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/gcp-metadata/node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "optional": true, - "peer": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/generate-function": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", diff --git a/backend/package.json b/backend/package.json index f84db13fc..aaef9f567 100644 --- a/backend/package.json +++ b/backend/package.json @@ -37,7 +37,7 @@ "build": "tsup --sourcemap", "build:frontend": "npm run build --prefix ../frontend", "start": "node --enable-source-maps dist/main.mjs", - "type:check": "tsc --noEmit", + "type:check": "node --max-old-space-size=8192 ./node_modules/.bin/tsc --noEmit", "lint:fix": "node --max-old-space-size=8192 ./node_modules/.bin/eslint --fix --ext js,ts ./src", "lint": "node --max-old-space-size=8192 ./node_modules/.bin/eslint 'src/**/*.ts'", "test:unit": "vitest run -c vitest.unit.config.ts", @@ -183,6 +183,7 @@ "argon2": "^0.31.2", "aws-sdk": "^2.1553.0", "axios": "^1.11.0", + "axios-ntlm": "^1.4.4", "axios-retry": "^4.0.0", "bcrypt": "^5.1.1", "botbuilder": "^4.23.2", diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index adf9489d4..c25d8d4d1 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -148,6 +148,7 @@ declare module "fastify" { interface Session { callbackPort: string; isAdminLogin: boolean; + orgSlug?: string; } interface FastifyRequest { diff --git a/backend/src/db/manual-migrations/partition-audit-logs.ts b/backend/src/db/manual-migrations/partition-audit-logs.ts index fbead3a24..e08f811ae 100644 --- a/backend/src/db/manual-migrations/partition-audit-logs.ts +++ b/backend/src/db/manual-migrations/partition-audit-logs.ts @@ -84,6 +84,9 @@ const up = async (knex: Knex): Promise => { t.index("expiresAt"); t.index("orgId"); t.index("projectId"); + t.index("eventType"); + t.index("userAgentType"); + t.index("actor"); }); console.log("Adding GIN indices..."); @@ -119,8 +122,8 @@ const up = async (knex: Knex): Promise => { console.log("Creating audit log partitions ahead of time... next date:", nextDateStr); await createAuditLogPartition(knex, nextDate, new Date(nextDate.getFullYear(), nextDate.getMonth() + 1)); - // create partitions 4 years ahead - const partitionMonths = 4 * 12; + // create partitions 20 years ahead + const partitionMonths = 20 * 12; const partitionPromises: Promise[] = []; for (let x = 1; x <= partitionMonths; x += 1) { partitionPromises.push( diff --git a/backend/src/db/migrations/20250808174003_add-user-alias-is-email-verified.ts b/backend/src/db/migrations/20250808174003_add-user-alias-is-email-verified.ts new file mode 100644 index 000000000..03a45b1fd --- /dev/null +++ b/backend/src/db/migrations/20250808174003_add-user-alias-is-email-verified.ts @@ -0,0 +1,49 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +const BATCH_SIZE = 1000; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.UserAliases, "isEmailVerified"))) { + // Add the column + await knex.schema.alterTable(TableName.UserAliases, (t) => { + t.boolean("isEmailVerified").defaultTo(false); + }); + + const aliasesToUpdate: { aliasId: string; isEmailVerified: boolean }[] = await knex(TableName.UserAliases) + .join(TableName.Users, `${TableName.UserAliases}.userId`, `${TableName.Users}.id`) + .select([`${TableName.UserAliases}.id as aliasId`, `${TableName.Users}.isEmailVerified`]); + + for (let i = 0; i < aliasesToUpdate.length; i += BATCH_SIZE) { + const batch = aliasesToUpdate.slice(i, i + BATCH_SIZE); + + const trueIds = batch.filter((row) => row.isEmailVerified).map((row) => row.aliasId); + + if (trueIds.length > 0) { + // eslint-disable-next-line no-await-in-loop + await knex(TableName.UserAliases).whereIn("id", trueIds).update({ isEmailVerified: true }); + } + } + } + + if (!(await knex.schema.hasColumn(TableName.AuthTokens, "aliasId"))) { + await knex.schema.alterTable(TableName.AuthTokens, (t) => { + t.string("aliasId").nullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.UserAliases, "isEmailVerified")) { + await knex.schema.alterTable(TableName.UserAliases, (t) => { + t.dropColumn("isEmailVerified"); + }); + } + + if (await knex.schema.hasColumn(TableName.AuthTokens, "aliasId")) { + await knex.schema.alterTable(TableName.AuthTokens, (t) => { + t.dropColumn("aliasId"); + }); + } +} diff --git a/backend/src/db/migrations/20250813214709_enforce-google-sso.ts b/backend/src/db/migrations/20250813214709_enforce-google-sso.ts new file mode 100644 index 000000000..2346c91a4 --- /dev/null +++ b/backend/src/db/migrations/20250813214709_enforce-google-sso.ts @@ -0,0 +1,39 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +const GOOGLE_SSO_AUTH_ENFORCED_COLUMN_NAME = "googleSsoAuthEnforced"; +const GOOGLE_SSO_AUTH_LAST_USED_COLUMN_NAME = "googleSsoAuthLastUsed"; +export async function up(knex: Knex): Promise { + const hasGoogleSsoAuthEnforcedColumn = await knex.schema.hasColumn( + TableName.Organization, + GOOGLE_SSO_AUTH_ENFORCED_COLUMN_NAME + ); + const hasGoogleSsoAuthLastUsedColumn = await knex.schema.hasColumn( + TableName.Organization, + GOOGLE_SSO_AUTH_LAST_USED_COLUMN_NAME + ); + + await knex.schema.alterTable(TableName.Organization, (table) => { + if (!hasGoogleSsoAuthEnforcedColumn) + table.boolean(GOOGLE_SSO_AUTH_ENFORCED_COLUMN_NAME).defaultTo(false).notNullable(); + if (!hasGoogleSsoAuthLastUsedColumn) table.timestamp(GOOGLE_SSO_AUTH_LAST_USED_COLUMN_NAME).nullable(); + }); +} + +export async function down(knex: Knex): Promise { + const hasGoogleSsoAuthEnforcedColumn = await knex.schema.hasColumn( + TableName.Organization, + GOOGLE_SSO_AUTH_ENFORCED_COLUMN_NAME + ); + + const hasGoogleSsoAuthLastUsedColumn = await knex.schema.hasColumn( + TableName.Organization, + GOOGLE_SSO_AUTH_LAST_USED_COLUMN_NAME + ); + + await knex.schema.alterTable(TableName.Organization, (table) => { + if (hasGoogleSsoAuthEnforcedColumn) table.dropColumn(GOOGLE_SSO_AUTH_ENFORCED_COLUMN_NAME); + if (hasGoogleSsoAuthLastUsedColumn) table.dropColumn(GOOGLE_SSO_AUTH_LAST_USED_COLUMN_NAME); + }); +} diff --git a/backend/src/db/migrations/20250815022242_identity-lockouts.ts b/backend/src/db/migrations/20250815022242_identity-lockouts.ts new file mode 100644 index 000000000..d25ee4f47 --- /dev/null +++ b/backend/src/db/migrations/20250815022242_identity-lockouts.ts @@ -0,0 +1,57 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.IdentityUniversalAuth)) { + const hasLockoutEnabled = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutEnabled"); + const hasLockoutThreshold = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutThreshold"); + const hasLockoutDuration = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutDurationSeconds"); + const hasLockoutCounterReset = await knex.schema.hasColumn( + TableName.IdentityUniversalAuth, + "lockoutCounterResetSeconds" + ); + + await knex.schema.alterTable(TableName.IdentityUniversalAuth, (t) => { + if (!hasLockoutEnabled) { + t.boolean("lockoutEnabled").notNullable().defaultTo(true); + } + if (!hasLockoutThreshold) { + t.integer("lockoutThreshold").notNullable().defaultTo(3); + } + if (!hasLockoutDuration) { + t.integer("lockoutDurationSeconds").notNullable().defaultTo(300); // 5 minutes + } + if (!hasLockoutCounterReset) { + t.integer("lockoutCounterResetSeconds").notNullable().defaultTo(30); // 30 seconds + } + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.IdentityUniversalAuth)) { + const hasLockoutEnabled = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutEnabled"); + const hasLockoutThreshold = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutThreshold"); + const hasLockoutDuration = await knex.schema.hasColumn(TableName.IdentityUniversalAuth, "lockoutDurationSeconds"); + const hasLockoutCounterReset = await knex.schema.hasColumn( + TableName.IdentityUniversalAuth, + "lockoutCounterResetSeconds" + ); + + await knex.schema.alterTable(TableName.IdentityUniversalAuth, (t) => { + if (hasLockoutEnabled) { + t.dropColumn("lockoutEnabled"); + } + if (hasLockoutThreshold) { + t.dropColumn("lockoutThreshold"); + } + if (hasLockoutDuration) { + t.dropColumn("lockoutDurationSeconds"); + } + if (hasLockoutCounterReset) { + t.dropColumn("lockoutCounterResetSeconds"); + } + }); + } +} diff --git a/backend/src/db/migrations/20250824173438_add-approval-secret-read-compat.ts b/backend/src/db/migrations/20250824173438_add-approval-secret-read-compat.ts new file mode 100644 index 000000000..7398920af --- /dev/null +++ b/backend/src/db/migrations/20250824173438_add-approval-secret-read-compat.ts @@ -0,0 +1,19 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.SecretApprovalPolicy, "shouldCheckSecretPermission"))) { + await knex.schema.alterTable(TableName.SecretApprovalPolicy, (t) => { + t.boolean("shouldCheckSecretPermission").nullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.SecretApprovalPolicy, "shouldCheckSecretPermission")) { + await knex.schema.alterTable(TableName.SecretApprovalPolicy, (t) => { + t.dropColumn("shouldCheckSecretPermission"); + }); + } +} diff --git a/backend/src/db/migrations/20250824192801_backfill-secret-read-compat-flag.ts b/backend/src/db/migrations/20250824192801_backfill-secret-read-compat-flag.ts new file mode 100644 index 000000000..7a629ca52 --- /dev/null +++ b/backend/src/db/migrations/20250824192801_backfill-secret-read-compat-flag.ts @@ -0,0 +1,29 @@ +import { Knex } from "knex"; + +import { selectAllTableCols } from "@app/lib/knex"; + +import { TableName } from "../schemas"; + +const BATCH_SIZE = 100; + +export async function up(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.SecretApprovalPolicy, "shouldCheckSecretPermission")) { + // find all existing SecretApprovalPolicy rows to backfill shouldCheckSecretPermission flag + const rows = await knex(TableName.SecretApprovalPolicy).select(selectAllTableCols(TableName.SecretApprovalPolicy)); + + if (rows.length > 0) { + for (let i = 0; i < rows.length; i += BATCH_SIZE) { + const batch = rows.slice(i, i + BATCH_SIZE); + // eslint-disable-next-line no-await-in-loop + await knex(TableName.SecretApprovalPolicy) + .whereIn( + "id", + batch.map((row) => row.id) + ) + .update({ shouldCheckSecretPermission: true }); + } + } + } +} + +export async function down(): Promise {} diff --git a/backend/src/db/migrations/20250826190000_add-properties-to-pki-subscriber.ts b/backend/src/db/migrations/20250826190000_add-properties-to-pki-subscriber.ts new file mode 100644 index 000000000..83a85c94f --- /dev/null +++ b/backend/src/db/migrations/20250826190000_add-properties-to-pki-subscriber.ts @@ -0,0 +1,23 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasPropertiesCol = await knex.schema.hasColumn(TableName.PkiSubscriber, "properties"); + + if (!hasPropertiesCol) { + await knex.schema.alterTable(TableName.PkiSubscriber, (t) => { + t.jsonb("properties").nullable(); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasPropertiesCol = await knex.schema.hasColumn(TableName.PkiSubscriber, "properties"); + + if (hasPropertiesCol) { + await knex.schema.alterTable(TableName.PkiSubscriber, (t) => { + t.dropColumn("properties"); + }); + } +} diff --git a/backend/src/db/schemas/auth-tokens.ts b/backend/src/db/schemas/auth-tokens.ts index dd8563b85..0d3e93219 100644 --- a/backend/src/db/schemas/auth-tokens.ts +++ b/backend/src/db/schemas/auth-tokens.ts @@ -17,7 +17,8 @@ export const AuthTokensSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), userId: z.string().uuid().nullable().optional(), - orgId: z.string().uuid().nullable().optional() + orgId: z.string().uuid().nullable().optional(), + aliasId: z.string().nullable().optional() }); export type TAuthTokens = z.infer; diff --git a/backend/src/db/schemas/identity-universal-auths.ts b/backend/src/db/schemas/identity-universal-auths.ts index da27b4a55..29e8314aa 100644 --- a/backend/src/db/schemas/identity-universal-auths.ts +++ b/backend/src/db/schemas/identity-universal-auths.ts @@ -18,7 +18,11 @@ export const IdentityUniversalAuthsSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), identityId: z.string().uuid(), - accessTokenPeriod: z.coerce.number().default(0) + accessTokenPeriod: z.coerce.number().default(0), + lockoutEnabled: z.boolean().default(true), + lockoutThreshold: z.number().default(3), + lockoutDurationSeconds: z.number().default(300), + lockoutCounterResetSeconds: z.number().default(30) }); export type TIdentityUniversalAuths = z.infer; diff --git a/backend/src/db/schemas/organizations.ts b/backend/src/db/schemas/organizations.ts index fb0728707..afc9e2b73 100644 --- a/backend/src/db/schemas/organizations.ts +++ b/backend/src/db/schemas/organizations.ts @@ -36,7 +36,9 @@ export const OrganizationsSchema = z.object({ scannerProductEnabled: z.boolean().default(true).nullable().optional(), shareSecretsProductEnabled: z.boolean().default(true).nullable().optional(), maxSharedSecretLifetime: z.number().default(2592000).nullable().optional(), - maxSharedSecretViewLimit: z.number().nullable().optional() + maxSharedSecretViewLimit: z.number().nullable().optional(), + googleSsoAuthEnforced: z.boolean().default(false), + googleSsoAuthLastUsed: z.date().nullable().optional() }); export type TOrganizations = z.infer; diff --git a/backend/src/db/schemas/pki-subscribers.ts b/backend/src/db/schemas/pki-subscribers.ts index 0cdff4250..1641ba528 100644 --- a/backend/src/db/schemas/pki-subscribers.ts +++ b/backend/src/db/schemas/pki-subscribers.ts @@ -25,7 +25,8 @@ export const PkiSubscribersSchema = z.object({ lastAutoRenewAt: z.date().nullable().optional(), lastOperationStatus: z.string().nullable().optional(), lastOperationMessage: z.string().nullable().optional(), - lastOperationAt: z.date().nullable().optional() + lastOperationAt: z.date().nullable().optional(), + properties: z.unknown().nullable().optional() }); export type TPkiSubscribers = z.infer; diff --git a/backend/src/db/schemas/secret-approval-policies.ts b/backend/src/db/schemas/secret-approval-policies.ts index 0273e617c..dbb881db3 100644 --- a/backend/src/db/schemas/secret-approval-policies.ts +++ b/backend/src/db/schemas/secret-approval-policies.ts @@ -17,7 +17,8 @@ export const SecretApprovalPoliciesSchema = z.object({ updatedAt: z.date(), enforcementLevel: z.string().default("hard"), deletedAt: z.date().nullable().optional(), - allowedSelfApprovals: z.boolean().default(true) + allowedSelfApprovals: z.boolean().default(true), + shouldCheckSecretPermission: z.boolean().nullable().optional() }); export type TSecretApprovalPolicies = z.infer; diff --git a/backend/src/db/schemas/user-aliases.ts b/backend/src/db/schemas/user-aliases.ts index 14147abf8..428fa62ca 100644 --- a/backend/src/db/schemas/user-aliases.ts +++ b/backend/src/db/schemas/user-aliases.ts @@ -16,7 +16,8 @@ export const UserAliasesSchema = z.object({ emails: z.string().array().nullable().optional(), orgId: z.string().uuid().nullable().optional(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + isEmailVerified: z.boolean().default(false).nullable().optional() }); export type TUserAliases = z.infer; diff --git a/backend/src/ee/routes/v1/access-approval-request-router.ts b/backend/src/ee/routes/v1/access-approval-request-router.ts index 83378c16e..2cfc90564 100644 --- a/backend/src/ee/routes/v1/access-approval-request-router.ts +++ b/backend/src/ee/routes/v1/access-approval-request-router.ts @@ -133,6 +133,7 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv approvals: z.number(), approvers: z .object({ + isOrgMembershipActive: z.boolean().nullable().optional(), userId: z.string().nullable().optional(), sequence: z.number().nullable().optional(), approvalsRequired: z.number().nullable().optional(), @@ -150,6 +151,7 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv }), reviewers: z .object({ + isOrgMembershipActive: z.boolean().nullable().optional(), userId: z.string(), status: z.string() }) diff --git a/backend/src/ee/routes/v1/github-org-sync-router.ts b/backend/src/ee/routes/v1/github-org-sync-router.ts index 3f33a5d8f..0d7279928 100644 --- a/backend/src/ee/routes/v1/github-org-sync-router.ts +++ b/backend/src/ee/routes/v1/github-org-sync-router.ts @@ -126,4 +126,39 @@ export const registerGithubOrgSyncRouter = async (server: FastifyZodProvider) => return { githubOrgSyncConfig }; } }); + + server.route({ + url: "/sync-all-teams", + method: "POST", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT]), + schema: { + response: { + 200: z.object({ + totalUsers: z.number(), + errors: z.array(z.string()), + createdTeams: z.array(z.string()), + updatedTeams: z.array(z.string()), + removedMemberships: z.number(), + syncDuration: z.number() + }) + } + }, + handler: async (req) => { + const result = await server.services.githubOrgSync.syncAllTeams({ + orgPermission: req.permission + }); + + return { + totalUsers: result.totalUsers, + errors: result.errors, + createdTeams: result.createdTeams, + updatedTeams: result.updatedTeams, + removedMemberships: result.removedMemberships, + syncDuration: result.syncDuration + }; + } + }); }; diff --git a/backend/src/ee/routes/v1/secret-approval-request-router.ts b/backend/src/ee/routes/v1/secret-approval-request-router.ts index 3d07af562..bdc9c2dcd 100644 --- a/backend/src/ee/routes/v1/secret-approval-request-router.ts +++ b/backend/src/ee/routes/v1/secret-approval-request-router.ts @@ -294,22 +294,30 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv 200: z.object({ approval: SecretApprovalRequestsSchema.merge( z.object({ - // secretPath: z.string(), policy: z.object({ id: z.string(), name: z.string(), approvals: z.number(), - approvers: approvalRequestUser.array(), + approvers: approvalRequestUser + .extend({ isOrgMembershipActive: z.boolean().nullable().optional() }) + .array(), bypassers: approvalRequestUser.array(), secretPath: z.string().optional().nullable(), enforcementLevel: z.string(), deletedAt: z.date().nullish(), - allowedSelfApprovals: z.boolean() + allowedSelfApprovals: z.boolean(), + shouldCheckSecretPermission: z.boolean().nullable().optional() }), environment: z.string(), statusChangedByUser: approvalRequestUser.optional(), committerUser: approvalRequestUser.nullish(), - reviewers: approvalRequestUser.extend({ status: z.string(), comment: z.string().optional() }).array(), + reviewers: approvalRequestUser + .extend({ + status: z.string(), + comment: z.string().optional(), + isOrgMembershipActive: z.boolean().nullable().optional() + }) + .array(), secretPath: z.string(), commits: secretRawSchema .omit({ _id: true, environment: true, workspace: true, type: true, version: true, secretValue: true }) diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts b/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts index 68f9240a6..f3972fc1c 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts @@ -5,6 +5,7 @@ import { AccessApprovalRequestsSchema, TableName, TAccessApprovalRequests, + TOrgMemberships, TUserGroupMembership, TUsers } from "@app/db/schemas"; @@ -144,6 +145,7 @@ export interface TAccessApprovalRequestDALFactory extends Omit( + db(TableName.OrgMembership).as("approverOrgMembership"), + `${TableName.AccessApprovalPolicyApprover}.approverUserId`, + `approverOrgMembership.userId` + ) + + .leftJoin( + db(TableName.OrgMembership).as("approverGroupOrgMembership"), + `${TableName.Users}.id`, + `approverGroupOrgMembership.userId` + ) + + .leftJoin( + db(TableName.OrgMembership).as("reviewerOrgMembership"), + `${TableName.AccessApprovalRequestReviewer}.reviewerUserId`, + `reviewerOrgMembership.userId` + ) + .leftJoin(TableName.Environment, `${TableName.AccessApprovalPolicy}.envId`, `${TableName.Environment}.id`) .select(selectAllTableCols(TableName.AccessApprovalRequest)) @@ -300,6 +324,10 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR db.ref("allowedSelfApprovals").withSchema(TableName.AccessApprovalPolicy).as("policyAllowedSelfApprovals"), db.ref("envId").withSchema(TableName.AccessApprovalPolicy).as("policyEnvId"), db.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt"), + + db.ref("isActive").withSchema("approverOrgMembership").as("approverIsOrgMembershipActive"), + db.ref("isActive").withSchema("approverGroupOrgMembership").as("approverGroupIsOrgMembershipActive"), + db.ref("isActive").withSchema("reviewerOrgMembership").as("reviewerIsOrgMembershipActive"), db.ref("maxTimePeriod").withSchema(TableName.AccessApprovalPolicy).as("policyMaxTimePeriod") ) .select(db.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover)) @@ -396,17 +424,26 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR { key: "reviewerUserId", label: "reviewers" as const, - mapper: ({ reviewerUserId: userId, reviewerStatus: status }) => (userId ? { userId, status } : undefined) + mapper: ({ reviewerUserId: userId, reviewerStatus: status, reviewerIsOrgMembershipActive }) => + userId ? { userId, status, isOrgMembershipActive: reviewerIsOrgMembershipActive } : undefined }, { key: "approverUserId", label: "approvers" as const, - mapper: ({ approverUserId, approverSequence, approvalsRequired, approverUsername, approverEmail }) => ({ + mapper: ({ + approverUserId, + approverSequence, + approvalsRequired, + approverUsername, + approverEmail, + approverIsOrgMembershipActive + }) => ({ userId: approverUserId, sequence: approverSequence, approvalsRequired, email: approverEmail, - username: approverUsername + username: approverUsername, + isOrgMembershipActive: approverIsOrgMembershipActive }) }, { @@ -417,13 +454,15 @@ export const accessApprovalRequestDALFactory = (db: TDbClient): TAccessApprovalR approverSequence, approvalsRequired, approverGroupEmail, - approverGroupUsername + approverGroupUsername, + approverGroupIsOrgMembershipActive }) => ({ userId: approverGroupUserId, sequence: approverSequence, approvalsRequired, email: approverGroupEmail, - username: approverGroupUsername + username: approverGroupUsername, + isOrgMembershipActive: approverGroupIsOrgMembershipActive }) }, { key: "bypasserUserId", label: "bypassers" as const, mapper: ({ bypasserUserId }) => bypasserUserId }, diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-types.ts b/backend/src/ee/services/access-approval-request/access-approval-request-types.ts index ed027835a..f1d3102d6 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-types.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-types.ts @@ -87,6 +87,7 @@ export interface TAccessApprovalRequestServiceFactory { approvalsRequired: number | null | undefined; email: string | null | undefined; username: string; + isOrgMembershipActive: boolean; } | { userId: string; @@ -94,6 +95,7 @@ export interface TAccessApprovalRequestServiceFactory { approvalsRequired: number | null | undefined; email: string | null | undefined; username: string; + isOrgMembershipActive: boolean; } )[]; bypassers: string[]; @@ -145,6 +147,7 @@ export interface TAccessApprovalRequestServiceFactory { reviewers: { userId: string; status: string; + isOrgMembershipActive: boolean; }[]; approvers: ( | { @@ -153,6 +156,7 @@ export interface TAccessApprovalRequestServiceFactory { approvalsRequired: number | null | undefined; email: string | null | undefined; username: string; + isOrgMembershipActive: boolean; } | { userId: string; @@ -160,6 +164,7 @@ export interface TAccessApprovalRequestServiceFactory { approvalsRequired: number | null | undefined; email: string | null | undefined; username: string; + isOrgMembershipActive: boolean; } )[]; bypassers: string[]; diff --git a/backend/src/ee/services/audit-log/audit-log-dal.ts b/backend/src/ee/services/audit-log/audit-log-dal.ts index b58f58164..7a986a2d3 100644 --- a/backend/src/ee/services/audit-log/audit-log-dal.ts +++ b/backend/src/ee/services/audit-log/audit-log-dal.ts @@ -14,7 +14,7 @@ import { ActorType } from "@app/services/auth/auth-type"; import { EventType, filterableSecretEvents } from "./audit-log-types"; export interface TAuditLogDALFactory extends Omit, "find"> { - pruneAuditLog: (tx?: knex.Knex) => Promise; + pruneAuditLog: () => Promise; find: ( arg: Omit & { actorId?: string | undefined; @@ -41,6 +41,10 @@ type TFindQuery = { offset?: number; }; +const QUERY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes +const AUDIT_LOG_PRUNE_BATCH_SIZE = 10000; +const MAX_RETRY_ON_FAILURE = 3; + export const auditLogDALFactory = (db: TDbClient) => { const auditLogOrm = ormify(db, TableName.AuditLog); @@ -151,20 +155,20 @@ export const auditLogDALFactory = (db: TDbClient) => { }; // delete all audit log that have expired - const pruneAuditLog: TAuditLogDALFactory["pruneAuditLog"] = async (tx) => { - const runPrune = async (dbClient: knex.Knex) => { - const AUDIT_LOG_PRUNE_BATCH_SIZE = 10000; - const MAX_RETRY_ON_FAILURE = 3; + const pruneAuditLog: TAuditLogDALFactory["pruneAuditLog"] = async () => { + const today = new Date(); + let deletedAuditLogIds: { id: string }[] = []; + let numberOfRetryOnFailure = 0; + let isRetrying = false; - const today = new Date(); - let deletedAuditLogIds: { id: string }[] = []; - let numberOfRetryOnFailure = 0; - let isRetrying = false; + logger.info(`${QueueName.DailyResourceCleanUp}: audit log started`); + do { + try { + // eslint-disable-next-line no-await-in-loop + deletedAuditLogIds = await db.transaction(async (trx) => { + await trx.raw(`SET statement_timeout = ${QUERY_TIMEOUT_MS}`); - logger.info(`${QueueName.DailyResourceCleanUp}: audit log started`); - do { - try { - const findExpiredLogSubQuery = dbClient(TableName.AuditLog) + const findExpiredLogSubQuery = trx(TableName.AuditLog) .where("expiresAt", "<", today) .where("createdAt", "<", today) // to use audit log partition .orderBy(`${TableName.AuditLog}.createdAt`, "desc") @@ -172,34 +176,25 @@ export const auditLogDALFactory = (db: TDbClient) => { .limit(AUDIT_LOG_PRUNE_BATCH_SIZE); // eslint-disable-next-line no-await-in-loop - deletedAuditLogIds = await dbClient(TableName.AuditLog) - .whereIn("id", findExpiredLogSubQuery) - .del() - .returning("id"); - numberOfRetryOnFailure = 0; // reset - } catch (error) { - numberOfRetryOnFailure += 1; - logger.error(error, "Failed to delete audit log on pruning"); - } finally { - // eslint-disable-next-line no-await-in-loop - await new Promise((resolve) => { - setTimeout(resolve, 10); // time to breathe for db - }); - } - isRetrying = numberOfRetryOnFailure > 0; - } while (deletedAuditLogIds.length > 0 || (isRetrying && numberOfRetryOnFailure < MAX_RETRY_ON_FAILURE)); - logger.info(`${QueueName.DailyResourceCleanUp}: audit log completed`); - }; + const results = await trx(TableName.AuditLog).whereIn("id", findExpiredLogSubQuery).del().returning("id"); - if (tx) { - await runPrune(tx); - } else { - const QUERY_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes - await db.transaction(async (trx) => { - await trx.raw(`SET statement_timeout = ${QUERY_TIMEOUT_MS}`); - await runPrune(trx); - }); - } + return results; + }); + + numberOfRetryOnFailure = 0; // reset + } catch (error) { + numberOfRetryOnFailure += 1; + deletedAuditLogIds = []; + logger.error(error, "Failed to delete audit log on pruning"); + } finally { + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { + setTimeout(resolve, 10); // time to breathe for db + }); + } + isRetrying = numberOfRetryOnFailure > 0; + } while (deletedAuditLogIds.length > 0 || (isRetrying && numberOfRetryOnFailure < MAX_RETRY_ON_FAILURE)); + logger.info(`${QueueName.DailyResourceCleanUp}: audit log completed`); }; const create: TAuditLogDALFactory["create"] = async (tx) => { diff --git a/backend/src/ee/services/audit-log/audit-log-service.ts b/backend/src/ee/services/audit-log/audit-log-service.ts index 06186d54b..ece5edaf9 100644 --- a/backend/src/ee/services/audit-log/audit-log-service.ts +++ b/backend/src/ee/services/audit-log/audit-log-service.ts @@ -6,9 +6,9 @@ import { getConfig } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; import { ActorType } from "@app/services/auth/auth-type"; -import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; +import { OrgPermissionAuditLogsActions, OrgPermissionSubjects } from "../permission/org-permission"; import { TPermissionServiceFactory } from "../permission/permission-service-types"; -import { ProjectPermissionActions, ProjectPermissionSub } from "../permission/project-permission"; +import { ProjectPermissionAuditLogsActions, ProjectPermissionSub } from "../permission/project-permission"; import { TAuditLogDALFactory } from "./audit-log-dal"; import { TAuditLogQueueServiceFactory } from "./audit-log-queue"; import { EventType, TAuditLogServiceFactory } from "./audit-log-types"; @@ -41,7 +41,10 @@ export const auditLogServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.Any }); - ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionAuditLogsActions.Read, + ProjectPermissionSub.AuditLogs + ); } else { // Organization-wide logs const { permission } = await permissionService.getOrgPermission( @@ -52,7 +55,10 @@ export const auditLogServiceFactory = ({ actorOrgId ); - ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.AuditLogs); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionAuditLogsActions.Read, + OrgPermissionSubjects.AuditLogs + ); } // If project ID is not provided, then we need to return all the audit logs for the organization itself. diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 11d045eb2..10c84cc4a 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -198,6 +198,7 @@ export enum EventType { CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "create-identity-universal-auth-client-secret", REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "revoke-identity-universal-auth-client-secret", + CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS = "clear-identity-universal-auth-lockouts", GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS = "get-identity-universal-auth-client-secret", GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET_BY_ID = "get-identity-universal-auth-client-secret-by-id", @@ -281,6 +282,7 @@ export enum EventType { UPDATE_SSH_CERTIFICATE_TEMPLATE = "update-ssh-certificate-template", DELETE_SSH_CERTIFICATE_TEMPLATE = "delete-ssh-certificate-template", GET_SSH_CERTIFICATE_TEMPLATE = "get-ssh-certificate-template", + GET_AZURE_AD_TEMPLATES = "get-azure-ad-templates", GET_SSH_HOST = "get-ssh-host", CREATE_SSH_HOST = "create-ssh-host", UPDATE_SSH_HOST = "update-ssh-host", @@ -866,6 +868,10 @@ interface AddIdentityUniversalAuthEvent { accessTokenMaxTTL: number; accessTokenNumUsesLimit: number; accessTokenTrustedIps: Array; + lockoutEnabled: boolean; + lockoutThreshold: number; + lockoutDurationSeconds: number; + lockoutCounterResetSeconds: number; }; } @@ -878,6 +884,10 @@ interface UpdateIdentityUniversalAuthEvent { accessTokenMaxTTL?: number; accessTokenNumUsesLimit?: number; accessTokenTrustedIps?: Array; + lockoutEnabled?: boolean; + lockoutThreshold?: number; + lockoutDurationSeconds?: number; + lockoutCounterResetSeconds?: number; }; } @@ -1037,6 +1047,13 @@ interface RevokeIdentityUniversalAuthClientSecretEvent { }; } +interface ClearIdentityUniversalAuthLockoutsEvent { + type: EventType.CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS; + metadata: { + identityId: string; + }; +} + interface LoginIdentityGcpAuthEvent { type: EventType.LOGIN_IDENTITY_GCP_AUTH; metadata: { @@ -2497,6 +2514,14 @@ interface CreateCertificateTemplateEstConfig { }; } +interface GetAzureAdCsTemplatesEvent { + type: EventType.GET_AZURE_AD_TEMPLATES; + metadata: { + caId: string; + amount: number; + }; +} + interface UpdateCertificateTemplateEstConfig { type: EventType.UPDATE_CERTIFICATE_TEMPLATE_EST_CONFIG; metadata: { @@ -3491,6 +3516,7 @@ export type Event = | GetIdentityUniversalAuthClientSecretsEvent | GetIdentityUniversalAuthClientSecretByIdEvent | RevokeIdentityUniversalAuthClientSecretEvent + | ClearIdentityUniversalAuthLockoutsEvent | LoginIdentityGcpAuthEvent | AddIdentityGcpAuthEvent | DeleteIdentityGcpAuthEvent @@ -3636,6 +3662,7 @@ export type Event = | CreateCertificateTemplateEstConfig | UpdateCertificateTemplateEstConfig | GetCertificateTemplateEstConfig + | GetAzureAdCsTemplatesEvent | AttemptCreateSlackIntegration | AttemptReinstallSlackIntegration | UpdateSlackIntegration diff --git a/backend/src/ee/services/event/event-sse-stream.ts b/backend/src/ee/services/event/event-sse-stream.ts index dc7d15c79..13e18374f 100644 --- a/backend/src/ee/services/event/event-sse-stream.ts +++ b/backend/src/ee/services/event/event-sse-stream.ts @@ -123,7 +123,7 @@ export function createEventStreamClient(redis: Redis, options: IEventStreamClien await redis.set(key, "1", "EX", 60); - stream.push("1"); + send({ type: "ping" }); }; const close = () => { diff --git a/backend/src/ee/services/github-org-sync/github-org-sync-service.ts b/backend/src/ee/services/github-org-sync/github-org-sync-service.ts index 37d1bd398..7c4ad15eb 100644 --- a/backend/src/ee/services/github-org-sync/github-org-sync-service.ts +++ b/backend/src/ee/services/github-org-sync/github-org-sync-service.ts @@ -1,14 +1,19 @@ +/* eslint-disable @typescript-eslint/return-await */ +/* eslint-disable no-await-in-loop */ import { ForbiddenError } from "@casl/ability"; import { Octokit } from "@octokit/core"; import { paginateGraphql } from "@octokit/plugin-paginate-graphql"; import { Octokit as OctokitRest } from "@octokit/rest"; +import RE2 from "re2"; import { OrgMembershipRole } from "@app/db/schemas"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { groupBy } from "@app/lib/fn"; import { logger } from "@app/lib/logger"; +import { retryWithBackoff } from "@app/lib/retry"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; +import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { TGroupDALFactory } from "../group/group-dal"; import { TUserGroupMembershipDALFactory } from "../group/user-group-membership-dal"; @@ -16,20 +21,67 @@ import { TLicenseServiceFactory } from "../license/license-service"; import { OrgPermissionActions, OrgPermissionSubjects } from "../permission/org-permission"; import { TPermissionServiceFactory } from "../permission/permission-service-types"; import { TGithubOrgSyncDALFactory } from "./github-org-sync-dal"; -import { TCreateGithubOrgSyncDTO, TDeleteGithubOrgSyncDTO, TUpdateGithubOrgSyncDTO } from "./github-org-sync-types"; +import { + TCreateGithubOrgSyncDTO, + TDeleteGithubOrgSyncDTO, + TSyncAllTeamsDTO, + TSyncResult, + TUpdateGithubOrgSyncDTO, + TValidateGithubTokenDTO +} from "./github-org-sync-types"; const OctokitWithPlugin = Octokit.plugin(paginateGraphql); +// Type definitions for GitHub API errors +interface GitHubApiError extends Error { + status?: number; + response?: { + status?: number; + headers?: { + "x-ratelimit-reset"?: string; + }; + }; +} + +interface OrgMembershipWithUser { + id: string; + orgId: string; + role: string; + status: string; + isActive: boolean; + inviteEmail: string | null; + user: { + id: string; + email: string; + username: string | null; + firstName: string | null; + lastName: string | null; + } | null; +} + +interface GroupMembership { + id: string; + groupId: string; + groupName: string; + orgMembershipId: string; + firstName: string | null; + lastName: string | null; +} + type TGithubOrgSyncServiceFactoryDep = { githubOrgSyncDAL: TGithubOrgSyncDALFactory; permissionService: Pick; kmsService: Pick; userGroupMembershipDAL: Pick< TUserGroupMembershipDALFactory, - "findGroupMembershipsByUserIdInOrg" | "insertMany" | "delete" + "findGroupMembershipsByUserIdInOrg" | "findGroupMembershipsByGroupIdInOrg" | "insertMany" | "delete" >; groupDAL: Pick; licenseService: Pick; + orgMembershipDAL: Pick< + TOrgMembershipDALFactory, + "find" | "findOrgMembershipById" | "findOrgMembershipsWithUsersByOrgId" + >; }; export type TGithubOrgSyncServiceFactory = ReturnType; @@ -40,7 +92,8 @@ export const githubOrgSyncServiceFactory = ({ kmsService, userGroupMembershipDAL, groupDAL, - licenseService + licenseService, + orgMembershipDAL }: TGithubOrgSyncServiceFactoryDep) => { const createGithubOrgSync = async ({ githubOrgName, @@ -304,8 +357,8 @@ export const githubOrgSyncServiceFactory = ({ const removeFromTeams = infisicalUserGroups.filter((el) => !githubUserTeamSet.has(el.groupName)); if (newTeams.length || updateTeams.length || removeFromTeams.length) { - await groupDAL.transaction(async (tx) => { - if (newTeams.length) { + if (newTeams.length) { + await groupDAL.transaction(async (tx) => { const newGroups = await groupDAL.insertMany( newTeams.map((newGroupName) => ({ name: newGroupName, @@ -322,9 +375,11 @@ export const githubOrgSyncServiceFactory = ({ })), tx ); - } + }); + } - if (updateTeams.length) { + if (updateTeams.length) { + await groupDAL.transaction(async (tx) => { await userGroupMembershipDAL.insertMany( updateTeams.map((el) => ({ groupId: githubUserTeamOnInfisicalGroupByName[el][0].id, @@ -332,16 +387,433 @@ export const githubOrgSyncServiceFactory = ({ })), tx ); - } + }); + } - if (removeFromTeams.length) { + if (removeFromTeams.length) { + await groupDAL.transaction(async (tx) => { await userGroupMembershipDAL.delete( { userId, $in: { groupId: removeFromTeams.map((el) => el.groupId) } }, tx ); - } + }); + } + } + }; + + const validateGithubToken = async ({ orgPermission, githubOrgAccessToken }: TValidateGithubTokenDTO) => { + const { permission } = await permissionService.getOrgPermission( + orgPermission.type, + orgPermission.id, + orgPermission.orgId, + orgPermission.authMethod, + orgPermission.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Read, OrgPermissionSubjects.GithubOrgSync); + + const plan = await licenseService.getPlan(orgPermission.orgId); + if (!plan.githubOrgSync) { + throw new BadRequestError({ + message: + "Failed to validate GitHub token due to plan restriction. Upgrade plan to use GitHub organization sync." }); } + + const config = await githubOrgSyncDAL.findOne({ orgId: orgPermission.orgId }); + if (!config) { + throw new BadRequestError({ message: "GitHub organization sync is not configured" }); + } + + try { + const testOctokit = new OctokitRest({ + auth: githubOrgAccessToken, + request: { + signal: AbortSignal.timeout(10000) + } + }); + + const { data: org } = await testOctokit.rest.orgs.get({ + org: config.githubOrgName + }); + + const octokitGraphQL = new OctokitWithPlugin({ + auth: githubOrgAccessToken, + request: { + signal: AbortSignal.timeout(10000) + } + }); + + await octokitGraphQL.graphql(`query($org: String!) { organization(login: $org) { id name } }`, { + org: config.githubOrgName + }); + + return { + valid: true, + organizationInfo: { + id: org.id, + login: org.login, + name: org.name || org.login, + publicRepos: org.public_repos, + privateRepos: org.owned_private_repos || 0 + } + }; + } catch (error) { + logger.error(error, `GitHub token validation failed for org ${config.githubOrgName}`); + + const gitHubError = error as GitHubApiError; + const statusCode = gitHubError.status || gitHubError.response?.status; + if (statusCode) { + if (statusCode === 401) { + throw new BadRequestError({ + message: "GitHub access token is invalid or expired." + }); + } + if (statusCode === 403) { + throw new BadRequestError({ + message: + "GitHub access token lacks required permissions. Required: 1) 'read:org' scope for organization teams, 2) Token owner must be an organization member with team visibility access, 3) Organization settings must allow team visibility. Check GitHub token scopes and organization member permissions." + }); + } + if (statusCode === 404) { + throw new BadRequestError({ + message: `Organization '${config.githubOrgName}' not found or access token does not have access to it.` + }); + } + } + + throw new BadRequestError({ + message: `GitHub token validation failed: ${(error as Error).message}` + }); + } + }; + + const syncAllTeams = async ({ orgPermission }: TSyncAllTeamsDTO): Promise => { + const { permission } = await permissionService.getOrgPermission( + orgPermission.type, + orgPermission.id, + orgPermission.orgId, + orgPermission.authMethod, + orgPermission.orgId + ); + + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionActions.Edit, + OrgPermissionSubjects.GithubOrgSyncManual + ); + + const plan = await licenseService.getPlan(orgPermission.orgId); + if (!plan.githubOrgSync) { + throw new BadRequestError({ + message: + "Failed to sync all GitHub teams due to plan restriction. Upgrade plan to use GitHub organization sync." + }); + } + + const config = await githubOrgSyncDAL.findOne({ orgId: orgPermission.orgId }); + if (!config || !config?.isActive) { + throw new BadRequestError({ message: "GitHub organization sync is not configured or not active" }); + } + + const { decryptor } = await kmsService.createCipherPairWithDataKey({ + type: KmsDataKey.Organization, + orgId: orgPermission.orgId + }); + + if (!config.encryptedGithubOrgAccessToken) { + throw new BadRequestError({ + message: "GitHub organization access token is required. Please set a token first." + }); + } + + const orgAccessToken = decryptor({ cipherTextBlob: config.encryptedGithubOrgAccessToken }).toString(); + + try { + const testOctokit = new OctokitRest({ + auth: orgAccessToken, + request: { + signal: AbortSignal.timeout(10000) + } + }); + + await testOctokit.rest.orgs.get({ + org: config.githubOrgName + }); + + await testOctokit.rest.users.getAuthenticated(); + } catch (error) { + throw new BadRequestError({ + message: "Stored GitHub access token is invalid or expired. Please set a new token." + }); + } + + const allMembers = await orgMembershipDAL.findOrgMembershipsWithUsersByOrgId(orgPermission.orgId); + const activeMembers = allMembers.filter( + (member) => member.status === "accepted" && member.isActive + ) as OrgMembershipWithUser[]; + + const startTime = Date.now(); + const syncErrors: string[] = []; + + const octokit = new OctokitWithPlugin({ + auth: orgAccessToken, + request: { + signal: AbortSignal.timeout(30000) + } + }); + + const data = await retryWithBackoff(async () => { + return octokit.graphql + .paginate<{ + organization: { + teams: { + totalCount: number; + edges: { + node: { + name: string; + description: string; + members: { + edges: { + node: { + login: string; + }; + }[]; + }; + }; + }[]; + }; + }; + }>( + ` + query orgTeams($cursor: String, $org: String!) { + organization(login: $org) { + teams(first: 100, after: $cursor) { + totalCount + edges { + node { + name + description + members(first: 100) { + edges { + node { + login + } + } + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } + `, + { + org: config.githubOrgName + } + ) + .catch((err) => { + logger.error(err, "GitHub GraphQL error for batched team sync"); + + const gitHubError = err as GitHubApiError; + const statusCode = gitHubError.status || gitHubError.response?.status; + if (statusCode) { + if (statusCode === 401) { + throw new BadRequestError({ + message: "GitHub access token is invalid or expired. Please provide a new token." + }); + } + if (statusCode === 403) { + throw new BadRequestError({ + message: + "GitHub access token lacks required permissions for organization team sync. Required: 1) 'admin:org' scope, 2) Token owner must be organization owner or have team read permissions, 3) Organization settings must allow team visibility. Check token scopes and user role." + }); + } + if (statusCode === 404) { + throw new BadRequestError({ + message: `Organization ${config.githubOrgName} not found or access token does not have sufficient permissions to read it.` + }); + } + } + + if ((err as Error)?.message?.includes("Although you appear to have the correct authorization credential")) { + throw new BadRequestError({ + message: + "Organization has restricted OAuth app access. Please check that: 1) Your organization has approved the Infisical OAuth application, 2) The token owner has sufficient organization permissions." + }); + } + throw new BadRequestError({ message: `GitHub GraphQL query failed: ${(err as Error)?.message}` }); + }); + }); + + const { + organization: { teams } + } = data; + + const userTeamMap = new Map(); + const allGithubUsernamesInTeams = new Set(); + + teams?.edges?.forEach((teamEdge) => { + const teamName = teamEdge.node.name.toLowerCase(); + + teamEdge.node.members.edges.forEach((memberEdge) => { + const username = memberEdge.node.login.toLowerCase(); + allGithubUsernamesInTeams.add(username); + + if (!userTeamMap.has(username)) { + userTeamMap.set(username, []); + } + userTeamMap.get(username)!.push(teamName); + }); + }); + + const allGithubTeamNames = Array.from(new Set(teams?.edges?.map((edge) => edge.node.name.toLowerCase()) || [])); + + const existingTeamsOnInfisical = await groupDAL.find({ + orgId: orgPermission.orgId, + $in: { name: allGithubTeamNames } + }); + const existingTeamsMap = groupBy(existingTeamsOnInfisical, (i) => i.name); + + const teamsToCreate = allGithubTeamNames.filter((teamName) => !(teamName in existingTeamsMap)); + const createdTeams = new Set(); + const updatedTeams = new Set(); + const totalRemovedMemberships = 0; + + await groupDAL.transaction(async (tx) => { + if (teamsToCreate.length > 0) { + const newGroups = await groupDAL.insertMany( + teamsToCreate.map((teamName) => ({ + name: teamName, + role: OrgMembershipRole.Member, + slug: teamName, + orgId: orgPermission.orgId + })), + tx + ); + + newGroups.forEach((group) => { + if (!existingTeamsMap[group.name]) { + existingTeamsMap[group.name] = []; + } + existingTeamsMap[group.name].push(group); + createdTeams.add(group.name); + }); + } + + const allTeams = [...Object.values(existingTeamsMap).flat()]; + + for (const team of allTeams) { + const teamName = team.name.toLowerCase(); + + const currentMemberships = (await userGroupMembershipDAL.findGroupMembershipsByGroupIdInOrg( + team.id, + orgPermission.orgId + )) as GroupMembership[]; + + const expectedUserIds = new Set(); + teams?.edges?.forEach((teamEdge) => { + if (teamEdge.node.name.toLowerCase() === teamName) { + teamEdge.node.members.edges.forEach((memberEdge) => { + const githubUsername = memberEdge.node.login.toLowerCase(); + + const matchingMember = activeMembers.find((member) => { + const email = member.user?.email || member.inviteEmail; + if (!email) return false; + + const emailPrefix = email.split("@")[0].toLowerCase(); + const emailDomain = email.split("@")[1].toLowerCase(); + + if (emailPrefix === githubUsername) { + return true; + } + const domainName = emailDomain.split(".")[0]; + if (githubUsername.endsWith(domainName) && githubUsername.length > domainName.length) { + const baseUsername = githubUsername.slice(0, -domainName.length); + if (emailPrefix === baseUsername) { + return true; + } + } + const emailSplitRegex = new RE2(/[._-]/); + const emailParts = emailPrefix.split(emailSplitRegex); + const longestEmailPart = emailParts.reduce((a, b) => (a.length > b.length ? a : b), ""); + if (longestEmailPart.length >= 4 && githubUsername.includes(longestEmailPart)) { + return true; + } + return false; + }); + + if (matchingMember?.user?.id) { + expectedUserIds.add(matchingMember.user.id); + logger.info( + `Matched GitHub user ${githubUsername} to email ${matchingMember.user?.email || matchingMember.inviteEmail}` + ); + } + }); + } + }); + + const currentUserIds = new Set(); + currentMemberships.forEach((membership) => { + const activeMember = activeMembers.find((am) => am.id === membership.orgMembershipId); + if (activeMember?.user?.id) { + currentUserIds.add(activeMember.user.id); + } + }); + + const usersToAdd = Array.from(expectedUserIds).filter((userId) => !currentUserIds.has(userId)); + + const membershipsToRemove = currentMemberships.filter((membership) => { + const activeMember = activeMembers.find((am) => am.id === membership.orgMembershipId); + return activeMember?.user?.id && !expectedUserIds.has(activeMember.user.id); + }); + + if (usersToAdd.length > 0) { + await userGroupMembershipDAL.insertMany( + usersToAdd.map((userId) => ({ + userId, + groupId: team.id + })), + tx + ); + updatedTeams.add(teamName); + } + + if (membershipsToRemove.length > 0) { + await userGroupMembershipDAL.delete( + { + $in: { + id: membershipsToRemove.map((m) => m.id) + } + }, + tx + ); + updatedTeams.add(teamName); + } + } + }); + + const syncDuration = Date.now() - startTime; + + logger.info( + { + orgId: orgPermission.orgId, + createdTeams: createdTeams.size, + syncDuration + }, + "GitHub team sync completed" + ); + + return { + totalUsers: activeMembers.length, + errors: syncErrors, + createdTeams: Array.from(createdTeams), + updatedTeams: Array.from(updatedTeams), + removedMemberships: totalRemovedMemberships, + syncDuration + }; }; return { @@ -349,6 +821,8 @@ export const githubOrgSyncServiceFactory = ({ updateGithubOrgSync, deleteGithubOrgSync, getGithubOrgSync, - syncUserGroups + syncUserGroups, + syncAllTeams, + validateGithubToken }; }; diff --git a/backend/src/ee/services/github-org-sync/github-org-sync-types.ts b/backend/src/ee/services/github-org-sync/github-org-sync-types.ts index e1df71e82..909414c2f 100644 --- a/backend/src/ee/services/github-org-sync/github-org-sync-types.ts +++ b/backend/src/ee/services/github-org-sync/github-org-sync-types.ts @@ -21,3 +21,21 @@ export interface TDeleteGithubOrgSyncDTO { export interface TGetGithubOrgSyncDTO { orgPermission: OrgServiceActor; } + +export interface TSyncAllTeamsDTO { + orgPermission: OrgServiceActor; +} + +export interface TSyncResult { + totalUsers: number; + errors: string[]; + createdTeams: string[]; + updatedTeams: string[]; + removedMemberships: number; + syncDuration: number; +} + +export interface TValidateGithubTokenDTO { + orgPermission: OrgServiceActor; + githubOrgAccessToken: string; +} diff --git a/backend/src/ee/services/ldap-config/ldap-config-service.ts b/backend/src/ee/services/ldap-config/ldap-config-service.ts index 338099da9..a72d50760 100644 --- a/backend/src/ee/services/ldap-config/ldap-config-service.ts +++ b/backend/src/ee/services/ldap-config/ldap-config-service.ts @@ -400,15 +400,13 @@ export const ldapConfigServiceFactory = ({ userAlias = await userDAL.transaction(async (tx) => { let newUser: TUsers | undefined; - if (serverCfg.trustLdapEmails) { - newUser = await userDAL.findOne( - { - email: email.toLowerCase(), - isEmailVerified: true - }, - tx - ); - } + newUser = await userDAL.findOne( + { + email: email.toLowerCase(), + isEmailVerified: true + }, + tx + ); if (!newUser) { const uniqueUsername = await normalizeUsername(username, userDAL); @@ -433,7 +431,8 @@ export const ldapConfigServiceFactory = ({ aliasType: UserAliasType.LDAP, externalId, emails: [email], - orgId + orgId, + isEmailVerified: serverCfg.trustLdapEmails }, tx ); @@ -556,15 +555,14 @@ export const ldapConfigServiceFactory = ({ return newUser; }); - const isUserCompleted = Boolean(user.isAccepted); - + const isUserCompleted = Boolean(user.isAccepted) && userAlias.isEmailVerified; const providerAuthToken = crypto.jwt().sign( { authTokenType: AuthTokenType.PROVIDER_TOKEN, userId: user.id, username: user.username, hasExchangedPrivateKey: true, - ...(user.email && { email: user.email, isEmailVerified: user.isEmailVerified }), + ...(user.email && { email: user.email, isEmailVerified: userAlias.isEmailVerified }), firstName, lastName, organizationName: organization.name, @@ -572,6 +570,7 @@ export const ldapConfigServiceFactory = ({ organizationSlug: organization.slug, authMethod: AuthMethod.LDAP, authType: UserAliasType.LDAP, + aliasId: userAlias.id, isUserCompleted, ...(relayState ? { @@ -585,10 +584,11 @@ export const ldapConfigServiceFactory = ({ } ); - if (user.email && !user.isEmailVerified) { + if (user.email && !userAlias.isEmailVerified) { const token = await tokenService.createTokenForUser({ type: TokenType.TOKEN_EMAIL_VERIFICATION, - userId: user.id + userId: user.id, + aliasId: userAlias.id }); await smtpService.sendMail({ diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index bd3949a7e..8d2d6fdbe 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -32,6 +32,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ auditLogStreams: false, auditLogStreamLimit: 3, samlSSO: false, + enforceGoogleSSO: false, hsm: false, oidcSSO: false, scim: false, diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 098d00feb..345e26638 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -47,6 +47,7 @@ export type TFeatureSet = { auditLogStreamLimit: 3; githubOrgSync: false; samlSSO: false; + enforceGoogleSSO: false; hsm: false; oidcSSO: false; secretAccessInsights: false; diff --git a/backend/src/ee/services/oidc/oidc-config-service.ts b/backend/src/ee/services/oidc/oidc-config-service.ts index b9cf011d9..8f479b12c 100644 --- a/backend/src/ee/services/oidc/oidc-config-service.ts +++ b/backend/src/ee/services/oidc/oidc-config-service.ts @@ -180,7 +180,7 @@ export const oidcConfigServiceFactory = ({ } const appCfg = getConfig(); - const userAlias = await userAliasDAL.findOne({ + let userAlias = await userAliasDAL.findOne({ externalId, orgId, aliasType: UserAliasType.OIDC @@ -231,32 +231,29 @@ export const oidcConfigServiceFactory = ({ } else { user = await userDAL.transaction(async (tx) => { let newUser: TUsers | undefined; + // we prioritize getting the most complete user to create the new alias under + newUser = await userDAL.findOne( + { + email, + isEmailVerified: true + }, + tx + ); - if (serverCfg.trustOidcEmails) { - // we prioritize getting the most complete user to create the new alias under + if (!newUser) { + // this fetches user entries created via invites newUser = await userDAL.findOne( { - email, - isEmailVerified: true + username: email }, tx ); - if (!newUser) { - // this fetches user entries created via invites - newUser = await userDAL.findOne( - { - username: email - }, - tx - ); - - if (newUser && !newUser.isEmailVerified) { - // we automatically mark it as email-verified because we've configured trust for OIDC emails - newUser = await userDAL.updateById(newUser.id, { - isEmailVerified: true - }); - } + if (newUser && !newUser.isEmailVerified) { + // we automatically mark it as email-verified because we've configured trust for OIDC emails + newUser = await userDAL.updateById(newUser.id, { + isEmailVerified: serverCfg.trustOidcEmails + }); } } @@ -276,13 +273,14 @@ export const oidcConfigServiceFactory = ({ ); } - await userAliasDAL.create( + userAlias = await userAliasDAL.create( { userId: newUser.id, aliasType: UserAliasType.OIDC, externalId, emails: email ? [email] : [], - orgId + orgId, + isEmailVerified: serverCfg.trustOidcEmails }, tx ); @@ -404,19 +402,20 @@ export const oidcConfigServiceFactory = ({ await licenseService.updateSubscriptionOrgMemberCount(organization.id); - const isUserCompleted = Boolean(user.isAccepted); + const isUserCompleted = Boolean(user.isAccepted) && userAlias.isEmailVerified; const providerAuthToken = crypto.jwt().sign( { authTokenType: AuthTokenType.PROVIDER_TOKEN, userId: user.id, username: user.username, - ...(user.email && { email: user.email, isEmailVerified: user.isEmailVerified }), + ...(user.email && { email: user.email, isEmailVerified: userAlias.isEmailVerified }), firstName, lastName, organizationName: organization.name, organizationId: organization.id, organizationSlug: organization.slug, hasExchangedPrivateKey: true, + aliasId: userAlias.id, authMethod: AuthMethod.OIDC, authType: UserAliasType.OIDC, isUserCompleted, @@ -430,10 +429,11 @@ export const oidcConfigServiceFactory = ({ await oidcConfigDAL.update({ orgId }, { lastUsed: new Date() }); - if (user.email && !user.isEmailVerified) { + if (user.email && !userAlias.isEmailVerified) { const token = await tokenService.createTokenForUser({ type: TokenType.TOKEN_EMAIL_VERIFICATION, - userId: user.id + userId: user.id, + aliasId: userAlias.id }); await smtpService diff --git a/backend/src/ee/services/permission/default-roles.ts b/backend/src/ee/services/permission/default-roles.ts index cca4efaf2..9329c3c7f 100644 --- a/backend/src/ee/services/permission/default-roles.ts +++ b/backend/src/ee/services/permission/default-roles.ts @@ -2,6 +2,7 @@ import { AbilityBuilder, createMongoAbility, MongoAbility } from "@casl/ability" import { ProjectPermissionActions, + ProjectPermissionAuditLogsActions, ProjectPermissionCertificateActions, ProjectPermissionCmekActions, ProjectPermissionCommitsActions, @@ -13,6 +14,7 @@ import { ProjectPermissionPkiSubscriberActions, ProjectPermissionPkiTemplateActions, ProjectPermissionSecretActions, + ProjectPermissionSecretEventActions, ProjectPermissionSecretRotationActions, ProjectPermissionSecretScanningConfigActions, ProjectPermissionSecretScanningDataSourceActions, @@ -252,6 +254,16 @@ const buildAdminPermissionRules = () => { ProjectPermissionSub.SecretScanningConfigs ); + can( + [ + ProjectPermissionSecretEventActions.SubscribeCreated, + ProjectPermissionSecretEventActions.SubscribeDeleted, + ProjectPermissionSecretEventActions.SubscribeUpdated, + ProjectPermissionSecretEventActions.SubscribeImportMutations + ], + ProjectPermissionSub.SecretEvents + ); + return rules; }; @@ -383,7 +395,7 @@ const buildMemberPermissionRules = () => { ); can([ProjectPermissionActions.Read], ProjectPermissionSub.Role); - can([ProjectPermissionActions.Read], ProjectPermissionSub.AuditLogs); + can([ProjectPermissionAuditLogsActions.Read], ProjectPermissionSub.AuditLogs); can([ProjectPermissionActions.Read], ProjectPermissionSub.IpAllowList); // double check if all CRUD are needed for CA and Certificates @@ -455,6 +467,16 @@ const buildMemberPermissionRules = () => { can([ProjectPermissionSecretScanningConfigActions.Read], ProjectPermissionSub.SecretScanningConfigs); + can( + [ + ProjectPermissionSecretEventActions.SubscribeCreated, + ProjectPermissionSecretEventActions.SubscribeDeleted, + ProjectPermissionSecretEventActions.SubscribeUpdated, + ProjectPermissionSecretEventActions.SubscribeImportMutations + ], + ProjectPermissionSub.SecretEvents + ); + return rules; }; @@ -481,7 +503,7 @@ const buildViewerPermissionRules = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.Settings); can(ProjectPermissionActions.Read, ProjectPermissionSub.Environments); can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags); - can(ProjectPermissionActions.Read, ProjectPermissionSub.AuditLogs); + can(ProjectPermissionAuditLogsActions.Read, ProjectPermissionSub.AuditLogs); can(ProjectPermissionActions.Read, ProjectPermissionSub.IpAllowList); can(ProjectPermissionActions.Read, ProjectPermissionSub.CertificateAuthorities); can(ProjectPermissionCertificateActions.Read, ProjectPermissionSub.Certificates); @@ -505,6 +527,16 @@ const buildViewerPermissionRules = () => { can([ProjectPermissionSecretScanningConfigActions.Read], ProjectPermissionSub.SecretScanningConfigs); + can( + [ + ProjectPermissionSecretEventActions.SubscribeCreated, + ProjectPermissionSecretEventActions.SubscribeDeleted, + ProjectPermissionSecretEventActions.SubscribeUpdated, + ProjectPermissionSecretEventActions.SubscribeImportMutations + ], + ProjectPermissionSub.SecretEvents + ); + return rules; }; diff --git a/backend/src/ee/services/permission/org-permission.ts b/backend/src/ee/services/permission/org-permission.ts index 2436dae2a..d4155d02c 100644 --- a/backend/src/ee/services/permission/org-permission.ts +++ b/backend/src/ee/services/permission/org-permission.ts @@ -23,6 +23,10 @@ export enum OrgPermissionAppConnectionActions { Connect = "connect" } +export enum OrgPermissionAuditLogsActions { + Read = "read" +} + export enum OrgPermissionKmipActions { Proxy = "proxy", Setup = "setup" @@ -90,6 +94,7 @@ export enum OrgPermissionSubjects { Sso = "sso", Scim = "scim", GithubOrgSync = "github-org-sync", + GithubOrgSyncManual = "github-org-sync-manual", Ldap = "ldap", Groups = "groups", Billing = "billing", @@ -119,13 +124,14 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.Sso] | [OrgPermissionActions, OrgPermissionSubjects.Scim] | [OrgPermissionActions, OrgPermissionSubjects.GithubOrgSync] + | [OrgPermissionActions, OrgPermissionSubjects.GithubOrgSyncManual] | [OrgPermissionActions, OrgPermissionSubjects.Ldap] | [OrgPermissionGroupActions, OrgPermissionSubjects.Groups] | [OrgPermissionActions, OrgPermissionSubjects.SecretScanning] | [OrgPermissionBillingActions, OrgPermissionSubjects.Billing] | [OrgPermissionIdentityActions, OrgPermissionSubjects.Identity] | [OrgPermissionActions, OrgPermissionSubjects.Kms] - | [OrgPermissionActions, OrgPermissionSubjects.AuditLogs] + | [OrgPermissionAuditLogsActions, OrgPermissionSubjects.AuditLogs] | [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates] | [OrgPermissionGatewayActions, OrgPermissionSubjects.Gateway] | [ @@ -188,6 +194,10 @@ export const OrgPermissionSchema = z.discriminatedUnion("subject", [ subject: z.literal(OrgPermissionSubjects.GithubOrgSync).describe("The entity this permission pertains to."), action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.") }), + z.object({ + subject: z.literal(OrgPermissionSubjects.GithubOrgSyncManual).describe("The entity this permission pertains to."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.") + }), z.object({ subject: z.literal(OrgPermissionSubjects.Ldap).describe("The entity this permission pertains to."), action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.") @@ -214,7 +224,9 @@ export const OrgPermissionSchema = z.discriminatedUnion("subject", [ }), z.object({ subject: z.literal(OrgPermissionSubjects.AuditLogs).describe("The entity this permission pertains to."), - action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionActions).describe("Describe what action an entity can take.") + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(OrgPermissionAuditLogsActions).describe( + "Describe what action an entity can take." + ) }), z.object({ subject: z.literal(OrgPermissionSubjects.ProjectTemplates).describe("The entity this permission pertains to."), @@ -309,6 +321,11 @@ const buildAdminPermission = () => { can(OrgPermissionActions.Edit, OrgPermissionSubjects.GithubOrgSync); can(OrgPermissionActions.Delete, OrgPermissionSubjects.GithubOrgSync); + can(OrgPermissionActions.Read, OrgPermissionSubjects.GithubOrgSyncManual); + can(OrgPermissionActions.Create, OrgPermissionSubjects.GithubOrgSyncManual); + can(OrgPermissionActions.Edit, OrgPermissionSubjects.GithubOrgSyncManual); + can(OrgPermissionActions.Delete, OrgPermissionSubjects.GithubOrgSyncManual); + can(OrgPermissionActions.Read, OrgPermissionSubjects.Ldap); can(OrgPermissionActions.Create, OrgPermissionSubjects.Ldap); can(OrgPermissionActions.Edit, OrgPermissionSubjects.Ldap); @@ -340,10 +357,7 @@ const buildAdminPermission = () => { can(OrgPermissionActions.Edit, OrgPermissionSubjects.Kms); can(OrgPermissionActions.Delete, OrgPermissionSubjects.Kms); - can(OrgPermissionActions.Read, OrgPermissionSubjects.AuditLogs); - can(OrgPermissionActions.Create, OrgPermissionSubjects.AuditLogs); - can(OrgPermissionActions.Edit, OrgPermissionSubjects.AuditLogs); - can(OrgPermissionActions.Delete, OrgPermissionSubjects.AuditLogs); + can(OrgPermissionAuditLogsActions.Read, OrgPermissionSubjects.AuditLogs); can(OrgPermissionActions.Read, OrgPermissionSubjects.ProjectTemplates); can(OrgPermissionActions.Create, OrgPermissionSubjects.ProjectTemplates); @@ -416,7 +430,7 @@ const buildMemberPermission = () => { can(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); can(OrgPermissionIdentityActions.Delete, OrgPermissionSubjects.Identity); - can(OrgPermissionActions.Read, OrgPermissionSubjects.AuditLogs); + can(OrgPermissionAuditLogsActions.Read, OrgPermissionSubjects.AuditLogs); can(OrgPermissionAppConnectionActions.Connect, OrgPermissionSubjects.AppConnections); can(OrgPermissionGatewayActions.ListGateways, OrgPermissionSubjects.Gateway); diff --git a/backend/src/ee/services/permission/permission-dal.ts b/backend/src/ee/services/permission/permission-dal.ts index 9677c69b1..cdf55127f 100644 --- a/backend/src/ee/services/permission/permission-dal.ts +++ b/backend/src/ee/services/permission/permission-dal.ts @@ -35,6 +35,7 @@ export interface TPermissionDALFactory { projectFavorites?: string[] | null | undefined; customRoleSlug?: string | null | undefined; orgAuthEnforced?: boolean | null | undefined; + orgGoogleSsoAuthEnforced: boolean; } & { groups: { id: string; @@ -87,6 +88,7 @@ export interface TPermissionDALFactory { }[]; orgId: string; orgAuthEnforced: boolean | null | undefined; + orgGoogleSsoAuthEnforced: boolean; orgRole: OrgMembershipRole; userId: string; projectId: string; @@ -350,6 +352,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { db.ref("slug").withSchema(TableName.OrgRoles).withSchema(TableName.OrgRoles).as("customRoleSlug"), db.ref("permissions").withSchema(TableName.OrgRoles), db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"), + db.ref("googleSsoAuthEnforced").withSchema(TableName.Organization).as("orgGoogleSsoAuthEnforced"), db.ref("bypassOrgAuthEnabled").withSchema(TableName.Organization).as("bypassOrgAuthEnabled"), db.ref("groupId").withSchema("userGroups"), db.ref("groupOrgId").withSchema("userGroups"), @@ -369,6 +372,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { OrgMembershipsSchema.extend({ permissions: z.unknown(), orgAuthEnforced: z.boolean().optional().nullable(), + orgGoogleSsoAuthEnforced: z.boolean(), bypassOrgAuthEnabled: z.boolean(), customRoleSlug: z.string().optional().nullable(), shouldUseNewPrivilegeSystem: z.boolean() @@ -988,6 +992,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { db.ref("key").withSchema(TableName.IdentityMetadata).as("metadataKey"), db.ref("value").withSchema(TableName.IdentityMetadata).as("metadataValue"), db.ref("authEnforced").withSchema(TableName.Organization).as("orgAuthEnforced"), + db.ref("googleSsoAuthEnforced").withSchema(TableName.Organization).as("orgGoogleSsoAuthEnforced"), db.ref("bypassOrgAuthEnabled").withSchema(TableName.Organization).as("bypassOrgAuthEnabled"), db.ref("role").withSchema(TableName.OrgMembership).as("orgRole"), db.ref("orgId").withSchema(TableName.Project), @@ -1003,6 +1008,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { orgId, username, orgAuthEnforced, + orgGoogleSsoAuthEnforced, orgRole, membershipId, groupMembershipId, @@ -1016,6 +1022,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => { }) => ({ orgId, orgAuthEnforced, + orgGoogleSsoAuthEnforced, orgRole: orgRole as OrgMembershipRole, userId, projectId, diff --git a/backend/src/ee/services/permission/permission-fns.ts b/backend/src/ee/services/permission/permission-fns.ts index d645e2bec..7d61d7499 100644 --- a/backend/src/ee/services/permission/permission-fns.ts +++ b/backend/src/ee/services/permission/permission-fns.ts @@ -121,6 +121,7 @@ function isAuthMethodSaml(actorAuthMethod: ActorAuthMethod) { function validateOrgSSO( actorAuthMethod: ActorAuthMethod, isOrgSsoEnforced: TOrganizations["authEnforced"], + isOrgGoogleSsoEnforced: TOrganizations["googleSsoAuthEnforced"], isOrgSsoBypassEnabled: TOrganizations["bypassOrgAuthEnabled"], orgRole: OrgMembershipRole ) { @@ -128,10 +129,16 @@ function validateOrgSSO( throw new UnauthorizedError({ name: "No auth method defined" }); } - if (isOrgSsoEnforced && isOrgSsoBypassEnabled && orgRole === OrgMembershipRole.Admin) { + if ((isOrgSsoEnforced || isOrgGoogleSsoEnforced) && isOrgSsoBypassEnabled && orgRole === OrgMembershipRole.Admin) { return; } + // case: google sso is enforced, but the actor is not using google sso + if (isOrgGoogleSsoEnforced && actorAuthMethod !== null && actorAuthMethod !== AuthMethod.GOOGLE) { + throw new ForbiddenRequestError({ name: "Org auth enforced. Cannot access org-scoped resource" }); + } + + // case: SAML SSO is enforced, but the actor is not using SAML SSO if ( isOrgSsoEnforced && actorAuthMethod !== null && diff --git a/backend/src/ee/services/permission/permission-service.ts b/backend/src/ee/services/permission/permission-service.ts index 85ee82cca..461ceef46 100644 --- a/backend/src/ee/services/permission/permission-service.ts +++ b/backend/src/ee/services/permission/permission-service.ts @@ -146,6 +146,7 @@ export const permissionServiceFactory = ({ validateOrgSSO( authMethod, membership.orgAuthEnforced, + membership.orgGoogleSsoAuthEnforced, membership.bypassOrgAuthEnabled, membership.role as OrgMembershipRole ); @@ -238,6 +239,7 @@ export const permissionServiceFactory = ({ validateOrgSSO( authMethod, userProjectPermission.orgAuthEnforced, + userProjectPermission.orgGoogleSsoAuthEnforced, userProjectPermission.bypassOrgAuthEnabled, userProjectPermission.orgRole ); diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index ab8fea5df..20b4344d3 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -164,6 +164,10 @@ export enum ProjectPermissionSecretEventActions { SubscribeImportMutations = "subscribe-on-import-mutations" } +export enum ProjectPermissionAuditLogsActions { + Read = "read" +} + export enum ProjectPermissionSub { Role = "role", Member = "member", @@ -304,7 +308,7 @@ export type ProjectPermissionSet = | [ProjectPermissionGroupActions, ProjectPermissionSub.Groups] | [ProjectPermissionActions, ProjectPermissionSub.Integrations] | [ProjectPermissionActions, ProjectPermissionSub.Webhooks] - | [ProjectPermissionActions, ProjectPermissionSub.AuditLogs] + | [ProjectPermissionAuditLogsActions, ProjectPermissionSub.AuditLogs] | [ProjectPermissionActions, ProjectPermissionSub.Environments] | [ProjectPermissionActions, ProjectPermissionSub.IpAllowList] | [ProjectPermissionActions, ProjectPermissionSub.Settings] @@ -645,7 +649,7 @@ const GeneralPermissionSchema = [ }), z.object({ subject: z.literal(ProjectPermissionSub.AuditLogs).describe("The entity this permission pertains to."), - action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionAuditLogsActions).describe( "Describe what action an entity can take." ) }), diff --git a/backend/src/ee/services/saml-config/saml-config-service.ts b/backend/src/ee/services/saml-config/saml-config-service.ts index cbb99f7eb..6b8bbe304 100644 --- a/backend/src/ee/services/saml-config/saml-config-service.ts +++ b/backend/src/ee/services/saml-config/saml-config-service.ts @@ -246,7 +246,7 @@ export const samlConfigServiceFactory = ({ }); } - const userAlias = await userAliasDAL.findOne({ + let userAlias = await userAliasDAL.findOne({ externalId, orgId, aliasType: UserAliasType.SAML @@ -320,15 +320,13 @@ export const samlConfigServiceFactory = ({ user = await userDAL.transaction(async (tx) => { let newUser: TUsers | undefined; - if (serverCfg.trustSamlEmails) { - newUser = await userDAL.findOne( - { - email, - isEmailVerified: true - }, - tx - ); - } + newUser = await userDAL.findOne( + { + email, + isEmailVerified: true + }, + tx + ); if (!newUser) { const uniqueUsername = await normalizeUsername(`${firstName ?? ""}-${lastName ?? ""}`, userDAL); @@ -346,13 +344,14 @@ export const samlConfigServiceFactory = ({ ); } - await userAliasDAL.create( + userAlias = await userAliasDAL.create( { userId: newUser.id, aliasType: UserAliasType.SAML, externalId, emails: email ? [email] : [], - orgId + orgId, + isEmailVerified: serverCfg.trustSamlEmails }, tx ); @@ -410,13 +409,13 @@ export const samlConfigServiceFactory = ({ } await licenseService.updateSubscriptionOrgMemberCount(organization.id); - const isUserCompleted = Boolean(user.isAccepted && user.isEmailVerified); + const isUserCompleted = Boolean(user.isAccepted && user.isEmailVerified && userAlias.isEmailVerified); const providerAuthToken = crypto.jwt().sign( { authTokenType: AuthTokenType.PROVIDER_TOKEN, userId: user.id, username: user.username, - ...(user.email && { email: user.email, isEmailVerified: user.isEmailVerified }), + ...(user.email && { email: user.email, isEmailVerified: userAlias.isEmailVerified }), firstName, lastName, organizationName: organization.name, @@ -424,6 +423,7 @@ export const samlConfigServiceFactory = ({ organizationSlug: organization.slug, authMethod: authProvider, hasExchangedPrivateKey: true, + aliasId: userAlias.id, authType: UserAliasType.SAML, isUserCompleted, ...(relayState @@ -440,10 +440,11 @@ export const samlConfigServiceFactory = ({ await samlConfigDAL.update({ orgId }, { lastUsed: new Date() }); - if (user.email && !user.isEmailVerified) { + if (user.email && !userAlias.isEmailVerified) { const token = await tokenService.createTokenForUser({ type: TokenType.TOKEN_EMAIL_VERIFICATION, - userId: user.id + userId: user.id, + aliasId: userAlias.id }); await smtpService.sendMail({ diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts index 49f31bdf6..fe4ca94e1 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts @@ -4,6 +4,7 @@ import { TDbClient } from "@app/db"; import { SecretApprovalRequestsSchema, TableName, + TOrgMemberships, TSecretApprovalRequests, TSecretApprovalRequestsSecrets, TUserGroupMembership, @@ -107,11 +108,32 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { `${TableName.SecretApprovalRequestReviewer}.reviewerUserId`, `secretApprovalReviewerUser.id` ) + + .leftJoin( + db(TableName.OrgMembership).as("approverOrgMembership"), + `${TableName.SecretApprovalPolicyApprover}.approverUserId`, + `approverOrgMembership.userId` + ) + + .leftJoin( + db(TableName.OrgMembership).as("approverGroupOrgMembership"), + `secretApprovalPolicyGroupApproverUser.id`, + `approverGroupOrgMembership.userId` + ) + + .leftJoin( + db(TableName.OrgMembership).as("reviewerOrgMembership"), + `${TableName.SecretApprovalRequestReviewer}.reviewerUserId`, + `reviewerOrgMembership.userId` + ) + .select(selectAllTableCols(TableName.SecretApprovalRequest)) .select( tx.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover), tx.ref("userId").withSchema("approverUserGroupMembership").as("approverGroupUserId"), tx.ref("email").withSchema("secretApprovalPolicyApproverUser").as("approverEmail"), + tx.ref("isActive").withSchema("approverOrgMembership").as("approverIsOrgMembershipActive"), + tx.ref("isActive").withSchema("approverGroupOrgMembership").as("approverGroupIsOrgMembershipActive"), tx.ref("email").withSchema("secretApprovalPolicyGroupApproverUser").as("approverGroupEmail"), tx.ref("username").withSchema("secretApprovalPolicyApproverUser").as("approverUsername"), tx.ref("username").withSchema("secretApprovalPolicyGroupApproverUser").as("approverGroupUsername"), @@ -148,6 +170,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { tx.ref("username").withSchema("secretApprovalReviewerUser").as("reviewerUsername"), tx.ref("firstName").withSchema("secretApprovalReviewerUser").as("reviewerFirstName"), tx.ref("lastName").withSchema("secretApprovalReviewerUser").as("reviewerLastName"), + tx.ref("isActive").withSchema("reviewerOrgMembership").as("reviewerIsOrgMembershipActive"), tx.ref("id").withSchema(TableName.SecretApprovalPolicy).as("policyId"), tx.ref("name").withSchema(TableName.SecretApprovalPolicy).as("policyName"), tx.ref("projectId").withSchema(TableName.Environment), @@ -157,7 +180,11 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { tx.ref("enforcementLevel").withSchema(TableName.SecretApprovalPolicy).as("policyEnforcementLevel"), tx.ref("allowedSelfApprovals").withSchema(TableName.SecretApprovalPolicy).as("policyAllowedSelfApprovals"), tx.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals"), - tx.ref("deletedAt").withSchema(TableName.SecretApprovalPolicy).as("policyDeletedAt") + tx.ref("deletedAt").withSchema(TableName.SecretApprovalPolicy).as("policyDeletedAt"), + tx + .ref("shouldCheckSecretPermission") + .withSchema(TableName.SecretApprovalPolicy) + .as("policySecretReadAccessCompat") ); const findById = async (id: string, tx?: Knex) => { @@ -197,7 +224,8 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { enforcementLevel: el.policyEnforcementLevel, envId: el.policyEnvId, deletedAt: el.policyDeletedAt, - allowedSelfApprovals: el.policyAllowedSelfApprovals + allowedSelfApprovals: el.policyAllowedSelfApprovals, + shouldCheckSecretPermission: el.policySecretReadAccessCompat } }), childrenMapper: [ @@ -211,9 +239,21 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { reviewerLastName: lastName, reviewerUsername: username, reviewerFirstName: firstName, - reviewerComment: comment + reviewerComment: comment, + reviewerIsOrgMembershipActive: isOrgMembershipActive }) => - userId ? { userId, status, email, firstName, lastName, username, comment: comment ?? "" } : undefined + userId + ? { + userId, + status, + email, + firstName, + lastName, + username, + comment: comment ?? "", + isOrgMembershipActive + } + : undefined }, { key: "approverUserId", @@ -223,13 +263,15 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { approverEmail: email, approverUsername: username, approverLastName: lastName, - approverFirstName: firstName + approverFirstName: firstName, + approverIsOrgMembershipActive: isOrgMembershipActive }) => ({ userId, email, firstName, lastName, - username + username, + isOrgMembershipActive }) }, { @@ -240,13 +282,15 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { approverGroupEmail: email, approverGroupUsername: username, approverGroupLastName: lastName, - approverGroupFirstName: firstName + approverGroupFirstName: firstName, + approverGroupIsOrgMembershipActive: isOrgMembershipActive }) => ({ userId, email, firstName, lastName, - username + username, + isOrgMembershipActive }) }, { @@ -653,14 +697,15 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { db.ref("firstName").withSchema("committerUser").as("committerUserFirstName"), db.ref("lastName").withSchema("committerUser").as("committerUserLastName") ) - .distinctOn(`${TableName.SecretApprovalRequest}.id`) .as("inner"); - const query = (tx || db) - .select("*") + const countQuery = (await (tx || db) .select(db.raw("count(*) OVER() as total_count")) - .from(innerQuery) - .orderBy("createdAt", "desc") as typeof innerQuery; + .from(innerQuery.clone().distinctOn(`${TableName.SecretApprovalRequest}.id`))) as Array<{ + total_count: number; + }>; + + const query = (tx || db).select("*").from(innerQuery).orderBy("createdAt", "desc") as typeof innerQuery; if (search) { void query.where((qb) => { @@ -686,8 +731,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { .where("w.rank", ">=", rankOffset) .andWhere("w.rank", "<", rankOffset + limit); - // @ts-expect-error knex does not infer - const totalCount = Number(docs[0]?.total_count || 0); + const totalCount = Number(countQuery[0]?.total_count || 0); const formattedDoc = sqlNestRelationships({ data: docs, diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts index 90a7b0e1f..d485f7ea0 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts @@ -258,6 +258,7 @@ export const secretApprovalRequestServiceFactory = ({ if (actor === ActorType.SERVICE) throw new BadRequestError({ message: "Cannot use service token" }); const secretApprovalRequest = await secretApprovalRequestDAL.findById(id); + if (!secretApprovalRequest) throw new NotFoundError({ message: `Secret approval request with ID '${id}' not found` }); @@ -280,13 +281,22 @@ export const secretApprovalRequestServiceFactory = ({ ) { throw new ForbiddenRequestError({ message: "User has insufficient privileges" }); } - const getHasSecretReadAccess = (environment: string, tags: { slug: string }[], secretPath?: string) => { - const canRead = hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { - environment, - secretPath: secretPath || "/", - secretTags: tags.map((i) => i.slug) - }); - return canRead; + const getHasSecretReadAccess = ( + shouldCheckSecretPermission: boolean | null | undefined, + environment: string, + tags: { slug: string }[], + secretPath?: string + ) => { + if (shouldCheckSecretPermission) { + const canRead = hasSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.ReadValue, { + environment, + secretPath: secretPath || "/", + secretTags: tags.map((i) => i.slug) + }); + return canRead; + } + + return true; }; let secrets; @@ -308,8 +318,18 @@ export const secretApprovalRequestServiceFactory = ({ version: el.version, secretMetadata: el.secretMetadata as ResourceMetadataDTO, isRotatedSecret: el.secret?.isRotatedSecret ?? false, - secretValueHidden: !getHasSecretReadAccess(secretApprovalRequest.environment, el.tags, secretPath?.[0]?.path), - secretValue: !getHasSecretReadAccess(secretApprovalRequest.environment, el.tags, secretPath?.[0]?.path) + secretValueHidden: !getHasSecretReadAccess( + secretApprovalRequest.policy.shouldCheckSecretPermission, + secretApprovalRequest.environment, + el.tags, + secretPath?.[0]?.path + ), + secretValue: !getHasSecretReadAccess( + secretApprovalRequest.policy.shouldCheckSecretPermission, + secretApprovalRequest.environment, + el.tags, + secretPath?.[0]?.path + ) ? INFISICAL_SECRET_VALUE_HIDDEN_MASK : el.secret && el.secret.isRotatedSecret ? undefined @@ -325,11 +345,17 @@ 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.environment, el.tags, secretPath?.[0]?.path) + secretValue: !getHasSecretReadAccess( + secretApprovalRequest.policy.shouldCheckSecretPermission, + secretApprovalRequest.environment, + el.tags, + secretPath?.[0]?.path + ) ? INFISICAL_SECRET_VALUE_HIDDEN_MASK : el.secret.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.secret.encryptedValue }).toString() @@ -345,11 +371,17 @@ 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.environment, el.tags, secretPath?.[0]?.path) + secretValue: !getHasSecretReadAccess( + secretApprovalRequest.policy.shouldCheckSecretPermission, + secretApprovalRequest.environment, + el.tags, + secretPath?.[0]?.path + ) ? INFISICAL_SECRET_VALUE_HIDDEN_MASK : el.secretVersion.encryptedValue ? secretManagerDecryptor({ cipherTextBlob: el.secretVersion.encryptedValue }).toString() @@ -367,7 +399,12 @@ export const secretApprovalRequestServiceFactory = ({ const encryptedSecrets = await secretApprovalRequestSecretDAL.findByRequestId(secretApprovalRequest.id); secrets = encryptedSecrets.map((el) => ({ ...el, - secretValueHidden: !getHasSecretReadAccess(secretApprovalRequest.environment, el.tags, secretPath?.[0]?.path), + secretValueHidden: !getHasSecretReadAccess( + secretApprovalRequest.policy.shouldCheckSecretPermission, + secretApprovalRequest.environment, + el.tags, + secretPath?.[0]?.path + ), ...decryptSecretWithBot(el, botKey), secret: el.secret ? { @@ -1447,6 +1484,7 @@ export const secretApprovalRequestServiceFactory = ({ const commits: Omit[] = []; const commitTagIds: Record = {}; + const existingTagIds: Record = {}; const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, @@ -1512,6 +1550,11 @@ export const secretApprovalRequestServiceFactory = ({ type: SecretType.Shared })) ); + + secretsToUpdateStoredInDB.forEach((el) => { + if (el.tags?.length) existingTagIds[el.key] = el.tags.map((i) => i.id); + }); + if (secretsToUpdateStoredInDB.length !== secretsToUpdate.length) throw new NotFoundError({ message: `Secret does not exist: ${secretsToUpdateStoredInDB.map((el) => el.key).join(",")}` @@ -1555,7 +1598,10 @@ export const secretApprovalRequestServiceFactory = ({ secretMetadata }) => { const secretId = updatingSecretsGroupByKey[secretKey][0].id; - if (tagIds?.length) commitTagIds[newSecretName ?? secretKey] = tagIds; + if (tagIds?.length || existingTagIds[secretKey]?.length) { + commitTagIds[newSecretName ?? secretKey] = tagIds || existingTagIds[secretKey]; + } + return { ...latestSecretVersions[secretId], secretMetadata, diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts index 26aff767e..777e73fe2 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -13,7 +13,8 @@ export const PgSqlLock = { SecretRotationV2Creation: (folderId: string) => pgAdvisoryLockHashText(`secret-rotation-v2-creation:${folderId}`), CreateProject: (orgId: string) => pgAdvisoryLockHashText(`create-project:${orgId}`), CreateFolder: (envId: string, projectId: string) => pgAdvisoryLockHashText(`create-folder:${envId}-${projectId}`), - SshInit: (projectId: string) => pgAdvisoryLockHashText(`ssh-bootstrap:${projectId}`) + SshInit: (projectId: string) => pgAdvisoryLockHashText(`ssh-bootstrap:${projectId}`), + IdentityLogin: (identityId: string, nonce: string) => pgAdvisoryLockHashText(`identity-login:${identityId}:${nonce}`) } as const; // all the key prefixes used must be set here to avoid conflict @@ -40,6 +41,7 @@ export const KeyStorePrefixes = { SecretRotationLock: (rotationId: string) => `secret-rotation-v2-mutex-${rotationId}` as const, SecretScanningLock: (dataSourceId: string, resourceExternalId: string) => `secret-scanning-v2-mutex-${dataSourceId}-${resourceExternalId}` as const, + IdentityLockoutLock: (lockoutKey: string) => `identity-lockout-lock-${lockoutKey}` as const, CaOrderCertificateForSubscriberLock: (subscriberId: string) => `ca-order-certificate-for-subscriber-lock-${subscriberId}` as const, SecretSyncLastRunTimestamp: (syncId: string) => `secret-sync-last-run-${syncId}` as const, diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 0cc272f15..d61694d8f 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -166,7 +166,12 @@ export const UNIVERSAL_AUTH = { accessTokenNumUsesLimit: "The maximum number of times that an access token can be used; a value of 0 implies infinite number of uses.", accessTokenPeriod: - "The period for an access token in seconds. This value will be referenced at renewal time. Default value is 0." + "The period for an access token in seconds. This value will be referenced at renewal time. Default value is 0.", + lockoutEnabled: "Whether the lockout feature is enabled.", + lockoutThreshold: "The amount of times login must fail before locking the identity auth method.", + lockoutDurationSeconds: "How long an identity auth method lockout lasts.", + lockoutCounterResetSeconds: + "How long to wait from the most recent failed login until resetting the lockout counter." }, RETRIEVE: { identityId: "The ID of the identity to retrieve the auth method for." @@ -181,7 +186,12 @@ export const UNIVERSAL_AUTH = { accessTokenTTL: "The new lifetime for an access token in seconds.", accessTokenMaxTTL: "The new maximum lifetime for an access token in seconds.", accessTokenNumUsesLimit: "The new maximum number of times that an access token can be used.", - accessTokenPeriod: "The new period for an access token in seconds." + accessTokenPeriod: "The new period for an access token in seconds.", + lockoutEnabled: "Whether the lockout feature is enabled.", + lockoutThreshold: "The amount of times login must fail before locking the identity auth method.", + lockoutDurationSeconds: "How long an identity auth method lockout lasts.", + lockoutCounterResetSeconds: + "How long to wait from the most recent failed login until resetting the lockout counter." }, CREATE_CLIENT_SECRET: { identityId: "The ID of the identity to create a client secret for.", @@ -201,6 +211,9 @@ export const UNIVERSAL_AUTH = { identityId: "The ID of the identity to revoke the client secret from.", clientSecretId: "The ID of the client secret to revoke." }, + CLEAR_CLIENT_LOCKOUTS: { + identityId: "The ID of the identity to clear the client lockouts from." + }, RENEW_ACCESS_TOKEN: { accessToken: "The access token to renew." }, @@ -2148,7 +2161,9 @@ export const CertificateAuthorities = { directoryUrl: `The directory URL for the ACME Certificate Authority.`, accountEmail: `The email address for the ACME Certificate Authority.`, provider: `The DNS provider for the ACME Certificate Authority.`, - hostedZoneId: `The hosted zone ID for the ACME Certificate Authority.` + hostedZoneId: `The hosted zone ID for the ACME Certificate Authority.`, + eabKid: `The External Account Binding (EAB) Key ID for the ACME Certificate Authority. Required if the ACME provider uses EAB.`, + eabHmacKey: `The External Account Binding (EAB) HMAC key for the ACME Certificate Authority. Required if the ACME provider uses EAB.` }, INTERNAL: { type: "The type of CA to create.", @@ -2312,6 +2327,15 @@ export const AppConnections = { OKTA: { instanceUrl: "The URL used to access your Okta organization.", apiToken: "The API token used to authenticate with Okta." + }, + AZURE_ADCS: { + adcsUrl: + "The HTTPS URL of the Azure ADCS instance to connect with (e.g., 'https://adcs.yourdomain.com/certsrv').", + username: "The username used to access Azure ADCS (format: 'DOMAIN\\username' or 'username@domain.com').", + password: "The password used to access Azure ADCS.", + sslRejectUnauthorized: + "Whether or not to reject unauthorized SSL certificates (true/false). Set to false only in test environments with self-signed certificates.", + sslCertificate: "The SSL certificate (PEM format) to use for secure connection." } } }; @@ -2491,6 +2515,7 @@ export const SecretSyncs = { }, RENDER: { serviceId: "The ID of the Render service to sync secrets to.", + environmentGroupId: "The ID of the Render environment group to sync secrets to.", scope: "The Render scope that secrets should be synced to.", type: "The Render resource type to sync secrets to." }, diff --git a/backend/src/lib/retry/index.ts b/backend/src/lib/retry/index.ts new file mode 100644 index 000000000..de7115399 --- /dev/null +++ b/backend/src/lib/retry/index.ts @@ -0,0 +1,43 @@ +/* eslint-disable no-await-in-loop */ +interface GitHubApiError extends Error { + status?: number; + response?: { + status?: number; + headers?: { + "x-ratelimit-reset"?: string; + }; + }; +} + +const delay = (ms: number) => + new Promise((resolve) => { + setTimeout(() => resolve(), ms); + }); + +export const retryWithBackoff = async (fn: () => Promise, maxRetries = 3, baseDelay = 1000): Promise => { + let lastError: Error; + + for (let attempt = 0; attempt <= maxRetries; attempt += 1) { + try { + return await fn(); + } catch (error) { + lastError = error as Error; + const gitHubError = error as GitHubApiError; + const statusCode = gitHubError.status || gitHubError.response?.status; + if (statusCode === 403) { + const rateLimitReset = gitHubError.response?.headers?.["x-ratelimit-reset"]; + if (rateLimitReset) { + const resetTime = parseInt(rateLimitReset, 10) * 1000; + const waitTime = Math.max(resetTime - Date.now(), baseDelay); + await delay(Math.min(waitTime, 60000)); + } else { + await delay(baseDelay * 2 ** attempt); + } + } else if (attempt < maxRetries) { + await delay(baseDelay * 2 ** attempt); + } + } + } + + throw lastError!; +}; diff --git a/backend/src/lib/template/dot-access.ts b/backend/src/lib/template/dot-access.ts index ec3208feb..898252acc 100644 --- a/backend/src/lib/template/dot-access.ts +++ b/backend/src/lib/template/dot-access.ts @@ -1,11 +1,11 @@ /** * Safely retrieves a value from a nested object using dot notation path */ -export const getStringValueByDot = ( +export const getValueByDot = ( obj: Record | null | undefined, path: string, - defaultValue?: string -): string | undefined => { + defaultValue?: string | number | boolean +): string | number | boolean | undefined => { // Handle null or undefined input if (!obj) { return defaultValue; @@ -26,7 +26,7 @@ export const getStringValueByDot = ( current = (current as Record)[part]; } - if (typeof current !== "string") { + if (typeof current !== "string" && typeof current !== "number" && typeof current !== "boolean") { return defaultValue; } diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 6dd7d190d..3773048b0 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -680,7 +680,8 @@ export const registerRoutes = async ( kmsService, permissionService, groupDAL, - userGroupMembershipDAL + userGroupMembershipDAL, + orgMembershipDAL }); const ldapService = ldapConfigServiceFactory({ @@ -726,7 +727,8 @@ export const registerRoutes = async ( permissionService, groupProjectDAL, smtpService, - projectMembershipDAL + projectMembershipDAL, + userAliasDAL }); const totpService = totpServiceFactory({ @@ -1455,7 +1457,8 @@ export const registerRoutes = async ( identityOrgMembershipDAL, identityProjectDAL, licenseService, - identityMetadataDAL + identityMetadataDAL, + keyStore }); const identityAuthTemplateService = identityAuthTemplateServiceFactory({ @@ -1509,7 +1512,8 @@ export const registerRoutes = async ( identityAccessTokenDAL, identityUaClientSecretDAL, identityUaDAL, - licenseService + licenseService, + keyStore }); const identityKubernetesAuthService = identityKubernetesAuthServiceFactory({ @@ -1743,7 +1747,8 @@ export const registerRoutes = async ( const migrationService = externalMigrationServiceFactory({ externalMigrationQueue, userDAL, - permissionService + permissionService, + gatewayService }); const externalGroupOrgRoleMappingService = externalGroupOrgRoleMappingServiceFactory({ diff --git a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts index f184f2001..c2033f4b4 100644 --- a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts @@ -15,6 +15,10 @@ import { } from "@app/services/app-connection/1password"; import { Auth0ConnectionListItemSchema, SanitizedAuth0ConnectionSchema } from "@app/services/app-connection/auth0"; import { AwsConnectionListItemSchema, SanitizedAwsConnectionSchema } from "@app/services/app-connection/aws"; +import { + AzureADCSConnectionListItemSchema, + SanitizedAzureADCSConnectionSchema +} from "@app/services/app-connection/azure-adcs/azure-adcs-connection-schemas"; import { AzureAppConfigurationConnectionListItemSchema, SanitizedAzureAppConfigurationConnectionSchema @@ -150,7 +154,8 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedSupabaseConnectionSchema.options, ...SanitizedDigitalOceanConnectionSchema.options, ...SanitizedNetlifyConnectionSchema.options, - ...SanitizedOktaConnectionSchema.options + ...SanitizedOktaConnectionSchema.options, + ...SanitizedAzureADCSConnectionSchema.options ]); const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ @@ -190,7 +195,8 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ SupabaseConnectionListItemSchema, DigitalOceanConnectionListItemSchema, NetlifyConnectionListItemSchema, - OktaConnectionListItemSchema + OktaConnectionListItemSchema, + AzureADCSConnectionListItemSchema ]); export const registerAppConnectionRouter = async (server: FastifyZodProvider) => { diff --git a/backend/src/server/routes/v1/app-connection-routers/azure-adcs-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/azure-adcs-connection-router.ts new file mode 100644 index 000000000..cd4d4d1fd --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/azure-adcs-connection-router.ts @@ -0,0 +1,18 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + CreateAzureADCSConnectionSchema, + SanitizedAzureADCSConnectionSchema, + UpdateAzureADCSConnectionSchema +} from "@app/services/app-connection/azure-adcs"; + +import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; + +export const registerAzureADCSConnectionRouter = async (server: FastifyZodProvider) => { + registerAppConnectionEndpoints({ + app: AppConnection.AzureADCS, + server, + sanitizedResponseSchema: SanitizedAzureADCSConnectionSchema, + createSchema: CreateAzureADCSConnectionSchema, + updateSchema: UpdateAzureADCSConnectionSchema + }); +}; diff --git a/backend/src/server/routes/v1/app-connection-routers/checkly-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/checkly-connection-router.ts index bbe3fbbfb..668bea065 100644 --- a/backend/src/server/routes/v1/app-connection-routers/checkly-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/checkly-connection-router.ts @@ -53,4 +53,36 @@ export const registerChecklyConnectionRouter = async (server: FastifyZodProvider return { accounts }; } }); + + server.route({ + method: "GET", + url: `/:connectionId/accounts/:accountId/groups`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid(), + accountId: z.string() + }), + response: { + 200: z.object({ + groups: z + .object({ + name: z.string(), + id: z.string() + }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId, accountId } = req.params; + + const groups = await server.services.appConnection.checkly.listGroups(connectionId, accountId, req.permission); + + return { groups }; + } + }); }; diff --git a/backend/src/server/routes/v1/app-connection-routers/index.ts b/backend/src/server/routes/v1/app-connection-routers/index.ts index 28e3b4e49..70804d173 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -5,6 +5,7 @@ import { AppConnection } from "@app/services/app-connection/app-connection-enums import { registerOnePassConnectionRouter } from "./1password-connection-router"; import { registerAuth0ConnectionRouter } from "./auth0-connection-router"; import { registerAwsConnectionRouter } from "./aws-connection-router"; +import { registerAzureADCSConnectionRouter } from "./azure-adcs-connection-router"; import { registerAzureAppConfigurationConnectionRouter } from "./azure-app-configuration-connection-router"; import { registerAzureClientSecretsConnectionRouter } from "./azure-client-secrets-connection-router"; import { registerAzureDevOpsConnectionRouter } from "./azure-devops-connection-router"; @@ -50,6 +51,7 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record { + const { connectionId } = req.params; + const groups = await server.services.appConnection.render.listEnvironmentGroups(connectionId, req.permission); + + return groups; + } + }); }; diff --git a/backend/src/server/routes/v1/auth-router.ts b/backend/src/server/routes/v1/auth-router.ts index a91548285..911979b60 100644 --- a/backend/src/server/routes/v1/auth-router.ts +++ b/backend/src/server/routes/v1/auth-router.ts @@ -67,7 +67,7 @@ export const registerAuthRoutes = async (server: FastifyZodProvider) => { }) } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT], { requireOrg: false }), handler: () => ({ message: "Authenticated" as const }) }); diff --git a/backend/src/server/routes/v1/certificate-authority-routers/azure-ad-cs-certificate-authority-router.ts b/backend/src/server/routes/v1/certificate-authority-routers/azure-ad-cs-certificate-authority-router.ts new file mode 100644 index 000000000..28266b2f0 --- /dev/null +++ b/backend/src/server/routes/v1/certificate-authority-routers/azure-ad-cs-certificate-authority-router.ts @@ -0,0 +1,78 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { + AzureAdCsCertificateAuthoritySchema, + CreateAzureAdCsCertificateAuthoritySchema, + UpdateAzureAdCsCertificateAuthoritySchema +} from "@app/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-schemas"; +import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; + +import { registerCertificateAuthorityEndpoints } from "./certificate-authority-endpoints"; + +export const registerAzureAdCsCertificateAuthorityRouter = async (server: FastifyZodProvider) => { + registerCertificateAuthorityEndpoints({ + caType: CaType.AZURE_AD_CS, + server, + responseSchema: AzureAdCsCertificateAuthoritySchema, + createSchema: CreateAzureAdCsCertificateAuthoritySchema, + updateSchema: UpdateAzureAdCsCertificateAuthoritySchema + }); + + server.route({ + method: "GET", + url: "/:caId/templates", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + description: "Get available certificate templates from Azure AD CS CA", + params: z.object({ + caId: z.string().describe("Azure AD CS CA ID") + }), + querystring: z.object({ + projectId: z.string().describe("Project ID") + }), + response: { + 200: z.object({ + templates: z.array( + z.object({ + id: z.string().describe("Template identifier"), + name: z.string().describe("Template display name"), + description: z.string().optional().describe("Template description") + }) + ) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const templates = await server.services.certificateAuthority.getAzureAdcsTemplates({ + caId: req.params.caId, + projectId: req.query.projectId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.query.projectId, + event: { + type: EventType.GET_AZURE_AD_TEMPLATES, + metadata: { + caId: req.params.caId, + amount: templates.length + } + } + }); + + return { templates }; + } + }); +}; diff --git a/backend/src/server/routes/v1/certificate-authority-routers/index.ts b/backend/src/server/routes/v1/certificate-authority-routers/index.ts index 56a236911..d146e68be 100644 --- a/backend/src/server/routes/v1/certificate-authority-routers/index.ts +++ b/backend/src/server/routes/v1/certificate-authority-routers/index.ts @@ -1,6 +1,7 @@ import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; import { registerAcmeCertificateAuthorityRouter } from "./acme-certificate-authority-router"; +import { registerAzureAdCsCertificateAuthorityRouter } from "./azure-ad-cs-certificate-authority-router"; import { registerInternalCertificateAuthorityRouter } from "./internal-certificate-authority-router"; export * from "./internal-certificate-authority-router"; @@ -8,5 +9,6 @@ export * from "./internal-certificate-authority-router"; export const CERTIFICATE_AUTHORITY_REGISTER_ROUTER_MAP: Record Promise> = { [CaType.INTERNAL]: registerInternalCertificateAuthorityRouter, - [CaType.ACME]: registerAcmeCertificateAuthorityRouter + [CaType.ACME]: registerAcmeCertificateAuthorityRouter, + [CaType.AZURE_AD_CS]: registerAzureAdCsCertificateAuthorityRouter }; diff --git a/backend/src/server/routes/v1/dashboard-router.ts b/backend/src/server/routes/v1/dashboard-router.ts index 48d536c14..a54bd5ccf 100644 --- a/backend/src/server/routes/v1/dashboard-router.ts +++ b/backend/src/server/routes/v1/dashboard-router.ts @@ -703,6 +703,9 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { // prevent older projects from accessing endpoint if (!shouldUseSecretV2Bridge) throw new BadRequestError({ message: "Project version not supported" }); + // verify folder exists and user has project permission + await server.services.folder.getFolderByPath({ projectId, environment, secretPath }, req.permission); + const tags = req.query.tags?.split(",") ?? []; let remainingLimit = limit; diff --git a/backend/src/server/routes/v1/identity-router.ts b/backend/src/server/routes/v1/identity-router.ts index 9acd515c4..65b9448c5 100644 --- a/backend/src/server/routes/v1/identity-router.ts +++ b/backend/src/server/routes/v1/identity-router.ts @@ -250,7 +250,8 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { description: true }).optional(), identity: IdentitiesSchema.pick({ name: true, id: true, hasDeleteProtection: true }).extend({ - authMethods: z.array(z.string()) + authMethods: z.array(z.string()), + activeLockoutAuthMethods: z.array(z.string()) }) }) }) diff --git a/backend/src/server/routes/v1/identity-universal-auth-router.ts b/backend/src/server/routes/v1/identity-universal-auth-router.ts index 09fffbff8..6d911a88e 100644 --- a/backend/src/server/routes/v1/identity-universal-auth-router.ts +++ b/backend/src/server/routes/v1/identity-universal-auth-router.ts @@ -137,7 +137,21 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { .min(0) .default(0) .describe(UNIVERSAL_AUTH.ATTACH.accessTokenNumUsesLimit), - accessTokenPeriod: z.number().int().min(0).default(0).describe(UNIVERSAL_AUTH.ATTACH.accessTokenPeriod) + accessTokenPeriod: z.number().int().min(0).default(0).describe(UNIVERSAL_AUTH.ATTACH.accessTokenPeriod), + lockoutEnabled: z.boolean().default(true).describe(UNIVERSAL_AUTH.ATTACH.lockoutEnabled), + lockoutThreshold: z.number().min(1).max(30).default(3).describe(UNIVERSAL_AUTH.ATTACH.lockoutThreshold), + lockoutDurationSeconds: z + .number() + .min(30) + .max(86400) + .default(300) + .describe(UNIVERSAL_AUTH.ATTACH.lockoutDurationSeconds), + lockoutCounterResetSeconds: z + .number() + .min(5) + .max(3600) + .default(30) + .describe(UNIVERSAL_AUTH.ATTACH.lockoutCounterResetSeconds) }) .refine( (val) => val.accessTokenTTL <= val.accessTokenMaxTTL, @@ -171,7 +185,11 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { accessTokenMaxTTL: identityUniversalAuth.accessTokenMaxTTL, accessTokenTrustedIps: identityUniversalAuth.accessTokenTrustedIps as TIdentityTrustedIp[], clientSecretTrustedIps: identityUniversalAuth.clientSecretTrustedIps as TIdentityTrustedIp[], - accessTokenNumUsesLimit: identityUniversalAuth.accessTokenNumUsesLimit + accessTokenNumUsesLimit: identityUniversalAuth.accessTokenNumUsesLimit, + lockoutEnabled: identityUniversalAuth.lockoutEnabled, + lockoutThreshold: identityUniversalAuth.lockoutThreshold, + lockoutDurationSeconds: identityUniversalAuth.lockoutDurationSeconds, + lockoutCounterResetSeconds: identityUniversalAuth.lockoutCounterResetSeconds } } }); @@ -243,7 +261,21 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { .min(0) .max(315360000) .optional() - .describe(UNIVERSAL_AUTH.UPDATE.accessTokenPeriod) + .describe(UNIVERSAL_AUTH.UPDATE.accessTokenPeriod), + lockoutEnabled: z.boolean().optional().describe(UNIVERSAL_AUTH.UPDATE.lockoutEnabled), + lockoutThreshold: z.number().min(1).max(30).optional().describe(UNIVERSAL_AUTH.UPDATE.lockoutThreshold), + lockoutDurationSeconds: z + .number() + .min(30) + .max(86400) + .optional() + .describe(UNIVERSAL_AUTH.UPDATE.lockoutDurationSeconds), + lockoutCounterResetSeconds: z + .number() + .min(5) + .max(3600) + .optional() + .describe(UNIVERSAL_AUTH.UPDATE.lockoutCounterResetSeconds) }) .refine( (val) => (val.accessTokenMaxTTL && val.accessTokenTTL ? val.accessTokenTTL <= val.accessTokenMaxTTL : true), @@ -276,7 +308,11 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { accessTokenMaxTTL: identityUniversalAuth.accessTokenMaxTTL, accessTokenTrustedIps: identityUniversalAuth.accessTokenTrustedIps as TIdentityTrustedIp[], clientSecretTrustedIps: identityUniversalAuth.clientSecretTrustedIps as TIdentityTrustedIp[], - accessTokenNumUsesLimit: identityUniversalAuth.accessTokenNumUsesLimit + accessTokenNumUsesLimit: identityUniversalAuth.accessTokenNumUsesLimit, + lockoutEnabled: identityUniversalAuth.lockoutEnabled, + lockoutThreshold: identityUniversalAuth.lockoutThreshold, + lockoutDurationSeconds: identityUniversalAuth.lockoutDurationSeconds, + lockoutCounterResetSeconds: identityUniversalAuth.lockoutCounterResetSeconds } } }); @@ -594,4 +630,53 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { return { clientSecretData }; } }); + + server.route({ + method: "POST", + url: "/universal-auth/identities/:identityId/clear-lockouts", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + hide: false, + tags: [ApiDocsTags.UniversalAuth], + description: "Clear Universal Auth Lockouts for identity", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + identityId: z.string().describe(UNIVERSAL_AUTH.CLEAR_CLIENT_LOCKOUTS.identityId) + }), + response: { + 200: z.object({ + deleted: z.number() + }) + } + }, + handler: async (req) => { + const clearLockoutsData = await server.services.identityUa.clearUniversalAuthLockouts({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + identityId: req.params.identityId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + orgId: clearLockoutsData.orgId, + event: { + type: EventType.CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS, + metadata: { + identityId: clearLockoutsData.identityId + } + } + }); + + return clearLockoutsData; + } + }); }; diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index 323354bc1..b8eb3ad6b 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -279,6 +279,7 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { name: GenericResourceNameSchema.optional(), slug: slugSchema({ max: 64 }).optional(), authEnforced: z.boolean().optional(), + googleSsoAuthEnforced: z.boolean().optional(), scimEnabled: z.boolean().optional(), defaultMembershipRoleSlug: slugSchema({ max: 64, field: "Default Membership Role" }).optional(), enforceMfa: z.boolean().optional(), diff --git a/backend/src/server/routes/v1/pki-subscriber-router.ts b/backend/src/server/routes/v1/pki-subscriber-router.ts index 0e9ec6e0c..30202af18 100644 --- a/backend/src/server/routes/v1/pki-subscriber-router.ts +++ b/backend/src/server/routes/v1/pki-subscriber-router.ts @@ -1,3 +1,4 @@ +import RE2 from "re2"; import { z } from "zod"; import { CertificatesSchema } from "@app/db/schemas"; @@ -112,7 +113,88 @@ export const registerPkiSubscriberRouter = async (server: FastifyZodProvider) => .transform((arr) => Array.from(new Set(arr))) .describe(PKI_SUBSCRIBERS.CREATE.extendedKeyUsages), enableAutoRenewal: z.boolean().optional().describe(PKI_SUBSCRIBERS.CREATE.enableAutoRenewal), - autoRenewalPeriodInDays: z.number().min(1).optional().describe(PKI_SUBSCRIBERS.CREATE.autoRenewalPeriodInDays) + autoRenewalPeriodInDays: z.number().min(1).optional().describe(PKI_SUBSCRIBERS.CREATE.autoRenewalPeriodInDays), + properties: z + .object({ + azureTemplateType: z.string().optional().describe("Azure ADCS Certificate Template Type"), + organization: z + .string() + .trim() + .min(1) + .max(64, "Organization cannot exceed 64 characters") + .regex( + new RE2('^[^,=+<>#;\\\\"/\\r\\n\\t]*$'), + 'Organization contains invalid characters: , = + < > # ; \\ " / \\r \\n \\t' + ) + .regex( + new RE2("^[^\\\\s\\\\-_.]+.*[^\\\\s\\\\-_.]+$|^[^\\\\s\\\\-_.]{1}$"), + "Organization cannot start or end with spaces, hyphens, underscores, or periods" + ) + .optional() + .describe("Organization (O) - Maximum 64 characters, no special DN characters"), + organizationalUnit: z + .string() + .trim() + .min(1) + .max(64, "Organizational Unit cannot exceed 64 characters") + .regex( + new RE2('^[^,=+<>#;\\\\"/\\r\\n\\t]*$'), + 'Organizational Unit contains invalid characters: , = + < > # ; \\ " / \\r \\n \\t' + ) + .regex( + new RE2("^[^\\\\s\\\\-_.]+.*[^\\\\s\\\\-_.]+$|^[^\\\\s\\\\-_.]{1}$"), + "Organizational Unit cannot start or end with spaces, hyphens, underscores, or periods" + ) + .optional() + .describe("Organizational Unit (OU) - Maximum 64 characters, no special DN characters"), + country: z + .string() + .trim() + .length(2, "Country must be exactly 2 characters") + .regex(new RE2("^[A-Z]{2}$"), "Country must be exactly 2 uppercase letters") + .optional() + .describe("Country (C) - Two uppercase letter country code (e.g., US, CA, GB)"), + state: z + .string() + .trim() + .min(1) + .max(64, "State cannot exceed 64 characters") + .regex( + new RE2('^[^,=+<>#;\\\\"/\\r\\n\\t]*$'), + 'State contains invalid characters: , = + < > # ; \\ " / \\r \\n \\t' + ) + .regex( + new RE2("^[^\\\\s\\\\-_.]+.*[^\\\\s\\\\-_.]+$|^[^\\\\s\\\\-_.]{1}$"), + "State cannot start or end with spaces, hyphens, underscores, or periods" + ) + .optional() + .describe("State/Province (ST) - Maximum 64 characters, no special DN characters"), + locality: z + .string() + .trim() + .min(1) + .max(64, "Locality cannot exceed 64 characters") + .regex( + new RE2('^[^,=+<>#;\\\\"/\\r\\n\\t]*$'), + 'Locality contains invalid characters: , = + < > # ; \\ " / \\r \\n \\t' + ) + .regex( + new RE2("^[^\\\\s\\\\-_.]+.*[^\\\\s\\\\-_.]+$|^[^\\\\s\\\\-_.]{1}$"), + "Locality cannot start or end with spaces, hyphens, underscores, or periods" + ) + .optional() + .describe("Locality (L) - Maximum 64 characters, no special DN characters"), + emailAddress: z + .string() + .trim() + .email("Email Address must be a valid email format") + .min(6, "Email Address must be at least 6 characters") + .max(64, "Email Address cannot exceed 64 characters") + .optional() + .describe("Email Address - Valid email format between 6 and 64 characters") + }) + .optional() + .describe("Additional subscriber properties and subject fields") }), response: { 200: sanitizedPkiSubscriber @@ -199,7 +281,88 @@ export const registerPkiSubscriberRouter = async (server: FastifyZodProvider) => .optional() .describe(PKI_SUBSCRIBERS.UPDATE.extendedKeyUsages), enableAutoRenewal: z.boolean().optional().describe(PKI_SUBSCRIBERS.UPDATE.enableAutoRenewal), - autoRenewalPeriodInDays: z.number().min(1).optional().describe(PKI_SUBSCRIBERS.UPDATE.autoRenewalPeriodInDays) + autoRenewalPeriodInDays: z.number().min(1).optional().describe(PKI_SUBSCRIBERS.UPDATE.autoRenewalPeriodInDays), + properties: z + .object({ + azureTemplateType: z.string().optional().describe("Azure ADCS Certificate Template Type"), + organization: z + .string() + .trim() + .min(1) + .max(64, "Organization cannot exceed 64 characters") + .regex( + new RE2('^[^,=+<>#;\\\\"/\\r\\n\\t]*$'), + 'Organization contains invalid characters: , = + < > # ; \\ " / \\r \\n \\t' + ) + .regex( + new RE2("^[^\\\\s\\\\-_.]+.*[^\\\\s\\\\-_.]+$|^[^\\\\s\\\\-_.]{1}$"), + "Organization cannot start or end with spaces, hyphens, underscores, or periods" + ) + .optional() + .describe("Organization (O) - Maximum 64 characters, no special DN characters"), + organizationalUnit: z + .string() + .trim() + .min(1) + .max(64, "Organizational Unit cannot exceed 64 characters") + .regex( + new RE2('^[^,=+<>#;\\\\"/\\r\\n\\t]*$'), + 'Organizational Unit contains invalid characters: , = + < > # ; \\ " / \\r \\n \\t' + ) + .regex( + new RE2("^[^\\\\s\\\\-_.]+.*[^\\\\s\\\\-_.]+$|^[^\\\\s\\\\-_.]{1}$"), + "Organizational Unit cannot start or end with spaces, hyphens, underscores, or periods" + ) + .optional() + .describe("Organizational Unit (OU) - Maximum 64 characters, no special DN characters"), + country: z + .string() + .trim() + .length(2, "Country must be exactly 2 characters") + .regex(new RE2("^[A-Z]{2}$"), "Country must be exactly 2 uppercase letters") + .optional() + .describe("Country (C) - Two uppercase letter country code (e.g., US, CA, GB)"), + state: z + .string() + .trim() + .min(1) + .max(64, "State cannot exceed 64 characters") + .regex( + new RE2('^[^,=+<>#;\\\\"/\\r\\n\\t]*$'), + 'State contains invalid characters: , = + < > # ; \\ " / \\r \\n \\t' + ) + .regex( + new RE2("^[^\\\\s\\\\-_.]+.*[^\\\\s\\\\-_.]+$|^[^\\\\s\\\\-_.]{1}$"), + "State cannot start or end with spaces, hyphens, underscores, or periods" + ) + .optional() + .describe("State/Province (ST) - Maximum 64 characters, no special DN characters"), + locality: z + .string() + .trim() + .min(1) + .max(64, "Locality cannot exceed 64 characters") + .regex( + new RE2('^[^,=+<>#;\\\\"/\\r\\n\\t]*$'), + 'Locality contains invalid characters: , = + < > # ; \\ " / \\r \\n \\t' + ) + .regex( + new RE2("^[^\\\\s\\\\-_.]+.*[^\\\\s\\\\-_.]+$|^[^\\\\s\\\\-_.]{1}$"), + "Locality cannot start or end with spaces, hyphens, underscores, or periods" + ) + .optional() + .describe("Locality (L) - Maximum 64 characters, no special DN characters"), + emailAddress: z + .string() + .trim() + .email("Email Address must be a valid email format") + .min(6, "Email Address must be at least 6 characters") + .max(64, "Email Address cannot exceed 64 characters") + .optional() + .describe("Email Address - Valid email format between 6 and 64 characters") + }) + .optional() + .describe("Additional subscriber properties and subject fields") }), response: { 200: sanitizedPkiSubscriber diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index 7a5a9341f..5e7dce76e 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -108,7 +108,11 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { firstName: true, lastName: true, id: true - }).merge(UserEncryptionKeysSchema.pick({ publicKey: true })), + }) + .merge(UserEncryptionKeysSchema.pick({ publicKey: true })) + .extend({ + isOrgMembershipActive: z.boolean() + }), project: SanitizedProjectSchema.pick({ name: true, id: true }), roles: z.array( z.object({ diff --git a/backend/src/server/routes/v1/sso-router.ts b/backend/src/server/routes/v1/sso-router.ts index 0aec39f3e..366fa331d 100644 --- a/backend/src/server/routes/v1/sso-router.ts +++ b/backend/src/server/routes/v1/sso-router.ts @@ -54,6 +54,8 @@ export const registerOauthMiddlewares = (server: FastifyZodProvider) => { try { // @ts-expect-error this is because this is express type and not fastify const callbackPort = req.session.get("callbackPort"); + // @ts-expect-error this is because this is express type and not fastify + const orgSlug = req.session.get("orgSlug"); const email = profile?.emails?.[0]?.value; if (!email) @@ -67,7 +69,8 @@ export const registerOauthMiddlewares = (server: FastifyZodProvider) => { firstName: profile?.name?.givenName || "", lastName: profile?.name?.familyName || "", authMethod: AuthMethod.GOOGLE, - callbackPort + callbackPort, + orgSlug }); cb(null, { isUserCompleted, providerAuthToken }); } catch (error) { @@ -215,6 +218,7 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { schema: { querystring: z.object({ callback_port: z.string().optional(), + org_slug: z.string().optional(), is_admin_login: z .string() .optional() @@ -223,12 +227,15 @@ export const registerSsoRouter = async (server: FastifyZodProvider) => { }, preValidation: [ async (req, res) => { - const { callback_port: callbackPort, is_admin_login: isAdminLogin } = req.query; + const { callback_port: callbackPort, is_admin_login: isAdminLogin, org_slug: orgSlug } = req.query; // ensure fresh session state per login attempt await req.session.regenerate(); if (callbackPort) { req.session.set("callbackPort", callbackPort); } + if (orgSlug) { + req.session.set("orgSlug", orgSlug); + } if (isAdminLogin) { req.session.set("isAdminLogin", isAdminLogin); } diff --git a/backend/src/server/routes/v2/certificate-authority-router.ts b/backend/src/server/routes/v2/certificate-authority-router.ts index d8b434fdf..28d5527a8 100644 --- a/backend/src/server/routes/v2/certificate-authority-router.ts +++ b/backend/src/server/routes/v2/certificate-authority-router.ts @@ -6,12 +6,14 @@ import { readLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; import { AcmeCertificateAuthoritySchema } from "@app/services/certificate-authority/acme/acme-certificate-authority-schemas"; +import { AzureAdCsCertificateAuthoritySchema } from "@app/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-schemas"; import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; import { InternalCertificateAuthoritySchema } from "@app/services/certificate-authority/internal/internal-certificate-authority-schemas"; const CertificateAuthoritySchema = z.discriminatedUnion("type", [ InternalCertificateAuthoritySchema, - AcmeCertificateAuthoritySchema + AcmeCertificateAuthoritySchema, + AzureAdCsCertificateAuthoritySchema ]); export const registerCaRouter = async (server: FastifyZodProvider) => { @@ -52,19 +54,31 @@ export const registerCaRouter = async (server: FastifyZodProvider) => { req.permission ); + const azureAdCsCas = await server.services.certificateAuthority.listCertificateAuthoritiesByProjectId( + { + projectId: req.query.projectId, + type: CaType.AZURE_AD_CS + }, + req.permission + ); + await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, projectId: req.query.projectId, event: { type: EventType.GET_CAS, metadata: { - caIds: [...(internalCas ?? []).map((ca) => ca.id), ...(acmeCas ?? []).map((ca) => ca.id)] + caIds: [ + ...(internalCas ?? []).map((ca) => ca.id), + ...(acmeCas ?? []).map((ca) => ca.id), + ...(azureAdCsCas ?? []).map((ca) => ca.id) + ] } } }); return { - certificateAuthorities: [...(internalCas ?? []), ...(acmeCas ?? [])] + certificateAuthorities: [...(internalCas ?? []), ...(acmeCas ?? []), ...(azureAdCsCas ?? [])] }; } }); diff --git a/backend/src/server/routes/v2/user-router.ts b/backend/src/server/routes/v2/user-router.ts index 92b4138f2..f416fe8bb 100644 --- a/backend/src/server/routes/v2/user-router.ts +++ b/backend/src/server/routes/v2/user-router.ts @@ -18,14 +18,14 @@ export const registerUserRouter = async (server: FastifyZodProvider) => { }, schema: { body: z.object({ - username: z.string().trim() + token: z.string().trim() }), response: { 200: z.object({}) } }, handler: async (req) => { - await server.services.user.sendEmailVerificationCode(req.body.username); + await server.services.user.sendEmailVerificationCode(req.body.token); return {}; } }); diff --git a/backend/src/server/routes/v3/external-migration-router.ts b/backend/src/server/routes/v3/external-migration-router.ts index 4325692da..259a97ddb 100644 --- a/backend/src/server/routes/v3/external-migration-router.ts +++ b/backend/src/server/routes/v3/external-migration-router.ts @@ -66,7 +66,8 @@ export const registerExternalMigrationRouter = async (server: FastifyZodProvider vaultAccessToken: z.string(), vaultNamespace: z.string().trim().optional(), vaultUrl: z.string(), - mappingType: z.nativeEnum(VaultMappingType) + mappingType: z.nativeEnum(VaultMappingType), + gatewayId: z.string().optional() }) }, onRequest: verifyAuth([AuthMode.JWT]), diff --git a/backend/src/server/routes/v3/secret-router.ts b/backend/src/server/routes/v3/secret-router.ts index 55fc094b7..ce0d4f188 100644 --- a/backend/src/server/routes/v3/secret-router.ts +++ b/backend/src/server/routes/v3/secret-router.ts @@ -419,6 +419,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { 200: z.object({ secret: secretRawSchema.extend({ secretValueHidden: z.boolean(), + secretPath: z.string(), tags: SanitizedTagSchema.array().optional(), secretMetadata: ResourceMetadataSchema.optional() }) diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index 263cbdd9b..76dcdd5f0 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -8,6 +8,7 @@ export enum AppConnection { AzureAppConfiguration = "azure-app-configuration", AzureClientSecrets = "azure-client-secrets", AzureDevOps = "azure-devops", + AzureADCS = "azure-adcs", Humanitec = "humanitec", TerraformCloud = "terraform-cloud", Vercel = "vercel", diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index 9ffc358b6..94de51f1e 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -31,6 +31,11 @@ import { } from "./app-connection-types"; import { Auth0ConnectionMethod, getAuth0ConnectionListItem, validateAuth0ConnectionCredentials } from "./auth0"; import { AwsConnectionMethod, getAwsConnectionListItem, validateAwsConnectionCredentials } from "./aws"; +import { AzureADCSConnectionMethod } from "./azure-adcs"; +import { + getAzureADCSConnectionListItem, + validateAzureADCSConnectionCredentials +} from "./azure-adcs/azure-adcs-connection-fns"; import { AzureAppConfigurationConnectionMethod, getAzureAppConfigurationConnectionListItem, @@ -136,6 +141,7 @@ export const listAppConnectionOptions = () => { getAzureKeyVaultConnectionListItem(), getAzureAppConfigurationConnectionListItem(), getAzureDevopsConnectionListItem(), + getAzureADCSConnectionListItem(), getDatabricksConnectionListItem(), getHumanitecConnectionListItem(), getTerraformCloudConnectionListItem(), @@ -227,6 +233,7 @@ export const validateAppConnectionCredentials = async ( [AppConnection.AzureClientSecrets]: validateAzureClientSecretsConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.AzureDevOps]: validateAzureDevOpsConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.AzureADCS]: validateAzureADCSConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Humanitec]: validateHumanitecConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Postgres]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.MsSql]: validateSqlConnectionCredentials as TAppConnectionCredentialsValidator, @@ -300,6 +307,7 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => case MsSqlConnectionMethod.UsernameAndPassword: case MySqlConnectionMethod.UsernameAndPassword: case OracleDBConnectionMethod.UsernameAndPassword: + case AzureADCSConnectionMethod.UsernamePassword: return "Username & Password"; case WindmillConnectionMethod.AccessToken: case HCVaultConnectionMethod.AccessToken: @@ -357,6 +365,7 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.AzureKeyVault]: platformManagedCredentialsNotSupported, [AppConnection.AzureAppConfiguration]: platformManagedCredentialsNotSupported, [AppConnection.AzureDevOps]: platformManagedCredentialsNotSupported, + [AppConnection.AzureADCS]: platformManagedCredentialsNotSupported, [AppConnection.Humanitec]: platformManagedCredentialsNotSupported, [AppConnection.Postgres]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform, [AppConnection.MsSql]: transferSqlConnectionCredentialsToPlatform as TAppConnectionTransitionCredentialsToPlatform, diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index 155b68ce1..a2ce02669 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -9,6 +9,7 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.AzureAppConfiguration]: "Azure App Configuration", [AppConnection.AzureClientSecrets]: "Azure Client Secrets", [AppConnection.AzureDevOps]: "Azure DevOps", + [AppConnection.AzureADCS]: "Azure ADCS", [AppConnection.Databricks]: "Databricks", [AppConnection.Humanitec]: "Humanitec", [AppConnection.TerraformCloud]: "Terraform Cloud", @@ -49,6 +50,7 @@ export const APP_CONNECTION_PLAN_MAP: Record; +} + +interface AxiosNtlmResponse { + status: number; + data: string; + headers: unknown; +} + +// Types for credential parsing +interface ParsedCredentials { + domain: string; + username: string; + fullUsername: string; // domain\username format +} + +// Helper function to parse and normalize credentials for Windows authentication +const parseCredentials = (inputUsername: string): ParsedCredentials => { + // Ensure inputUsername is a string + if (typeof inputUsername !== "string" || !inputUsername.trim()) { + throw new BadRequestError({ + message: "Username must be a non-empty string" + }); + } + + let domain = ""; + let username = ""; + let fullUsername = ""; + + if (inputUsername.includes("\\")) { + // Already in domain\username format + const parts = inputUsername.split("\\"); + if (parts.length === 2) { + [domain, username] = parts; + fullUsername = inputUsername; + } else { + throw new BadRequestError({ + message: "Invalid domain\\username format. Expected format: DOMAIN\\username" + }); + } + } else if (inputUsername.includes("@")) { + // UPN format: user@domain.com + const [user, domainPart] = inputUsername.split("@"); + if (!user || !domainPart) { + throw new BadRequestError({ + message: "Invalid UPN format. Expected format: user@domain.com" + }); + } + + username = user; + // Extract NetBIOS name from FQDN + domain = domainPart.split(".")[0].toUpperCase(); + fullUsername = `${domain}\\${username}`; + } else { + // Plain username - assume local account or current domain + username = inputUsername; + domain = ""; + fullUsername = inputUsername; + } + + return { domain, username, fullUsername }; +}; + +// Helper to normalize URL +const normalizeAdcsUrl = (url: string): string => { + let normalizedUrl = url.trim(); + + // Remove trailing slash + normalizedUrl = normalizedUrl.replace(/\/$/, ""); + + // Ensure HTTPS protocol + if (normalizedUrl.startsWith("http://")) { + normalizedUrl = normalizedUrl.replace("http://", "https://"); + } else if (!normalizedUrl.startsWith("https://")) { + normalizedUrl = `https://${normalizedUrl}`; + } + + return normalizedUrl; +}; + +// NTLM request wrapper +const createHttpsAgent = (sslRejectUnauthorized: boolean, sslCertificate?: string): https.Agent => { + const agentOptions: https.AgentOptions = { + rejectUnauthorized: sslRejectUnauthorized, + keepAlive: true, // axios-ntlm needs keepAlive for NTLM handshake + ca: sslCertificate ? [sslCertificate.trim()] : undefined, + // Disable hostname verification as Microsoft servers by default use local IPs for certificates + // which may not match the hostname used to connect + checkServerIdentity: () => undefined + }; + + return new https.Agent(agentOptions); +}; + +const axiosNtlmRequest = async (config: AxiosNtlmConfig): Promise => { + const method = config.method || "GET"; + + const credentials = { + username: config.ntlm.username, + password: config.ntlm.password, + domain: config.ntlm.domain || "", + workstation: "" + }; + + const axiosConfig = { + httpsAgent: config.httpsAgent, + timeout: 30000 + }; + + const client = NtlmClient(credentials, axiosConfig); + + const requestOptions: { url: string; method: string; data?: string; headers?: Record } = { + url: config.url, + method + }; + + if (config.data) { + requestOptions.data = config.data; + } + + if (config.headers) { + requestOptions.headers = config.headers; + } + + const response = await client(requestOptions); + + return { + status: response.status, + data: response.data, + headers: response.headers + }; +}; + +// Test ADCS connectivity and authentication using NTLM +const testAdcsConnection = async ( + credentials: ParsedCredentials, + password: string, + baseUrl: string, + sslRejectUnauthorized: boolean = true, + sslCertificate?: string +): Promise => { + // Test endpoints in order of preference + const testEndpoints = [ + "/certsrv/certrqus.asp", // Certificate request status (most reliable) + "/certsrv/certfnsh.asp", // Certificate finalization + "/certsrv/default.asp", // Main ADCS page + "/certsrv/" // Root certsrv + ]; + + for (const endpoint of testEndpoints) { + try { + const testUrl = `${baseUrl}${endpoint}`; + + const shouldRejectUnauthorized = sslRejectUnauthorized; + + const httpsAgent = createHttpsAgent(shouldRejectUnauthorized, sslCertificate); + + const response = await axiosNtlmRequest({ + url: testUrl, + method: "GET", + httpsAgent, + ntlm: { + domain: credentials.domain, + username: credentials.username, + password + } + }); + + // Check if we got a successful response + if (response.status === 200) { + const responseText = response.data; + + // Verify this is actually an ADCS server by checking content + const adcsIndicators = [ + "Microsoft Active Directory Certificate Services", + "Certificate Services", + "Request a certificate", + "certsrv", + "Certificate Template", + "Web Enrollment" + ]; + + const isAdcsServer = adcsIndicators.some((indicator) => + responseText.toLowerCase().includes(indicator.toLowerCase()) + ); + + if (isAdcsServer) { + // Successfully authenticated and confirmed ADCS + return true; + } + } + + if (response.status === 401) { + throw new BadRequestError({ + message: "Authentication failed. Please verify your credentials are correct." + }); + } + + if (response.status === 403) { + throw new BadRequestError({ + message: "Access denied. Your account may not have permission to access ADCS web enrollment." + }); + } + } catch (error) { + if (error instanceof BadRequestError) { + throw error; + } + + // Handle network and connection errors + if (error instanceof Error) { + if (error.message.includes("ENOTFOUND")) { + throw new BadRequestError({ + message: "Cannot resolve ADCS server hostname. Please verify the URL is correct." + }); + } + if (error.message.includes("ECONNREFUSED")) { + throw new BadRequestError({ + message: "Connection refused by ADCS server. Please verify the server is running and accessible." + }); + } + if (error.message.includes("ETIMEDOUT") || error.message.includes("timeout")) { + throw new BadRequestError({ + message: "Connection timeout. Please verify the server is accessible and not blocked by firewall." + }); + } + if (error.message.includes("certificate") || error.message.includes("SSL") || error.message.includes("TLS")) { + throw new BadRequestError({ + message: `SSL/TLS certificate error: ${error.message}. This may indicate a certificate verification failure.` + }); + } + if (error.message.includes("DEPTH_ZERO_SELF_SIGNED_CERT")) { + throw new BadRequestError({ + message: + "Self-signed certificate detected. Either provide the server's certificate or set 'sslRejectUnauthorized' to false." + }); + } + if (error.message.includes("UNABLE_TO_VERIFY_LEAF_SIGNATURE")) { + throw new BadRequestError({ + message: "Unable to verify certificate signature. Please provide the correct CA certificate." + }); + } + } + + // Continue to next endpoint for other errors + continue; + } + } + + // If we get here, no endpoint worked + throw new BadRequestError({ + message: "Could not connect to ADCS server. Please verify the server URL and that Web Enrollment is enabled." + }); +}; + +// Create authenticated NTLM client for ADCS operations +const createNtlmClient = ( + username: string, + password: string, + baseUrl: string, + sslRejectUnauthorized: boolean = true, + sslCertificate?: string +) => { + const parsedCredentials = parseCredentials(username); + const normalizedUrl = normalizeAdcsUrl(baseUrl); + + return { + get: async (endpoint: string, additionalHeaders: Record = {}) => { + const shouldRejectUnauthorized = sslRejectUnauthorized; + + const httpsAgent = createHttpsAgent(shouldRejectUnauthorized, sslCertificate); + + return axiosNtlmRequest({ + url: `${normalizedUrl}${endpoint}`, + method: "GET", + httpsAgent, + headers: additionalHeaders, + ntlm: { + domain: parsedCredentials.domain, + username: parsedCredentials.username, + password + } + }); + }, + post: async (endpoint: string, body: string, additionalHeaders: Record = {}) => { + const shouldRejectUnauthorized = sslRejectUnauthorized; + + const httpsAgent = createHttpsAgent(shouldRejectUnauthorized, sslCertificate); + + return axiosNtlmRequest({ + url: `${normalizedUrl}${endpoint}`, + method: "POST", + httpsAgent, + data: body, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + ...additionalHeaders + }, + ntlm: { + domain: parsedCredentials.domain, + username: parsedCredentials.username, + password + } + }); + }, + baseUrl: normalizedUrl, + credentials: parsedCredentials + }; +}; + +export const getAzureADCSConnectionCredentials = async ( + connectionId: string, + appConnectionDAL: Pick, + kmsService: Pick +) => { + const appConnection = await appConnectionDAL.findById(connectionId); + + if (!appConnection) { + throw new NotFoundError({ message: `Connection with ID '${connectionId}' not found` }); + } + + if (appConnection.app !== AppConnection.AzureADCS) { + throw new BadRequestError({ message: `Connection with ID '${connectionId}' is not an Azure ADCS connection` }); + } + + switch (appConnection.method) { + case AzureADCSConnectionMethod.UsernamePassword: + const credentials = (await decryptAppConnectionCredentials({ + orgId: appConnection.orgId, + kmsService, + encryptedCredentials: appConnection.encryptedCredentials + })) as { + username: string; + password: string; + adcsUrl: string; + sslRejectUnauthorized?: boolean; + sslCertificate?: string; + }; + + return { + username: credentials.username, + password: credentials.password, + adcsUrl: credentials.adcsUrl, + sslRejectUnauthorized: credentials.sslRejectUnauthorized ?? true, + sslCertificate: credentials.sslCertificate + }; + + default: + throw new BadRequestError({ + message: `Unsupported Azure ADCS connection method: ${appConnection.method}` + }); + } +}; + +export const validateAzureADCSConnectionCredentials = async (appConnection: TAzureADCSConnectionConfig) => { + const { credentials } = appConnection; + + try { + // Parse and validate credentials + const parsedCredentials = parseCredentials(credentials.username); + const normalizedUrl = normalizeAdcsUrl(credentials.adcsUrl); + + // Validate URL to prevent DNS manipulation attacks and SSRF + await blockLocalAndPrivateIpAddresses(normalizedUrl); + + // Test the connection using NTLM + await testAdcsConnection( + parsedCredentials, + credentials.password, + normalizedUrl, + credentials.sslRejectUnauthorized ?? true, + credentials.sslCertificate + ); + + // If we get here, authentication was successful + return { + username: credentials.username, + password: credentials.password, + adcsUrl: credentials.adcsUrl, + sslRejectUnauthorized: credentials.sslRejectUnauthorized ?? true, + sslCertificate: credentials.sslCertificate + }; + } catch (error) { + if (error instanceof BadRequestError) { + throw error; + } + + // Handle unexpected errors + let errorMessage = "Unable to validate ADCS connection."; + if (error instanceof Error) { + if (error.message.includes("401") || error.message.includes("Unauthorized")) { + errorMessage = "NTLM authentication failed. Please verify your username, password, and domain are correct."; + } else if (error.message.includes("ENOTFOUND") || error.message.includes("ECONNREFUSED")) { + errorMessage = "Cannot connect to the ADCS server. Please verify the server URL is correct and accessible."; + } else if (error.message.includes("timeout")) { + errorMessage = "Connection to ADCS server timed out. Please verify the server is accessible."; + } else if ( + error.message.includes("certificate") || + error.message.includes("SSL") || + error.message.includes("TLS") || + error.message.includes("DEPTH_ZERO_SELF_SIGNED_CERT") || + error.message.includes("UNABLE_TO_VERIFY_LEAF_SIGNATURE") + ) { + errorMessage = `SSL/TLS certificate error: ${error.message}. The server certificate may be self-signed or the CA certificate may be incorrect.`; + } + } + + throw new BadRequestError({ + message: `Failed to validate Azure ADCS connection: ${errorMessage} Details: ${ + error instanceof Error ? error.message : "Unknown error" + }` + }); + } +}; + +export const getAzureADCSConnectionListItem = () => ({ + name: "Azure ADCS" as const, + app: AppConnection.AzureADCS as const, + methods: [AzureADCSConnectionMethod.UsernamePassword] as [AzureADCSConnectionMethod.UsernamePassword] +}); + +// Export helper functions for use in certificate ordering +export const createAdcsHttpClient = ( + username: string, + password: string, + baseUrl: string, + sslRejectUnauthorized: boolean = true, + sslCertificate?: string +) => { + return createNtlmClient(username, password, baseUrl, sslRejectUnauthorized, sslCertificate); +}; diff --git a/backend/src/services/app-connection/azure-adcs/azure-adcs-connection-schemas.ts b/backend/src/services/app-connection/azure-adcs/azure-adcs-connection-schemas.ts new file mode 100644 index 000000000..a43bb5954 --- /dev/null +++ b/backend/src/services/app-connection/azure-adcs/azure-adcs-connection-schemas.ts @@ -0,0 +1,88 @@ +import z from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { AzureADCSConnectionMethod } from "./azure-adcs-connection-enums"; + +export const AzureADCSUsernamePasswordCredentialsSchema = z.object({ + adcsUrl: z + .string() + .trim() + .min(1, "ADCS URL required") + .max(255) + .refine((value) => value.startsWith("https://"), "ADCS URL must use HTTPS") + .describe(AppConnections.CREDENTIALS.AZURE_ADCS.adcsUrl), + username: z + .string() + .trim() + .min(1, "Username required") + .max(255) + .describe(AppConnections.CREDENTIALS.AZURE_ADCS.username), + password: z + .string() + .trim() + .min(1, "Password required") + .max(255) + .describe(AppConnections.CREDENTIALS.AZURE_ADCS.password), + sslRejectUnauthorized: z.boolean().optional().describe(AppConnections.CREDENTIALS.AZURE_ADCS.sslRejectUnauthorized), + sslCertificate: z + .string() + .trim() + .transform((value) => value || undefined) + .optional() + .describe(AppConnections.CREDENTIALS.AZURE_ADCS.sslCertificate) +}); + +const BaseAzureADCSConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.AzureADCS) }); + +export const AzureADCSConnectionSchema = BaseAzureADCSConnectionSchema.extend({ + method: z.literal(AzureADCSConnectionMethod.UsernamePassword), + credentials: AzureADCSUsernamePasswordCredentialsSchema +}); + +export const SanitizedAzureADCSConnectionSchema = z.discriminatedUnion("method", [ + BaseAzureADCSConnectionSchema.extend({ + method: z.literal(AzureADCSConnectionMethod.UsernamePassword), + credentials: AzureADCSUsernamePasswordCredentialsSchema.pick({ + username: true, + adcsUrl: true, + sslRejectUnauthorized: true, + sslCertificate: true + }) + }) +]); + +export const ValidateAzureADCSConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z + .literal(AzureADCSConnectionMethod.UsernamePassword) + .describe(AppConnections.CREATE(AppConnection.AzureADCS).method), + credentials: AzureADCSUsernamePasswordCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.AzureADCS).credentials + ) + }) +]); + +export const CreateAzureADCSConnectionSchema = ValidateAzureADCSConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.AzureADCS) +); + +export const UpdateAzureADCSConnectionSchema = z + .object({ + credentials: AzureADCSUsernamePasswordCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.AzureADCS).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.AzureADCS)); + +export const AzureADCSConnectionListItemSchema = z.object({ + name: z.literal("Azure ADCS"), + app: z.literal(AppConnection.AzureADCS), + methods: z.nativeEnum(AzureADCSConnectionMethod).array() +}); diff --git a/backend/src/services/app-connection/azure-adcs/azure-adcs-connection-types.ts b/backend/src/services/app-connection/azure-adcs/azure-adcs-connection-types.ts new file mode 100644 index 000000000..051d13c0d --- /dev/null +++ b/backend/src/services/app-connection/azure-adcs/azure-adcs-connection-types.ts @@ -0,0 +1,23 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + AzureADCSConnectionSchema, + CreateAzureADCSConnectionSchema, + ValidateAzureADCSConnectionCredentialsSchema +} from "./azure-adcs-connection-schemas"; + +export type TAzureADCSConnection = z.infer; + +export type TAzureADCSConnectionInput = z.infer & { + app: AppConnection.AzureADCS; +}; + +export type TValidateAzureADCSConnectionCredentialsSchema = typeof ValidateAzureADCSConnectionCredentialsSchema; + +export type TAzureADCSConnectionConfig = DiscriminativePick< + TAzureADCSConnectionInput, + "method" | "app" | "credentials" +>; diff --git a/backend/src/services/app-connection/azure-adcs/index.ts b/backend/src/services/app-connection/azure-adcs/index.ts new file mode 100644 index 000000000..3cefd9090 --- /dev/null +++ b/backend/src/services/app-connection/azure-adcs/index.ts @@ -0,0 +1,4 @@ +export * from "./azure-adcs-connection-enums"; +export * from "./azure-adcs-connection-fns"; +export * from "./azure-adcs-connection-schemas"; +export * from "./azure-adcs-connection-types"; diff --git a/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-fns.ts b/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-fns.ts index 80e65a821..d173a47b5 100644 --- a/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-fns.ts +++ b/backend/src/services/app-connection/azure-app-configuration/azure-app-configuration-connection-fns.ts @@ -70,7 +70,7 @@ export const validateAzureAppConfigurationConnectionCredentials = async ( tokenError = e; } else { throw new BadRequestError({ - message: `Unable to validate connection: verify credentials` + message: "Unable to validate connection: verify credentials" }); } } diff --git a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-fns.ts b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-fns.ts index 41dbb4392..2614cfd12 100644 --- a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-fns.ts +++ b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-fns.ts @@ -186,7 +186,7 @@ export const validateAzureClientSecretsConnectionCredentials = async (config: TA tokenError = e; } else { throw new BadRequestError({ - message: `Unable to validate connection: verify credentials` + message: "Unable to validate connection: verify credentials" }); } } diff --git a/backend/src/services/app-connection/azure-devops/azure-devops-fns.ts b/backend/src/services/app-connection/azure-devops/azure-devops-fns.ts index 39a330360..a3a9f10bd 100644 --- a/backend/src/services/app-connection/azure-devops/azure-devops-fns.ts +++ b/backend/src/services/app-connection/azure-devops/azure-devops-fns.ts @@ -204,7 +204,7 @@ export const validateAzureDevOpsConnectionCredentials = async (config: TAzureDev tokenError = e; } else { throw new BadRequestError({ - message: `Unable to validate connection: verify credentials` + message: "Unable to validate connection: verify credentials" }); } } diff --git a/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-fns.ts b/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-fns.ts index 1f88488c0..d6a260050 100644 --- a/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-fns.ts +++ b/backend/src/services/app-connection/azure-key-vault/azure-key-vault-connection-fns.ts @@ -186,7 +186,7 @@ export const validateAzureKeyVaultConnectionCredentials = async (config: TAzureK tokenError = e; } else { throw new BadRequestError({ - message: `Unable to validate connection: verify credentials` + message: "Unable to validate connection: verify credentials" }); } } diff --git a/backend/src/services/app-connection/camunda/camunda-connection-fns.ts b/backend/src/services/app-connection/camunda/camunda-connection-fns.ts index 90d9744b8..91a033c0e 100644 --- a/backend/src/services/app-connection/camunda/camunda-connection-fns.ts +++ b/backend/src/services/app-connection/camunda/camunda-connection-fns.ts @@ -82,7 +82,7 @@ export const validateCamundaConnectionCredentials = async (appConnection: TCamun }; } catch (e: unknown) { throw new BadRequestError({ - message: `Unable to validate connection: verify credentials` + message: "Unable to validate connection: verify credentials" }); } }; diff --git a/backend/src/services/app-connection/checkly/checkly-connection-public-client.ts b/backend/src/services/app-connection/checkly/checkly-connection-public-client.ts index 4e5db231f..35279d007 100644 --- a/backend/src/services/app-connection/checkly/checkly-connection-public-client.ts +++ b/backend/src/services/app-connection/checkly/checkly-connection-public-client.ts @@ -4,6 +4,7 @@ import { AxiosInstance, AxiosRequestConfig, AxiosResponse, HttpStatusCode, isAxi import { createRequestClient } from "@app/lib/config/request"; import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; +import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; import { ChecklyConnectionMethod } from "./checkly-connection-constants"; import { TChecklyAccount, TChecklyConnectionConfig, TChecklyVariable } from "./checkly-connection-types"; @@ -181,6 +182,122 @@ class ChecklyPublicClient { return res; } + + async getCheckGroups(connection: TChecklyConnectionConfig, accountId: string, limit = 50, page = 1) { + const res = await this.send<{ id: number; name: string }[]>(connection, { + accountId, + method: "GET", + url: `/v1/check-groups`, + params: { limit, page } + }); + + return res?.map((group) => ({ + id: group.id.toString(), + name: group.name + })); + } + + async getCheckGroup(connection: TChecklyConnectionConfig, accountId: string, groupId: string) { + try { + type ChecklyGroupResponse = { + id: number; + name: string; + environmentVariables: Array<{ + key: string; + value: string; + locked: boolean; + }>; + }; + + const res = await this.send(connection, { + accountId, + method: "GET", + url: `/v1/check-groups/${groupId}` + }); + + if (!res) return null; + + return { + id: res.id.toString(), + name: res.name, + environmentVariables: res.environmentVariables + }; + } catch (error) { + if (isAxiosError(error) && error.response?.status === HttpStatusCode.NotFound) { + return null; + } + throw error; + } + } + + async updateCheckGroupEnvironmentVariables( + connection: TChecklyConnectionConfig, + accountId: string, + groupId: string, + environmentVariables: Array<{ key: string; value: string; locked?: boolean }> + ) { + if (environmentVariables.length > 50) { + throw new SecretSyncError({ + message: "Checkly does not support syncing more than 50 variables to Check Group", + shouldRetry: false + }); + } + + const apiVariables = environmentVariables.map((v) => ({ + key: v.key, + value: v.value, + locked: v.locked ?? false, + secret: true + })); + + const group = await this.getCheckGroup(connection, accountId, groupId); + + await this.send(connection, { + accountId, + method: "PUT", + url: `/v2/check-groups/${groupId}`, + data: { name: group?.name, environmentVariables: apiVariables } + }); + + return this.getCheckGroup(connection, accountId, groupId); + } + + async getCheckGroupEnvironmentVariables(connection: TChecklyConnectionConfig, accountId: string, groupId: string) { + const group = await this.getCheckGroup(connection, accountId, groupId); + return group?.environmentVariables || []; + } + + async upsertCheckGroupEnvironmentVariables( + connection: TChecklyConnectionConfig, + accountId: string, + groupId: string, + variables: Array<{ key: string; value: string; locked?: boolean }> + ) { + const existingVars = await this.getCheckGroupEnvironmentVariables(connection, accountId, groupId); + const varMap = new Map(existingVars.map((v) => [v.key, v])); + + for (const newVar of variables) { + varMap.set(newVar.key, { + key: newVar.key, + value: newVar.value, + locked: newVar.locked ?? false + }); + } + + return this.updateCheckGroupEnvironmentVariables(connection, accountId, groupId, Array.from(varMap.values())); + } + + async deleteCheckGroupEnvironmentVariable( + connection: TChecklyConnectionConfig, + accountId: string, + groupId: string, + variableKey: string + ) { + const existingVars = await this.getCheckGroupEnvironmentVariables(connection, accountId, groupId); + const filteredVars = existingVars.filter((v) => v.key !== variableKey); + + return this.updateCheckGroupEnvironmentVariables(connection, accountId, groupId, filteredVars); + } } export const ChecklyPublicAPI = new ChecklyPublicClient(); diff --git a/backend/src/services/app-connection/checkly/checkly-connection-service.ts b/backend/src/services/app-connection/checkly/checkly-connection-service.ts index c3598320f..1312b4590 100644 --- a/backend/src/services/app-connection/checkly/checkly-connection-service.ts +++ b/backend/src/services/app-connection/checkly/checkly-connection-service.ts @@ -24,7 +24,19 @@ export const checklyConnectionService = (getAppConnection: TGetAppConnectionFunc } }; + const listGroups = async (connectionId: string, accountId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.Checkly, connectionId, actor); + try { + const groups = await ChecklyPublicAPI.getCheckGroups(appConnection, accountId); + return groups!; + } catch (error) { + logger.error(error, "Failed to list accounts on Checkly"); + return []; + } + }; + return { - listAccounts + listAccounts, + listGroups }; }; diff --git a/backend/src/services/app-connection/checkly/checkly-connection-types.ts b/backend/src/services/app-connection/checkly/checkly-connection-types.ts index e8bb242ba..195d319d6 100644 --- a/backend/src/services/app-connection/checkly/checkly-connection-types.ts +++ b/backend/src/services/app-connection/checkly/checkly-connection-types.ts @@ -33,3 +33,15 @@ export type TChecklyAccount = { name: string; runtimeId: string; }; + +export type TChecklyGroupEnvironmentVariable = { + key: string; + value: string; + locked: boolean; +}; + +export type TChecklyGroup = { + id: string; + name: string; + environmentVariables?: TChecklyGroupEnvironmentVariable[]; +}; diff --git a/backend/src/services/app-connection/databricks/databricks-connection-fns.ts b/backend/src/services/app-connection/databricks/databricks-connection-fns.ts index a35cc4aec..8912ad936 100644 --- a/backend/src/services/app-connection/databricks/databricks-connection-fns.ts +++ b/backend/src/services/app-connection/databricks/databricks-connection-fns.ts @@ -89,7 +89,7 @@ export const validateDatabricksConnectionCredentials = async (appConnection: TDa }; } catch (e: unknown) { throw new BadRequestError({ - message: `Unable to validate connection: verify credentials` + message: "Unable to validate connection: verify credentials" }); } }; diff --git a/backend/src/services/app-connection/github-radar/github-radar-connection-fns.ts b/backend/src/services/app-connection/github-radar/github-radar-connection-fns.ts index fba852a0a..7349f89a6 100644 --- a/backend/src/services/app-connection/github-radar/github-radar-connection-fns.ts +++ b/backend/src/services/app-connection/github-radar/github-radar-connection-fns.ts @@ -114,7 +114,7 @@ export const validateGitHubRadarConnectionCredentials = async (config: TGitHubRa } throw new BadRequestError({ - message: `Unable to validate connection: verify credentials` + message: "Unable to validate connection: verify credentials" }); } diff --git a/backend/src/services/app-connection/github/github-connection-fns.ts b/backend/src/services/app-connection/github/github-connection-fns.ts index a71036d82..05e6fdda3 100644 --- a/backend/src/services/app-connection/github/github-connection-fns.ts +++ b/backend/src/services/app-connection/github/github-connection-fns.ts @@ -1,5 +1,3 @@ -import { createAppAuth } from "@octokit/auth-app"; -import { request } from "@octokit/request"; import { AxiosError, AxiosRequestConfig, AxiosResponse } from "axios"; import https from "https"; import RE2 from "re2"; @@ -8,6 +6,7 @@ import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { getConfig } from "@app/lib/config/env"; import { request as httpRequest } from "@app/lib/config/request"; +import { crypto } from "@app/lib/crypto"; import { BadRequestError, ForbiddenRequestError, InternalServerError } from "@app/lib/errors"; import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; import { logger } from "@app/lib/logger"; @@ -114,10 +113,13 @@ export const requestWithGitHubGateway = async ( ); }; -export const getGitHubAppAuthToken = async (appConnection: TGitHubConnection) => { +export const getGitHubAppAuthToken = async ( + appConnection: TGitHubConnection, + gatewayService: Pick +) => { const appCfg = getConfig(); const appId = appCfg.INF_APP_CONNECTION_GITHUB_APP_ID; - const appPrivateKey = appCfg.INF_APP_CONNECTION_GITHUB_APP_PRIVATE_KEY; + let appPrivateKey = appCfg.INF_APP_CONNECTION_GITHUB_APP_PRIVATE_KEY; if (!appId || !appPrivateKey) { throw new InternalServerError({ @@ -125,21 +127,42 @@ export const getGitHubAppAuthToken = async (appConnection: TGitHubConnection) => }); } + appPrivateKey = appPrivateKey + .split("\n") + .map((line) => line.trim()) + .join("\n"); + if (appConnection.method !== GitHubConnectionMethod.App) { throw new InternalServerError({ message: "Cannot generate GitHub App token for non-app connection" }); } - const appAuth = createAppAuth({ - appId, - privateKey: appPrivateKey, - installationId: appConnection.credentials.installationId, - request: request.defaults({ - baseUrl: `https://${await getGitHubInstanceApiUrl(appConnection)}` - }) - }); + const now = Math.floor(Date.now() / 1000); + const payload = { + iat: now, + exp: now + 5 * 60, + iss: appId + }; - const { token } = await appAuth({ type: "installation" }); - return token; + const appJwt = crypto.jwt().sign(payload, appPrivateKey, { algorithm: "RS256" }); + + const apiBaseUrl = await getGitHubInstanceApiUrl(appConnection); + const { installationId } = appConnection.credentials; + + const response = await requestWithGitHubGateway<{ token: string; expires_at: string }>( + appConnection, + gatewayService, + { + url: `https://${apiBaseUrl}/app/installations/${installationId}/access_tokens`, + method: "POST", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${appJwt}`, + "X-GitHub-Api-Version": "2022-11-28" + } + } + ); + + return response.data.token; }; const parseGitHubLinkHeader = (linkHeader: string | undefined): Record => { @@ -174,7 +197,9 @@ export const makePaginatedGitHubRequest = async ( const { credentials, method } = appConnection; const token = - method === GitHubConnectionMethod.OAuth ? credentials.accessToken : await getGitHubAppAuthToken(appConnection); + method === GitHubConnectionMethod.OAuth + ? credentials.accessToken + : await getGitHubAppAuthToken(appConnection, gatewayService); const baseUrl = `https://${await getGitHubInstanceApiUrl(appConnection)}${path}`; const initialUrlObj = new URL(baseUrl); @@ -422,7 +447,7 @@ export const validateGitHubConnectionCredentials = async ( } throw new BadRequestError({ - message: `Unable to validate connection: verify credentials` + message: "Unable to validate connection: verify credentials" }); } diff --git a/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts b/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts index 48695a2a5..46a59bcec 100644 --- a/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts +++ b/backend/src/services/app-connection/hc-vault/hc-vault-connection-fns.ts @@ -1,18 +1,18 @@ -import { AxiosError } from "axios"; +import { AxiosError, AxiosRequestConfig, AxiosResponse } from "axios"; +import https from "https"; +import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic-secret-fns"; +import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { request } from "@app/lib/config/request"; import { BadRequestError } from "@app/lib/errors"; import { removeTrailingSlash } from "@app/lib/fn"; +import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; +import { logger } from "@app/lib/logger"; import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { HCVaultConnectionMethod } from "./hc-vault-connection-enums"; -import { - THCVaultConnection, - THCVaultConnectionConfig, - THCVaultMountResponse, - TValidateHCVaultConnectionCredentials -} from "./hc-vault-connection-types"; +import { THCVaultConnection, THCVaultConnectionConfig, THCVaultMountResponse } from "./hc-vault-connection-types"; export const getHCVaultInstanceUrl = async (config: THCVaultConnectionConfig) => { const instanceUrl = removeTrailingSlash(config.credentials.instanceUrl); @@ -37,7 +37,78 @@ type TokenRespData = { }; }; -export const getHCVaultAccessToken = async (connection: TValidateHCVaultConnectionCredentials) => { +export const requestWithHCVaultGateway = async ( + appConnection: { gatewayId?: string | null }, + gatewayService: Pick, + requestConfig: AxiosRequestConfig +): Promise> => { + const { gatewayId } = appConnection; + + // If gateway isn't set up, don't proxy request + if (!gatewayId) { + return request.request(requestConfig); + } + + const url = new URL(requestConfig.url as string); + + await blockLocalAndPrivateIpAddresses(url.toString()); + + const [targetHost] = await verifyHostInputValidity(url.hostname, true); + const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(gatewayId); + const [relayHost, relayPort] = relayDetails.relayAddress.split(":"); + + return withGatewayProxy( + async (proxyPort) => { + const httpsAgent = new https.Agent({ + servername: targetHost + }); + + url.protocol = "https:"; + url.host = `localhost:${proxyPort}`; + + const finalRequestConfig: AxiosRequestConfig = { + ...requestConfig, + url: url.toString(), + httpsAgent, + headers: { + ...requestConfig.headers, + Host: targetHost + } + }; + + try { + return await request.request(finalRequestConfig); + } catch (error) { + if (error instanceof AxiosError) { + logger.error( + { message: error.message, data: (error.response as undefined | { data: unknown })?.data }, + "Error during HashiCorp Vault gateway request:" + ); + } + throw error; + } + }, + { + protocol: GatewayProxyProtocol.Tcp, + targetHost, + targetPort: url.port ? Number(url.port) : 8200, // 8200 is the default port for Vault self-hosted/dedicated + relayHost, + relayPort: Number(relayPort), + identityId: relayDetails.identityId, + orgId: relayDetails.orgId, + tlsOptions: { + ca: relayDetails.certChain, + cert: relayDetails.certificate, + key: relayDetails.privateKey.toString() + } + } + ); +}; + +export const getHCVaultAccessToken = async ( + connection: THCVaultConnection, + gatewayService: Pick +) => { // Return access token directly if not using AppRole method if (connection.method !== HCVaultConnectionMethod.AppRole) { return connection.credentials.accessToken; @@ -46,16 +117,16 @@ export const getHCVaultAccessToken = async (connection: TValidateHCVaultConnecti // Generate temporary token for AppRole method try { const { instanceUrl, roleId, secretId } = connection.credentials; - const tokenResp = await request.post( - `${removeTrailingSlash(instanceUrl)}/v1/auth/approle/login`, - { role_id: roleId, secret_id: secretId }, - { - headers: { - "Content-Type": "application/json", - ...(connection.credentials.namespace ? { "X-Vault-Namespace": connection.credentials.namespace } : {}) - } - } - ); + + const tokenResp = await requestWithHCVaultGateway(connection, gatewayService, { + url: `${removeTrailingSlash(instanceUrl)}/v1/auth/approle/login`, + method: "POST", + headers: { + "Content-Type": "application/json", + ...(connection.credentials.namespace ? { "X-Vault-Namespace": connection.credentials.namespace } : {}) + }, + data: { role_id: roleId, secret_id: secretId } + }); if (tokenResp.status !== 200) { throw new BadRequestError({ @@ -71,38 +142,55 @@ export const getHCVaultAccessToken = async (connection: TValidateHCVaultConnecti } }; -export const validateHCVaultConnectionCredentials = async (config: THCVaultConnectionConfig) => { - const instanceUrl = await getHCVaultInstanceUrl(config); +export const validateHCVaultConnectionCredentials = async ( + connection: THCVaultConnection, + gatewayService: Pick +) => { + const instanceUrl = await getHCVaultInstanceUrl(connection); try { - const accessToken = await getHCVaultAccessToken(config); + const accessToken = await getHCVaultAccessToken(connection, gatewayService); // Verify token - await request.get(`${instanceUrl}/v1/auth/token/lookup-self`, { + await requestWithHCVaultGateway(connection, gatewayService, { + url: `${instanceUrl}/v1/auth/token/lookup-self`, + method: "GET", headers: { "X-Vault-Token": accessToken } }); - return config.credentials; + return connection.credentials; } catch (error: unknown) { + logger.error(error, "Unable to verify HC Vault connection"); + if (error instanceof AxiosError) { throw new BadRequestError({ message: `Failed to validate credentials: ${error.message || "Unknown error"}` }); } + + if (error instanceof BadRequestError) { + throw error; + } + throw new BadRequestError({ message: "Unable to validate connection: verify credentials" }); } }; -export const listHCVaultMounts = async (appConnection: THCVaultConnection) => { - const instanceUrl = await getHCVaultInstanceUrl(appConnection); - const accessToken = await getHCVaultAccessToken(appConnection); +export const listHCVaultMounts = async ( + connection: THCVaultConnection, + gatewayService: Pick +) => { + const instanceUrl = await getHCVaultInstanceUrl(connection); + const accessToken = await getHCVaultAccessToken(connection, gatewayService); - const { data } = await request.get(`${instanceUrl}/v1/sys/mounts`, { + const { data } = await requestWithHCVaultGateway(connection, gatewayService, { + url: `${instanceUrl}/v1/sys/mounts`, + method: "GET", headers: { "X-Vault-Token": accessToken, - ...(appConnection.credentials.namespace ? { "X-Vault-Namespace": appConnection.credentials.namespace } : {}) + ...(connection.credentials.namespace ? { "X-Vault-Namespace": connection.credentials.namespace } : {}) } }); diff --git a/backend/src/services/app-connection/hc-vault/hc-vault-connection-schemas.ts b/backend/src/services/app-connection/hc-vault/hc-vault-connection-schemas.ts index a6db4d0eb..57e8fbeaf 100644 --- a/backend/src/services/app-connection/hc-vault/hc-vault-connection-schemas.ts +++ b/backend/src/services/app-connection/hc-vault/hc-vault-connection-schemas.ts @@ -55,11 +55,18 @@ export const HCVaultConnectionSchema = z.intersection( export const SanitizedHCVaultConnectionSchema = z.discriminatedUnion("method", [ BaseHCVaultConnectionSchema.extend({ method: z.literal(HCVaultConnectionMethod.AccessToken), - credentials: HCVaultConnectionAccessTokenCredentialsSchema.pick({}) + credentials: HCVaultConnectionAccessTokenCredentialsSchema.pick({ + namespace: true, + instanceUrl: true + }) }), BaseHCVaultConnectionSchema.extend({ method: z.literal(HCVaultConnectionMethod.AppRole), - credentials: HCVaultConnectionAppRoleCredentialsSchema.pick({}) + credentials: HCVaultConnectionAppRoleCredentialsSchema.pick({ + namespace: true, + instanceUrl: true, + roleId: true + }) }) ]); @@ -81,7 +88,7 @@ export const ValidateHCVaultConnectionCredentialsSchema = z.discriminatedUnion(" ]); export const CreateHCVaultConnectionSchema = ValidateHCVaultConnectionCredentialsSchema.and( - GenericCreateAppConnectionFieldsSchema(AppConnection.HCVault) + GenericCreateAppConnectionFieldsSchema(AppConnection.HCVault, { supportsGateways: true }) ); export const UpdateHCVaultConnectionSchema = z @@ -91,7 +98,7 @@ export const UpdateHCVaultConnectionSchema = z .optional() .describe(AppConnections.UPDATE(AppConnection.HCVault).credentials) }) - .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.HCVault)); + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.HCVault, { supportsGateways: true })); export const HCVaultConnectionListItemSchema = z.object({ name: z.literal("HCVault"), diff --git a/backend/src/services/app-connection/hc-vault/hc-vault-connection-service.ts b/backend/src/services/app-connection/hc-vault/hc-vault-connection-service.ts index b5cee6fdd..589c7c1bd 100644 --- a/backend/src/services/app-connection/hc-vault/hc-vault-connection-service.ts +++ b/backend/src/services/app-connection/hc-vault/hc-vault-connection-service.ts @@ -1,3 +1,4 @@ +import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { logger } from "@app/lib/logger"; import { OrgServiceActor } from "@app/lib/types"; @@ -11,12 +12,15 @@ type TGetAppConnectionFunc = ( actor: OrgServiceActor ) => Promise; -export const hcVaultConnectionService = (getAppConnection: TGetAppConnectionFunc) => { +export const hcVaultConnectionService = ( + getAppConnection: TGetAppConnectionFunc, + gatewayService: Pick +) => { const listMounts = async (connectionId: string, actor: OrgServiceActor) => { const appConnection = await getAppConnection(AppConnection.HCVault, connectionId, actor); try { - const mounts = await listHCVaultMounts(appConnection); + const mounts = await listHCVaultMounts(appConnection, gatewayService); return mounts; } catch (error) { logger.error(error, "Failed to establish connection with Hashicorp Vault"); diff --git a/backend/src/services/app-connection/render/render-connection-fns.ts b/backend/src/services/app-connection/render/render-connection-fns.ts index bf85b9b71..c41c46c50 100644 --- a/backend/src/services/app-connection/render/render-connection-fns.ts +++ b/backend/src/services/app-connection/render/render-connection-fns.ts @@ -8,9 +8,11 @@ import { IntegrationUrls } from "@app/services/integration-auth/integration-list import { AppConnection } from "../app-connection-enums"; import { RenderConnectionMethod } from "./render-connection-enums"; import { + TRawRenderEnvironmentGroup, TRawRenderService, TRenderConnection, TRenderConnectionConfig, + TRenderEnvironmentGroup, TRenderService } from "./render-connection-types"; @@ -32,7 +34,11 @@ export const listRenderServices = async (appConnection: TRenderConnection): Prom const perPage = 100; let cursor; + let maxIterations = 10; + while (hasMorePages) { + if (maxIterations <= 0) break; + const res: TRawRenderService[] = ( await request.get(`${IntegrationUrls.RENDER_API_URL}/v1/services`, { params: new URLSearchParams({ @@ -59,6 +65,8 @@ export const listRenderServices = async (appConnection: TRenderConnection): Prom } else { cursor = res[res.length - 1].cursor; } + + maxIterations -= 1; } return services; @@ -86,3 +94,52 @@ export const validateRenderConnectionCredentials = async (config: TRenderConnect return inputCredentials; }; + +export const listRenderEnvironmentGroups = async ( + appConnection: TRenderConnection +): Promise => { + const { + credentials: { apiKey } + } = appConnection; + + const groups: TRenderEnvironmentGroup[] = []; + let hasMorePages = true; + const perPage = 100; + let cursor; + let maxIterations = 10; + + while (hasMorePages) { + if (maxIterations <= 0) break; + + const res: TRawRenderEnvironmentGroup[] = ( + await request.get(`${IntegrationUrls.RENDER_API_URL}/v1/env-groups`, { + params: new URLSearchParams({ + ...(cursor ? { cursor: String(cursor) } : {}), + limit: String(perPage) + }), + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + "Accept-Encoding": "application/json" + } + }) + ).data; + + res.forEach((item) => { + groups.push({ + name: item.envGroup.name, + id: item.envGroup.id + }); + }); + + if (res.length < perPage) { + hasMorePages = false; + } else { + cursor = res[res.length - 1].cursor; + } + + maxIterations -= 1; + } + + return groups; +}; diff --git a/backend/src/services/app-connection/render/render-connection-service.ts b/backend/src/services/app-connection/render/render-connection-service.ts index 371790bcb..7cf805e9c 100644 --- a/backend/src/services/app-connection/render/render-connection-service.ts +++ b/backend/src/services/app-connection/render/render-connection-service.ts @@ -2,7 +2,7 @@ import { logger } from "@app/lib/logger"; import { OrgServiceActor } from "@app/lib/types"; import { AppConnection } from "../app-connection-enums"; -import { listRenderServices } from "./render-connection-fns"; +import { listRenderEnvironmentGroups, listRenderServices } from "./render-connection-fns"; import { TRenderConnection } from "./render-connection-types"; type TGetAppConnectionFunc = ( @@ -24,7 +24,20 @@ export const renderConnectionService = (getAppConnection: TGetAppConnectionFunc) } }; + const listEnvironmentGroups = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.Render, connectionId, actor); + try { + const groups = await listRenderEnvironmentGroups(appConnection); + + return groups; + } catch (error) { + logger.error(error, "Failed to list environment groups for Render connection"); + return []; + } + }; + return { - listServices + listServices, + listEnvironmentGroups }; }; diff --git a/backend/src/services/app-connection/render/render-connection-types.ts b/backend/src/services/app-connection/render/render-connection-types.ts index 0902472e5..c3922a071 100644 --- a/backend/src/services/app-connection/render/render-connection-types.ts +++ b/backend/src/services/app-connection/render/render-connection-types.ts @@ -33,3 +33,16 @@ export type TRawRenderService = { name: string; }; }; + +export type TRenderEnvironmentGroup = { + name: string; + id: string; +}; + +export type TRawRenderEnvironmentGroup = { + cursor: string; + envGroup: { + id: string; + name: string; + }; +}; diff --git a/backend/src/services/auth-token/auth-token-service.ts b/backend/src/services/auth-token/auth-token-service.ts index 1a2f290ec..c309b3998 100644 --- a/backend/src/services/auth-token/auth-token-service.ts +++ b/backend/src/services/auth-token/auth-token-service.ts @@ -75,7 +75,7 @@ export const getTokenConfig = (tokenType: TokenType) => { }; export const tokenServiceFactory = ({ tokenDAL, userDAL, orgMembershipDAL }: TAuthTokenServiceFactoryDep) => { - const createTokenForUser = async ({ type, userId, orgId }: TCreateTokenForUserDTO) => { + const createTokenForUser = async ({ type, userId, orgId, aliasId }: TCreateTokenForUserDTO) => { const { token, ...tkCfg } = getTokenConfig(type); const appCfg = getConfig(); const tokenHash = await crypto.hashing().createHash(token, appCfg.SALT_ROUNDS); @@ -88,7 +88,8 @@ export const tokenServiceFactory = ({ tokenDAL, userDAL, orgMembershipDAL }: TAu type, userId, orgId, - triesLeft: tkCfg?.triesLeft + triesLeft: tkCfg?.triesLeft, + aliasId }, tx ); diff --git a/backend/src/services/auth-token/auth-token-types.ts b/backend/src/services/auth-token/auth-token-types.ts index 5f5843bc6..7deb719a9 100644 --- a/backend/src/services/auth-token/auth-token-types.ts +++ b/backend/src/services/auth-token/auth-token-types.ts @@ -14,6 +14,7 @@ export type TCreateTokenForUserDTO = { type: TokenType; userId: string; orgId?: string; + aliasId?: string; }; export type TCreateOrgInviteTokenDTO = { diff --git a/backend/src/services/auth/auth-login-service.ts b/backend/src/services/auth/auth-login-service.ts index 6a3456508..ede9e29e3 100644 --- a/backend/src/services/auth/auth-login-service.ts +++ b/backend/src/services/auth/auth-login-service.ts @@ -448,15 +448,46 @@ export const authLoginServiceFactory = ({ // Check if the user actually has access to the specified organization. const userOrgs = await orgDAL.findAllOrgsByUserId(user.id); - const hasOrganizationMembership = userOrgs.some((org) => org.id === organizationId && org.userStatus !== "invited"); + + const selectedOrgMembership = userOrgs.find((org) => org.id === organizationId && org.userStatus !== "invited"); + const selectedOrg = await orgDAL.findById(organizationId); - if (!hasOrganizationMembership) { + if (!selectedOrgMembership) { throw new ForbiddenRequestError({ message: `User does not have access to the organization named ${selectedOrg?.name}` }); } + // Check if authEnforced is true and the current auth method is not an enforced method + if ( + selectedOrg.authEnforced && + !isAuthMethodSaml(decodedToken.authMethod) && + decodedToken.authMethod !== AuthMethod.OIDC && + !(selectedOrg.bypassOrgAuthEnabled && selectedOrgMembership.userRole === OrgMembershipRole.Admin) + ) { + throw new BadRequestError({ + message: "Login with the auth method required by your organization." + }); + } + + if (selectedOrg.googleSsoAuthEnforced && decodedToken.authMethod !== AuthMethod.GOOGLE) { + const canBypass = selectedOrg.bypassOrgAuthEnabled && selectedOrgMembership.userRole === OrgMembershipRole.Admin; + + if (!canBypass) { + throw new ForbiddenRequestError({ + message: "Google SSO is enforced for this organization. Please use Google SSO to login.", + error: "GoogleSsoEnforced" + }); + } + } + + if (decodedToken.authMethod === AuthMethod.GOOGLE) { + await orgDAL.updateById(selectedOrg.id, { + googleSsoAuthLastUsed: new Date() + }); + } + const shouldCheckMfa = selectedOrg.enforceMfa || user.isMfaEnabled; const orgMfaMethod = selectedOrg.enforceMfa ? (selectedOrg.selectedMfaMethod ?? MfaMethod.EMAIL) : undefined; const userMfaMethod = user.isMfaEnabled ? (user.selectedMfaMethod ?? MfaMethod.EMAIL) : undefined; @@ -502,7 +533,8 @@ export const authLoginServiceFactory = ({ selectedOrg.authEnforced && selectedOrg.bypassOrgAuthEnabled && !isAuthMethodSaml(decodedToken.authMethod) && - decodedToken.authMethod !== AuthMethod.OIDC + decodedToken.authMethod !== AuthMethod.OIDC && + decodedToken.authMethod !== AuthMethod.GOOGLE ) { await auditLogService.createAuditLog({ orgId: organizationId, @@ -705,7 +737,7 @@ export const authLoginServiceFactory = ({ /* * OAuth2 login for google,github, and other oauth2 provider * */ - const oauth2Login = async ({ email, firstName, lastName, authMethod, callbackPort }: TOauthLoginDTO) => { + const oauth2Login = async ({ email, firstName, lastName, authMethod, callbackPort, orgSlug }: TOauthLoginDTO) => { // akhilmhdh: case sensitive email resolution const usersByUsername = await userDAL.findUserByUsername(email); let user = usersByUsername?.length > 1 ? usersByUsername.find((el) => el.username === email) : usersByUsername?.[0]; @@ -759,6 +791,8 @@ export const authLoginServiceFactory = ({ const appCfg = getConfig(); + let orgId = ""; + let orgName: undefined | string; if (!user) { // Create a new user based on oAuth if (!serverCfg?.allowSignUp) throw new BadRequestError({ message: "Sign up disabled", name: "Oauth 2 login" }); @@ -784,7 +818,6 @@ export const authLoginServiceFactory = ({ }); if (authMethod === AuthMethod.GITHUB && serverCfg.defaultAuthOrgId && !appCfg.isCloud) { - let orgId = ""; const defaultOrg = await orgDAL.findOrgById(serverCfg.defaultAuthOrgId); if (!defaultOrg) { throw new BadRequestError({ @@ -824,11 +857,39 @@ export const authLoginServiceFactory = ({ } } + if (!orgId && orgSlug) { + const org = await orgDAL.findOrgBySlug(orgSlug); + + if (org) { + // checks for the membership and only sets the orgId / orgName if the user is a member of the specified org + const orgMembership = await orgDAL.findMembership({ + [`${TableName.OrgMembership}.userId` as "userId"]: user.id, + [`${TableName.OrgMembership}.orgId` as "orgId"]: org.id, + [`${TableName.OrgMembership}.isActive` as "isActive"]: true, + [`${TableName.OrgMembership}.status` as "status"]: OrgMembershipStatus.Accepted + }); + + if (orgMembership) { + orgId = org.id; + orgName = org.name; + } + } + } + const isUserCompleted = user.isAccepted; const providerAuthToken = crypto.jwt().sign( { authTokenType: AuthTokenType.PROVIDER_TOKEN, userId: user.id, + + ...(orgId && orgSlug && orgName !== undefined + ? { + organizationId: orgId, + organizationName: orgName, + organizationSlug: orgSlug + } + : {}), + username: user.username, email: user.email, isEmailVerified: user.isEmailVerified, diff --git a/backend/src/services/auth/auth-login-type.ts b/backend/src/services/auth/auth-login-type.ts index d9d9520a8..09d81033f 100644 --- a/backend/src/services/auth/auth-login-type.ts +++ b/backend/src/services/auth/auth-login-type.ts @@ -32,6 +32,7 @@ export type TOauthLoginDTO = { lastName?: string; authMethod: AuthMethod; callbackPort?: string; + orgSlug?: string; }; export type TOauthTokenExchangeDTO = { diff --git a/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts b/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts index 86beadffe..830378ca8 100644 --- a/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts @@ -64,6 +64,8 @@ type DBConfigurationColumn = { directoryUrl: string; accountEmail: string; hostedZoneId: string; + eabKid?: string; + eabHmacKey?: string; }; export const castDbEntryToAcmeCertificateAuthority = ( @@ -89,7 +91,9 @@ export const castDbEntryToAcmeCertificateAuthority = ( hostedZoneId: dbConfigurationCol.hostedZoneId }, directoryUrl: dbConfigurationCol.directoryUrl, - accountEmail: dbConfigurationCol.accountEmail + accountEmail: dbConfigurationCol.accountEmail, + eabKid: dbConfigurationCol.eabKid, + eabHmacKey: dbConfigurationCol.eabHmacKey }, status: ca.status as CaStatus }; @@ -128,7 +132,7 @@ export const AcmeCertificateAuthorityFns = ({ }); } - const { dnsAppConnectionId, directoryUrl, accountEmail, dnsProviderConfig } = configuration; + const { dnsAppConnectionId, directoryUrl, accountEmail, dnsProviderConfig, eabKid, eabHmacKey } = configuration; const appConnection = await appConnectionDAL.findById(dnsAppConnectionId); if (!appConnection) { @@ -171,7 +175,9 @@ export const AcmeCertificateAuthorityFns = ({ directoryUrl, accountEmail, dnsProvider: dnsProviderConfig.provider, - hostedZoneId: dnsProviderConfig.hostedZoneId + hostedZoneId: dnsProviderConfig.hostedZoneId, + eabKid, + eabHmacKey } }, tx @@ -214,7 +220,7 @@ export const AcmeCertificateAuthorityFns = ({ }) => { const updatedCa = await certificateAuthorityDAL.transaction(async (tx) => { if (configuration) { - const { dnsAppConnectionId, directoryUrl, accountEmail, dnsProviderConfig } = configuration; + const { dnsAppConnectionId, directoryUrl, accountEmail, dnsProviderConfig, eabKid, eabHmacKey } = configuration; const appConnection = await appConnectionDAL.findById(dnsAppConnectionId); if (!appConnection) { @@ -254,7 +260,9 @@ export const AcmeCertificateAuthorityFns = ({ directoryUrl, accountEmail, dnsProvider: dnsProviderConfig.provider, - hostedZoneId: dnsProviderConfig.hostedZoneId + hostedZoneId: dnsProviderConfig.hostedZoneId, + eabKid, + eabHmacKey } }, tx @@ -354,10 +362,19 @@ export const AcmeCertificateAuthorityFns = ({ await blockLocalAndPrivateIpAddresses(acmeCa.configuration.directoryUrl); - const acmeClient = new acme.Client({ + const acmeClientOptions: acme.ClientOptions = { directoryUrl: acmeCa.configuration.directoryUrl, accountKey - }); + }; + + if (acmeCa.configuration.eabKid && acmeCa.configuration.eabHmacKey) { + acmeClientOptions.externalAccountBinding = { + kid: acmeCa.configuration.eabKid, + hmacKey: acmeCa.configuration.eabHmacKey + }; + } + + const acmeClient = new acme.Client(acmeClientOptions); const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048); diff --git a/backend/src/services/certificate-authority/acme/acme-certificate-authority-schemas.ts b/backend/src/services/certificate-authority/acme/acme-certificate-authority-schemas.ts index 56b3118cf..70b0cb0e1 100644 --- a/backend/src/services/certificate-authority/acme/acme-certificate-authority-schemas.ts +++ b/backend/src/services/certificate-authority/acme/acme-certificate-authority-schemas.ts @@ -18,7 +18,9 @@ export const AcmeCertificateAuthorityConfigurationSchema = z.object({ hostedZoneId: z.string().trim().min(1).describe(CertificateAuthorities.CONFIGURATIONS.ACME.hostedZoneId) }), directoryUrl: z.string().url().trim().min(1).describe(CertificateAuthorities.CONFIGURATIONS.ACME.directoryUrl), - accountEmail: z.string().trim().min(1).describe(CertificateAuthorities.CONFIGURATIONS.ACME.accountEmail) + accountEmail: z.string().trim().min(1).describe(CertificateAuthorities.CONFIGURATIONS.ACME.accountEmail), + eabKid: z.string().trim().max(64).optional().describe(CertificateAuthorities.CONFIGURATIONS.ACME.eabKid), + eabHmacKey: z.string().trim().max(512).optional().describe(CertificateAuthorities.CONFIGURATIONS.ACME.eabHmacKey) }); export const AcmeCertificateAuthorityCredentialsSchema = z.object({ diff --git a/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-fns.ts b/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-fns.ts new file mode 100644 index 000000000..0e2619a27 --- /dev/null +++ b/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-fns.ts @@ -0,0 +1,1172 @@ +/* eslint-disable no-await-in-loop */ +import * as x509 from "@peculiar/x509"; +import RE2 from "re2"; + +import { TableName } from "@app/db/schemas"; +import { crypto } from "@app/lib/crypto/cryptography"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { ms } from "@app/lib/ms"; +import { OrgServiceActor } from "@app/lib/types"; +import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; +import { + createAdcsHttpClient, + getAzureADCSConnectionCredentials +} from "@app/services/app-connection/azure-adcs/azure-adcs-connection-fns"; +import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; +import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal"; +import { + CertExtendedKeyUsage, + CertKeyAlgorithm, + CertKeyUsage, + CertStatus +} from "@app/services/certificate/certificate-types"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal"; +import { TPkiSubscriberProperties } from "@app/services/pki-subscriber/pki-subscriber-types"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; + +import { TCertificateAuthorityDALFactory } from "../certificate-authority-dal"; +import { CaStatus, CaType } from "../certificate-authority-enums"; +import { keyAlgorithmToAlgCfg } from "../certificate-authority-fns"; +import { TExternalCertificateAuthorityDALFactory } from "../external-certificate-authority-dal"; +import { + TAzureAdCsCertificateAuthority, + TCreateAzureAdCsCertificateAuthorityDTO, + TUpdateAzureAdCsCertificateAuthorityDTO +} from "./azure-ad-cs-certificate-authority-types"; + +type TAzureAdCsCertificateAuthorityFnsDeps = { + appConnectionDAL: Pick; + appConnectionService: Pick; + certificateAuthorityDAL: Pick< + TCertificateAuthorityDALFactory, + "create" | "transaction" | "findByIdWithAssociatedCa" | "updateById" | "findWithAssociatedCa" + >; + externalCertificateAuthorityDAL: Pick; + certificateDAL: Pick; + certificateBodyDAL: Pick; + certificateSecretDAL: Pick; + kmsService: Pick< + TKmsServiceFactory, + "encryptWithKmsKey" | "generateKmsKey" | "createCipherPairWithDataKey" | "decryptWithKmsKey" + >; + pkiSubscriberDAL: Pick; + projectDAL: Pick; +}; + +type AzureCertificateRequest = { + csr: string; + template: string; + attributes?: Record; +}; + +type AzureCertificateResponse = { + certificateId: string; + certificate: string; + certificateChain?: string; + status: "issued" | "pending" | "denied"; + disposition?: string; +}; + +const buildSubjectDN = (commonName: string, properties?: TPkiSubscriberProperties): string => { + // Validate and sanitize common name - it's required and cannot be empty + if (!commonName || !commonName.trim()) { + throw new BadRequestError({ message: "Common Name is required and cannot be empty" }); + } + + const trimmedCN = commonName.trim(); + + const invalidCharsRegex = new RE2("[,=+<>#;\\\\\\]]"); + if (invalidCharsRegex.test(trimmedCN)) { + throw new BadRequestError({ + message: "Common Name contains invalid characters: , = + < > # ; \\ ]" + }); + } + + let subject = `CN=${trimmedCN}`; + + const validateComponent = (value: string | undefined, componentName: string): string | null => { + if (!value || typeof value !== "string") return null; + const trimmed = value.trim(); + if (!trimmed) return null; + + const componentInvalidCharsRegex = new RE2('[,=+<>#;\\\\"\\/\\r\\n\\t]'); + if (componentInvalidCharsRegex.test(trimmed)) { + throw new BadRequestError({ + message: `${componentName} contains invalid characters: , = + < > # ; \\ " / \\r \\n \\t` + }); + } + + const problematicCharsRegex = new RE2("^[\\\\s\\\\-_.]+|[\\\\s\\\\-_.]+$"); + if (problematicCharsRegex.test(trimmed)) { + throw new BadRequestError({ + message: `${componentName} cannot start or end with spaces, hyphens, underscores, or periods` + }); + } + + return trimmed; + }; + + const emailAddress = validateComponent(properties?.emailAddress, "Email Address"); + if (emailAddress) { + const emailRegex = new RE2(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/); + if (!emailRegex.test(emailAddress) || emailAddress.length <= 5 || emailAddress.length >= 64) { + throw new BadRequestError({ + message: "Email Address must be a valid email format between 5 and 64 characters" + }); + } + subject += `,E=${emailAddress}`; + } + + const organizationalUnit = validateComponent(properties?.organizationalUnit, "Organizational Unit"); + if (organizationalUnit) { + if (organizationalUnit.length > 64) { + throw new BadRequestError({ + message: "Organizational Unit cannot exceed 64 characters" + }); + } + subject += `,OU=${organizationalUnit}`; + } + + const organization = validateComponent(properties?.organization, "Organization"); + if (organization) { + if (organization.length > 64) { + throw new BadRequestError({ + message: "Organization cannot exceed 64 characters" + }); + } + subject += `,O=${organization}`; + } + + const locality = validateComponent(properties?.locality, "Locality"); + if (locality) { + if (locality.length > 64) { + throw new BadRequestError({ + message: "Locality cannot exceed 64 characters" + }); + } + subject += `,L=${locality}`; + } + + const state = validateComponent(properties?.state, "State"); + if (state) { + if (state.length > 64) { + throw new BadRequestError({ + message: "State cannot exceed 64 characters" + }); + } + subject += `,ST=${state}`; + } + + const country = validateComponent(properties?.country, "Country"); + if (country) { + // Country code must be exactly 2 uppercase letters + const countryCode = country.toUpperCase(); + const invalidCountryRegex = new RE2("[^A-Z]"); + if (invalidCountryRegex.test(countryCode)) { + throw new BadRequestError({ + message: "Country must contain only uppercase letters" + }); + } + if (countryCode.length !== 2) { + throw new BadRequestError({ + message: "Country must be exactly 2 characters" + }); + } + subject += `,C=${countryCode}`; + } + + return subject; +}; + +export const castDbEntryToAzureAdCsCertificateAuthority = ( + ca: Awaited> +): TAzureAdCsCertificateAuthority & { credentials: unknown } => { + if (!ca.externalCa?.id) { + throw new BadRequestError({ message: "Malformed Azure AD Certificate Service certificate authority" }); + } + + if (!ca.externalCa.dnsAppConnectionId) { + throw new BadRequestError({ + message: "Azure ADCS connection ID is missing from certificate authority configuration" + }); + } + + return { + id: ca.id, + type: CaType.AZURE_AD_CS, + enableDirectIssuance: ca.enableDirectIssuance, + name: ca.name, + projectId: ca.projectId, + credentials: ca.externalCa.credentials, + configuration: { + azureAdcsConnectionId: ca.externalCa.dnsAppConnectionId + }, + status: ca.status as CaStatus + }; +}; + +const submitCertificateRequest = async ( + credentials: { username: string; password: string; sslRejectUnauthorized?: boolean; sslCertificate?: string }, + caServiceUrl: string, + certificateRequest: AzureCertificateRequest +): Promise => { + try { + const adcsClient = createAdcsHttpClient( + credentials.username, + credentials.password, + caServiceUrl, + credentials.sslRejectUnauthorized ?? true, + credentials.sslCertificate + ); + + // Clean CSR by removing headers and newlines for ADCS submission + const cleanCsr = certificateRequest.csr + .replace(new RE2("-----BEGIN CERTIFICATE REQUEST-----", "g"), "") + .replace(new RE2("-----END CERTIFICATE REQUEST-----", "g"), "") + .replace(new RE2("\\\\r?\\\\n", "g"), ""); + + // Build certificate attributes including template and validity period + const certAttribParts: string[] = []; + + // Add template - this is required + if (certificateRequest.template && certificateRequest.template.trim()) { + certAttribParts.push(`CertificateTemplate:${certificateRequest.template.trim()}`); + } + + // Add validity period if specified by the user + if (certificateRequest.attributes?.validityPeriod) { + try { + const ttlMs = ms(certificateRequest.attributes.validityPeriod); + const expirationDate = new Date(Date.now() + ttlMs); + + // Format expiration date in RFC 2616 format for ADCS + const rfc2616Date = expirationDate.toUTCString(); + + // Add ExpirationDate attribute (requires EDITF_ATTRIBUTEENDDATE flag on CA) + certAttribParts.push(`ExpirationDate:${rfc2616Date}`); + } catch (error) { + throw new BadRequestError({ + message: "Invalid validity period format" + }); + } + } + + // Join all attributes with proper CRLF ending + const certAttrib = certAttribParts.length > 0 ? `${certAttribParts.join("\r\n")}\r\n` : ""; + + // Prepare form data for ADCS web interface - these parameters are required by Microsoft ADCS + // Mode: "newreq" indicates a new certificate request + // CertRequest: the base64-encoded CSR without headers + // CertAttrib: certificate template and other attributes in CRLF format + // FriendlyType: display name for the certificate type + // TargetStoreFlags: certificate store flags (0 = default) + // SaveCert: "yes" to save the certificate to the server + const formData = new URLSearchParams({ + Mode: "newreq", + CertRequest: cleanCsr, + CertAttrib: certAttrib, + FriendlyType: "Saved-Request Certificate", + TargetStoreFlags: "0", + SaveCert: "yes" + }); + + const response = await adcsClient.post("/certsrv/certfnsh.asp", formData.toString()); + + const responseText = response.data; + + // Parse the HTML response to extract certificate information + let requestId: string | undefined; + let status: "issued" | "pending" | "denied" = "pending"; + let certificate = ""; + + // Look for request ID in various formats + const requestIdMatches = [ + new RE2("reqid[=:](\\d+)", "i"), + new RE2("request\\s+id[:\\s]+(\\d+)", "i"), + new RE2("certificate\\s+request\\s+(\\d+)", "i"), + new RE2("\\breqid=(\\d+)\\b", "i"), + new RE2("requestid[:\\s]*(\\d+)", "i") + ]; + + for (const regex of requestIdMatches) { + const match = responseText.match(regex); + if (match) { + [, requestId] = match; + break; + } + } + + // Check for immediate certificate issuance + const certMatch = responseText.match(new RE2("-----BEGIN CERTIFICATE-----[\\s\\S]*?-----END CERTIFICATE-----")); + if (certMatch) { + // Clean up the certificate format + certificate = certMatch[0].replace(new RE2("\\\\r\\\\n", "g"), "\n").replace(new RE2("\\\\r", "g"), "\n").trim(); + + // Validate the certificate format before using it + try { + status = "issued"; + } catch (error) { + certificate = ""; + status = "pending"; + } + } + + // Check disposition message for status + if (responseText.includes("taken under submission") || responseText.includes("pending")) { + status = "pending"; + } else if (responseText.includes("denied") || responseText.includes("rejected")) { + status = "denied"; + } else if (responseText.includes("issued") || certificate) { + status = "issued"; + } + + // If we couldn't parse a request ID and don't have a certificate, something went wrong + if (!requestId && !certificate) { + // Check for specific error types first + let errorMessage = "Unknown error occurred"; + + // ASN.1 parsing errors (CSR format issues) + if ( + responseText.includes("ASN1") || + responseText.includes("Error Parsing Request") || + responseText.includes("unexpected end of data") || + responseText.includes("bad tag value met") || + responseText.includes("0x80093102") + ) { + const asn1Patterns = [ + new RE2("Error Parsing Request\\s+ASN1[^.]*\\.?", "i"), + new RE2("ASN1[^.]*\\.?", "i"), + new RE2("Error Parsing Request[^.]*\\.?", "i") + ]; + + for (const pattern of asn1Patterns) { + const match = responseText.match(pattern); + if (match) { + errorMessage = match[0].trim(); + break; + } + } + + errorMessage = `Certificate request format error: ${errorMessage}. This indicates the CSR (Certificate Signing Request) format is incompatible with ADCS.`; + } + // Template permission errors (policy denials) + else if ( + responseText.includes("Denied by Policy Module") || + responseText.includes("0x80094800") || + responseText.includes("template that is not supported") + ) { + const policyMatch = + responseText.match(new RE2('Denied by Policy Module[^"]*"([^"]*)')) || + responseText.match(new RE2('The disposition message is "([^"]*)')); + + if (policyMatch) { + errorMessage = policyMatch[1].trim(); + } + + errorMessage = `Certificate template permission error: ${errorMessage}. Verify that the connection account has enrollment permissions for the selected certificate template.`; + } + // General error extraction + else { + const errorPatterns = [ + new RE2('The disposition message is "([^"]*)"', "i"), + new RE2('Denied by Policy Module[^"]*"([^"]*)"', "i"), + new RE2("]*class[^>]*error[^>]*>(.*?)<\\/p>", "i"), + new RE2("]*class[^>]*error[^>]*>(.*?)<\\/div>", "i"), + new RE2("]*class[^>]*error[^>]*>(.*?)<\\/span>", "i"), + new RE2("error[^<]*:([^<]*)", "i"), + new RE2("denied[^<]*:([^<]*)", "i"), + new RE2("The\\s+request\\s+contains\\s+no\\s+certificate\\s+template\\s+information", "i"), + new RE2("The\\s+template\\s+is\\s+missing", "i") + ]; + + // Try each pattern to find the error message + for (const pattern of errorPatterns) { + const match = responseText.match(pattern); + if (match) { + errorMessage = match[1] ? match[1].trim() : match[0].trim(); + break; + } + } + } + + // Clean up HTML entities and tags from error message + errorMessage = errorMessage + .replace(new RE2(""", "g"), '"') + .replace(new RE2("<", "g"), "<") + .replace(new RE2(">", "g"), ">") + .replace(new RE2("&", "g"), "&") + .replace(new RE2("<[^>]*>", "g"), "") // Remove HTML tags + .replace(new RE2("\\r\\n", "g"), " ") + .replace(new RE2("\\n", "g"), " ") + .replace(new RE2("\\r", "g"), " ") + .replace(new RE2("\\s+", "g"), " ") + .replace(new RE2('\\s*[".]*\\s*[".]*\\s*$'), "") // Remove trailing quotes and periods + .replace(new RE2('^\\s*[".]*\\s*'), "") // Remove leading quotes and periods + .trim(); + + // Handle specific Microsoft ADCS OID-related errors + if ( + responseText.includes("Cannot get OID for name type") || + responseText.includes("OID for name type") || + responseText.includes("name type ''") + ) { + errorMessage = + "Certificate template OID resolution error. This may be caused by: " + + "1) Certificate template name doesn't exist or is not published, " + + "2) ADCS OID cache needs refresh, or " + + "3) Template permissions are insufficient. " + + "Please verify the template exists, is published, and you have enrollment permissions."; + } else if (responseText.includes("template") && responseText.includes("not found")) { + errorMessage = `Certificate template not found. Please verify the template name '${certificateRequest.template}' exists and is published on the ADCS server.`; + } else if (responseText.includes("access denied") || responseText.includes("permission")) { + errorMessage = "Access denied. You may not have permission to request certificates with this template."; + } else if (responseText.includes("subject") || responseText.includes("DN")) { + errorMessage = + "Invalid subject DN format. Please check that all subject field values contain only valid characters."; + } else if (responseText.includes("Computer") && responseText.includes("Machine")) { + errorMessage = + "Computer/Machine template error. These templates may require domain-joined machines or specific subject name formats."; + } else if ( + certificateRequest.template.toLowerCase().includes("computer") || + certificateRequest.template.toLowerCase().includes("machine") + ) { + errorMessage = + `Template '${certificateRequest.template}' failed because it requires domain authentication and automatic enrollment. ` + + `Computer/Machine templates are designed for domain-joined computers, not manual requests. ` + + `Solutions: 1) Use 'User' or 'WebServer' templates instead, 2) Create a custom template based on ${certificateRequest.template} but configured for manual enrollment, ` + + `3) Ask your ADCS administrator to modify the template to allow manual enrollment and 'Supply subject in request'.`; + } else if (errorMessage.length < 10 || errorMessage === certificateRequest.template) { + errorMessage = `Certificate request failed with template '${certificateRequest.template}'. This may indicate a template configuration issue, permission problem, or invalid subject information.`; + } + + throw new BadRequestError({ + message: `Certificate request failed: ${errorMessage}` + }); + } + + return { + certificateId: requestId || "immediate", + certificate, + certificateChain: "", + status + }; + } catch (error) { + if (error instanceof BadRequestError) { + throw error; + } + + if (error instanceof Error) { + let errorMessage = `Failed to submit certificate request to ADCS: ${error.message}`; + + if (error.message.includes("401") || error.message.includes("Unauthorized")) { + errorMessage = "Authentication failed. Please verify your username and password are correct."; + } else if (error.message.includes("403") || error.message.includes("Forbidden")) { + errorMessage = "Access denied. You may not have permission to request certificates with this template."; + } else if (error.message.includes("404") || error.message.includes("Not Found")) { + errorMessage = "ADCS endpoint not found. Please verify the ADCS URL is correct."; + } else if (error.message.includes("ENOTFOUND")) { + errorMessage = "Cannot connect to ADCS server. Please verify the server URL and network connectivity."; + } else if (error.message.includes("ETIMEDOUT")) { + errorMessage = "Request timed out. The ADCS server may be overloaded or unreachable."; + } + + throw new BadRequestError({ message: errorMessage }); + } + + throw new BadRequestError({ + message: `Failed to submit certificate request to ADCS: ${error instanceof Error ? error.message : "Unknown error"}` + }); + } +}; + +const retrieveCertificate = async ( + credentials: { username: string; password: string; sslRejectUnauthorized?: boolean; sslCertificate?: string }, + caServiceUrl: string, + certificateId: string +): Promise => { + try { + const adcsClient = createAdcsHttpClient( + credentials.username, + credentials.password, + caServiceUrl, + credentials.sslRejectUnauthorized ?? true, + credentials.sslCertificate + ); + + const response = await adcsClient.get(`/certsrv/certnew.cer?ReqID=${certificateId}&Enc=b64`, { + Accept: "application/pkix-cert,application/x-x509-ca-cert,application/octet-stream,*/*" + }); + + const certData = response.data; + + // Check if the response contains HTML indicating the certificate is not ready + if (certData.includes("") || certData.includes("taken under submission") || certData.includes("pending")) { + throw new BadRequestError({ + message: `Certificate with ID ${certificateId} is still pending approval or processing` + }); + } + + // If certificate is already in PEM format, return as-is + if (certData.includes("-----BEGIN CERTIFICATE-----")) { + return certData.trim(); + } + + // Handle base64-encoded certificate data + let cleanCertData = certData.trim(); + + // Remove any HTML artifacts or unwanted characters, keeping only base64 + cleanCertData = cleanCertData.replace(new RE2("[^A-Za-z0-9+/=\\s]", "g"), "").replace(new RE2("\\s", "g"), ""); + + if (cleanCertData.length < 100) { + throw new BadRequestError({ + message: `Certificate data appears invalid or too short (${cleanCertData.length} characters). The certificate may still be pending.` + }); + } + + // Format as proper PEM certificate with 64 character lines + const formattedCert = cleanCertData.replace(new RE2("(.{64})", "g"), "$1\n").trim(); + const pemCert = `-----BEGIN CERTIFICATE-----\n${formattedCert}\n-----END CERTIFICATE-----`; + + // Validate the constructed PEM before returning + try { + // Test parse to ensure it's valid + const testCert = new x509.X509Certificate(pemCert); + // If we get here, the certificate is valid + if (testCert) { + return pemCert; + } + throw new Error("Certificate validation failed"); + } catch (error) { + throw new BadRequestError({ + message: `Failed to format certificate data from ADCS into valid PEM format: ${error instanceof Error ? error.message : "Unknown error"}` + }); + } + } catch (error) { + if (error instanceof BadRequestError) { + throw error; + } + + if (error instanceof Error) { + let errorMessage = `Failed to retrieve certificate with ID ${certificateId} from ADCS: ${error.message}`; + + if (error.message.includes("404") || error.message.includes("Not Found")) { + errorMessage = `Certificate with ID ${certificateId} not found. It may have been rejected or the ID is invalid.`; + } else if (error.message.includes("401") || error.message.includes("Unauthorized")) { + errorMessage = "Authentication failed while retrieving certificate. Please verify your credentials."; + } else if (error.message.includes("403") || error.message.includes("Forbidden")) { + errorMessage = + "Access denied while retrieving certificate. You may not have permission to access this certificate."; + } else if (error.message.includes("ETIMEDOUT")) { + errorMessage = "Timeout while retrieving certificate. The ADCS server may be overloaded."; + } + + throw new BadRequestError({ message: errorMessage }); + } + + throw new BadRequestError({ + message: `Failed to retrieve certificate with ID ${certificateId} from ADCS: ${error instanceof Error ? error.message : "Unknown error"}` + }); + } +}; + +export const AzureAdCsCertificateAuthorityFns = ({ + appConnectionDAL, + appConnectionService, + certificateAuthorityDAL, + externalCertificateAuthorityDAL, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + kmsService, + projectDAL, + pkiSubscriberDAL +}: TAzureAdCsCertificateAuthorityFnsDeps) => { + const createCertificateAuthority = async ({ + name, + projectId, + configuration, + enableDirectIssuance, + actor, + status + }: { + status: CaStatus; + name: string; + projectId: string; + configuration: TCreateAzureAdCsCertificateAuthorityDTO["configuration"]; + enableDirectIssuance: boolean; + actor: OrgServiceActor; + }) => { + // Azure ADCS does not support direct issuance - enforce this restriction + if (enableDirectIssuance) { + throw new BadRequestError({ + message: "Azure ADCS Certificate Authorities do not support direct issuance" + }); + } + + const { azureAdcsConnectionId } = configuration; + const appConnection = await appConnectionDAL.findById(azureAdcsConnectionId); + + if (!appConnection) { + throw new NotFoundError({ message: `App connection with ID '${azureAdcsConnectionId}' not found` }); + } + + if (appConnection.app !== AppConnection.AzureADCS) { + throw new BadRequestError({ + message: `App connection with ID '${azureAdcsConnectionId}' is not an Azure ADCS connection` + }); + } + + await appConnectionService.connectAppConnectionById( + appConnection.app as AppConnection, + azureAdcsConnectionId, + actor + ); + + const caEntity = await certificateAuthorityDAL.transaction(async (tx) => { + try { + const ca = await certificateAuthorityDAL.create( + { + projectId, + enableDirectIssuance: false, // Always false for Azure ADCS CAs + name, + status + }, + tx + ); + + await externalCertificateAuthorityDAL.create( + { + caId: ca.id, + dnsAppConnectionId: azureAdcsConnectionId, + type: CaType.AZURE_AD_CS, + configuration: {} + }, + tx + ); + + return await certificateAuthorityDAL.findByIdWithAssociatedCa(ca.id, tx); + } catch (error) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any + if ((error as any)?.error?.code === "23505") { + throw new BadRequestError({ + message: "Certificate authority with the same name already exists in your project" + }); + } + throw error; + } + }); + + if (!caEntity.externalCa?.id) { + throw new BadRequestError({ message: "Failed to create external certificate authority" }); + } + + return castDbEntryToAzureAdCsCertificateAuthority(caEntity); + }; + + const updateCertificateAuthority = async ({ + id, + status, + configuration, + enableDirectIssuance, + actor, + name + }: { + id: string; + status?: CaStatus; + configuration: TUpdateAzureAdCsCertificateAuthorityDTO["configuration"]; + enableDirectIssuance?: boolean; + actor: OrgServiceActor; + name?: string; + }) => { + // Azure ADCS does not support direct issuance - enforce this restriction + if (enableDirectIssuance) { + throw new BadRequestError({ + message: "Azure ADCS Certificate Authorities do not support direct issuance" + }); + } + + const updatedCa = await certificateAuthorityDAL.transaction(async (tx) => { + if (configuration) { + const { azureAdcsConnectionId } = configuration; + const appConnection = await appConnectionDAL.findById(azureAdcsConnectionId); + + if (!appConnection) { + throw new NotFoundError({ message: `App connection with ID '${azureAdcsConnectionId}' not found` }); + } + + if (appConnection.app !== AppConnection.AzureADCS) { + throw new BadRequestError({ + message: `App connection with ID '${azureAdcsConnectionId}' is not an Azure ADCS connection` + }); + } + + await appConnectionService.connectAppConnectionById( + appConnection.app as AppConnection, + azureAdcsConnectionId, + actor + ); + + await externalCertificateAuthorityDAL.update( + { + caId: id, + type: CaType.AZURE_AD_CS + }, + { + appConnectionId: azureAdcsConnectionId, + configuration: {} + }, + tx + ); + } + + if (name || status || enableDirectIssuance !== undefined) { + await certificateAuthorityDAL.updateById( + id, + { + name, + status, + enableDirectIssuance: false // Always false for Azure ADCS CAs + }, + tx + ); + } + + return certificateAuthorityDAL.findByIdWithAssociatedCa(id, tx); + }); + + if (!updatedCa.externalCa?.id) { + throw new BadRequestError({ message: "Failed to update external certificate authority" }); + } + + return castDbEntryToAzureAdCsCertificateAuthority(updatedCa); + }; + + const listCertificateAuthorities = async ({ projectId }: { projectId: string }) => { + const cas = await certificateAuthorityDAL.findWithAssociatedCa({ + [`${TableName.CertificateAuthority}.projectId` as "projectId"]: projectId, + [`${TableName.ExternalCertificateAuthority}.type` as "type"]: CaType.AZURE_AD_CS + }); + + return cas.map(castDbEntryToAzureAdCsCertificateAuthority); + }; + + const orderSubscriberCertificate = async (subscriberId: string) => { + const subscriber = await pkiSubscriberDAL.findById(subscriberId); + if (!subscriber.caId) { + throw new BadRequestError({ message: "Subscriber does not have a CA" }); + } + + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(subscriber.caId); + if (!ca.externalCa || ca.externalCa.type !== CaType.AZURE_AD_CS) { + throw new BadRequestError({ message: "CA is not an Azure AD Certificate Service CA" }); + } + + const azureCa = castDbEntryToAzureAdCsCertificateAuthority(ca); + if (azureCa.status !== CaStatus.ACTIVE) { + throw new BadRequestError({ message: "CA is disabled" }); + } + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: ca.projectId, + projectDAL, + kmsService + }); + + const kmsEncryptor = await kmsService.encryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + // Get credentials from the Azure ADCS connection + const { username, password, adcsUrl, sslRejectUnauthorized, sslCertificate } = + await getAzureADCSConnectionCredentials( + azureCa.configuration.azureAdcsConnectionId, + appConnectionDAL, + kmsService + ); + + const credentials: { + username: string; + password: string; + sslRejectUnauthorized?: boolean; + sslCertificate?: string; + } = { + username, + password, + sslRejectUnauthorized, + sslCertificate + }; + + const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048); + const leafKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); + const skLeafObj = crypto.nativeCrypto.KeyObject.from(leafKeys.privateKey); + const skLeaf = skLeafObj.export({ format: "pem", type: "pkcs8" }) as string; + + const subjectDN = buildSubjectDN( + subscriber.commonName, + subscriber.properties as TPkiSubscriberProperties | undefined + ); + + const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({ + name: subjectDN, + keys: leafKeys, + signingAlgorithm: alg + }); + + const csrPem = csrObj.toString("pem"); + + const properties = subscriber.properties as TPkiSubscriberProperties | undefined; + const azureTemplateType = properties?.azureTemplateType; + if (!azureTemplateType || typeof azureTemplateType !== "string") { + throw new BadRequestError({ + message: "Subscriber must have an Azure certificate template configured for Azure ADCS CA" + }); + } + + const templateInput = azureTemplateType.trim(); + if (!templateInput || templateInput.length === 0) { + throw new BadRequestError({ + message: "Certificate template name cannot be empty" + }); + } + + const templateValue = templateInput; + + const certificateRequest: AzureCertificateRequest = { + csr: csrPem, + template: templateValue, + attributes: { + subject: buildSubjectDN(subscriber.commonName, subscriber.properties as TPkiSubscriberProperties | undefined), + subjectAlternativeName: subscriber.subjectAlternativeNames.join(","), + ...(subscriber.ttl && { validityPeriod: subscriber.ttl }) + } + }; + + // Add retry logic for OID caching issues + let submissionResponse; + const maxOidRetries = 3; + let oidRetryCount = 0; + + while (oidRetryCount <= maxOidRetries) { + try { + submissionResponse = await submitCertificateRequest(credentials, adcsUrl, certificateRequest); + break; // Success, exit retry loop + } catch (error) { + const isOidError = + error instanceof BadRequestError && + (error.message.includes("OID resolution error") || error.message.includes("Cannot get OID for name type")); + + if (isOidError && oidRetryCount < maxOidRetries) { + oidRetryCount += 1; + + // Wait before retry with increasing delays: 3s, 6s, 9s + const delay = 3000 * oidRetryCount; + await new Promise((resolve) => { + setTimeout(resolve, delay); + }); + // eslint-disable-next-line no-continue + continue; + } + + // If not an OID error or we've exhausted retries, re-throw the error + throw error; + } + } + + if (!submissionResponse) { + throw new BadRequestError({ + message: "Failed to submit certificate request after multiple attempts due to OID resolution issues" + }); + } + + // Handle both "issued" and "pending" status - ADCS may auto-approve or require manual approval + if (submissionResponse.status === "denied") { + throw new BadRequestError({ message: "Certificate request was denied by ADCS" }); + } + + let certificatePem = ""; + + if (submissionResponse.status === "issued" && submissionResponse.certificate) { + certificatePem = submissionResponse.certificate; + } else { + // For pending certificates, implement a retry mechanism with exponential backoff + const maxRetries = 5; + const initialDelay = 2000; // 2 seconds + let retryCount = 0; + let lastError: Error | null = null; + + // eslint-disable-next-line no-await-in-loop + while (retryCount < maxRetries) { + try { + // eslint-disable-next-line no-await-in-loop + certificatePem = await retrieveCertificate(credentials, adcsUrl, submissionResponse.certificateId); + break; // Success, exit retry loop + } catch (error) { + lastError = error as Error; + // eslint-disable-next-line no-plusplus + retryCount++; + + if (retryCount < maxRetries) { + // Wait with exponential backoff: 2s, 4s, 8s, 16s, 32s + const delay = initialDelay * 2 ** (retryCount - 1); + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => { + setTimeout(resolve, delay); + }); + } + } + } + + if (retryCount === maxRetries) { + throw new BadRequestError({ + message: `Certificate request submitted with ID ${submissionResponse.certificateId} but failed to retrieve after ${maxRetries} attempts. The certificate may still be pending approval or processing. Last error: ${lastError?.message || "Unknown error"}.` + }); + } + } + + // Ensure we have a valid certificate before proceeding + if (!certificatePem) { + throw new BadRequestError({ + message: "Failed to obtain certificate from ADCS. The certificate may still be pending processing." + }); + } + + // Clean and validate the certificate PEM format + let cleanedCertificatePem = certificatePem.trim(); + + // Ensure proper PEM format + if (!cleanedCertificatePem.includes("-----BEGIN CERTIFICATE-----")) { + throw new BadRequestError({ + message: "Invalid certificate format received from ADCS. Expected PEM format." + }); + } + + // Remove any extra whitespace and ensure proper line endings + cleanedCertificatePem = cleanedCertificatePem + .replace(new RE2("\\r\\n", "g"), "\n") + .replace(new RE2("\\r", "g"), "\n") + .trim(); + + // Validate that we have both begin and end markers + if (!cleanedCertificatePem.includes("-----END CERTIFICATE-----")) { + throw new BadRequestError({ + message: "Invalid certificate format received from ADCS. Missing end marker." + }); + } + + let certObj: x509.X509Certificate; + try { + certObj = new x509.X509Certificate(cleanedCertificatePem); + } catch (error) { + throw new BadRequestError({ + message: `Failed to parse certificate from ADCS: ${error instanceof Error ? error.message : "Unknown error"}. Certificate data may be corrupted.` + }); + } + + const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({ + plainText: Buffer.from(new Uint8Array(certObj.rawData)) + }); + + const certificateChainPem = submissionResponse.certificateChain || ""; + + const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({ + plainText: Buffer.from(certificateChainPem) + }); + + const { cipherTextBlob: encryptedPrivateKey } = await kmsEncryptor({ + plainText: Buffer.from(skLeaf) + }); + + await certificateDAL.transaction(async (tx) => { + const cert = await certificateDAL.create( + { + caId: ca.id, + pkiSubscriberId: subscriber.id, + status: CertStatus.ACTIVE, + friendlyName: subscriber.commonName, + commonName: subscriber.commonName, + altNames: subscriber.subjectAlternativeNames.join(","), + serialNumber: certObj.serialNumber, + notBefore: certObj.notBefore, + notAfter: certObj.notAfter, + keyUsages: subscriber.keyUsages as CertKeyUsage[], + extendedKeyUsages: subscriber.extendedKeyUsages as CertExtendedKeyUsage[], + projectId: ca.projectId + }, + tx + ); + + await certificateBodyDAL.create( + { + certId: cert.id, + encryptedCertificate, + encryptedCertificateChain + }, + tx + ); + + await certificateSecretDAL.create( + { + certId: cert.id, + encryptedPrivateKey + }, + tx + ); + }); + + return { + certificate: certificatePem, + certificateChain: certificateChainPem, + privateKey: skLeaf, + serialNumber: certObj.serialNumber, + ca: azureCa, + subscriber + }; + }; + + const getTemplates = async ({ caId, projectId }: { caId: string; projectId: string }) => { + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); + if (!ca || ca.projectId !== projectId) { + throw new NotFoundError({ message: "Certificate Authority not found" }); + } + + const azureCa = castDbEntryToAzureAdCsCertificateAuthority(ca); + const { azureAdcsConnectionId } = azureCa.configuration; + + const appConnection = await appConnectionDAL.findById(azureAdcsConnectionId); + if (!appConnection) { + throw new NotFoundError({ message: `App connection with ID '${azureAdcsConnectionId}' not found` }); + } + + // Get credentials from the Azure ADCS connection + const { username, password, adcsUrl, sslRejectUnauthorized, sslCertificate } = + await getAzureADCSConnectionCredentials(azureAdcsConnectionId, appConnectionDAL, kmsService); + + const credentials: { + username: string; + password: string; + sslRejectUnauthorized?: boolean; + sslCertificate?: string; + } = { + username, + password, + sslRejectUnauthorized, + sslCertificate + }; + + const client = createAdcsHttpClient( + credentials.username, + credentials.password, + adcsUrl, + credentials.sslRejectUnauthorized ?? true, + credentials.sslCertificate + ); + + try { + // Get available templates from ADCS web interface and filter to only usable ones + let availableTemplates: Array<{ id: string; name: string; description: string }> = []; + + try { + const requestFormResponse = await client.get("/certsrv/certrqxt.asp"); + const responseText = requestFormResponse.data; + + // ADCS returns JavaScript-based template info instead of HTML options + // Look for patterns like: getTemplateStringInfo(CTINFO_INDEX_REALNAME, null) and sRealName assignments + const parsedTemplates: Array<{ id: string; name: string }> = []; + + // Extract template names from JavaScript variable assignments like sRealName="WebServer" + const nameRegex = new RE2('sRealName\\s*=\\s*"([^"]+)"', "gi"); + const names: string[] = []; + let nameMatch = nameRegex.exec(responseText); + while (nameMatch !== null) { + names.push(nameMatch[1]); + nameMatch = nameRegex.exec(responseText); + } + + // Extract template IDs/values from encoded strings or other patterns + const valueRegex = new RE2('CertificateTemplate\\s*\\+\\s*"([^"]+)"', "gi"); + const values: string[] = []; + let valueMatch = valueRegex.exec(responseText); + while (valueMatch !== null) { + values.push(valueMatch[1]); + valueMatch = valueRegex.exec(responseText); + } + + // Also look for any remaining HTML option patterns as fallback + const optionRegex = new RE2(']+value="([^"]*)"[^>]*>([^<]*)', "gi"); + let optionMatch = optionRegex.exec(responseText); + while (optionMatch !== null) { + const templateValue = optionMatch[1].trim(); + const templateDisplayName = optionMatch[2].trim(); + + if ( + templateValue && + templateDisplayName && + templateValue !== "" && + templateValue !== "0" && + !templateDisplayName.toLowerCase().includes("select") + ) { + // Parse the encoded template value format: "E;User;1;1;41;16;-1509949440;0;..." + // The template ID is the second semicolon-delimited value + const templateParts = templateValue.split(";"); + const templateId = templateParts.length >= 2 ? templateParts[1] : templateDisplayName; + + parsedTemplates.push({ + id: templateId, + name: templateDisplayName + }); + } + optionMatch = optionRegex.exec(responseText); + } + + // Combine JavaScript-extracted names with any found values + if (names.length > 0) { + names.forEach((name) => { + parsedTemplates.push({ + id: name, + name + }); + }); + } + + // If we successfully parsed templates, use them; otherwise fall back to common ones + if (parsedTemplates.length > 0) { + availableTemplates = parsedTemplates.map((template) => ({ + id: template.id, + name: template.name, + description: `Certificate template: ${template.name}` + })); + } else { + // Fallback to known working templates + availableTemplates = [ + { id: "User", name: "User", description: "User authentication certificate" }, + { id: "WebServer", name: "Web Server", description: "Web server certificate" } + ]; + } + } catch (requestError) { + // Fallback to known working templates if we can't parse the form + availableTemplates = [ + { id: "User", name: "User", description: "User authentication certificate" }, + { id: "WebServer", name: "Web Server", description: "Web server certificate" } + ]; + } + + // Return all available templates - let user decide what to use + return availableTemplates; + } catch (error) { + throw new BadRequestError({ message: "Failed to retrieve certificate templates from Azure ADCS" }); + } + }; + + return { + createCertificateAuthority, + updateCertificateAuthority, + listCertificateAuthorities, + orderSubscriberCertificate, + getTemplates + }; +}; diff --git a/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-schemas.ts b/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-schemas.ts new file mode 100644 index 000000000..2c2dfe484 --- /dev/null +++ b/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-schemas.ts @@ -0,0 +1,29 @@ +import { z } from "zod"; + +import { CaType } from "../certificate-authority-enums"; +import { + BaseCertificateAuthoritySchema, + GenericCreateCertificateAuthorityFieldsSchema, + GenericUpdateCertificateAuthorityFieldsSchema +} from "../certificate-authority-schemas"; + +export const AzureAdCsCertificateAuthorityConfigurationSchema = z.object({ + azureAdcsConnectionId: z.string().uuid().trim().describe("Azure ADCS Connection ID") +}); + +export const AzureAdCsCertificateAuthoritySchema = BaseCertificateAuthoritySchema.extend({ + type: z.literal(CaType.AZURE_AD_CS), + configuration: AzureAdCsCertificateAuthorityConfigurationSchema +}); + +export const CreateAzureAdCsCertificateAuthoritySchema = GenericCreateCertificateAuthorityFieldsSchema( + CaType.AZURE_AD_CS +).extend({ + configuration: AzureAdCsCertificateAuthorityConfigurationSchema +}); + +export const UpdateAzureAdCsCertificateAuthoritySchema = GenericUpdateCertificateAuthorityFieldsSchema( + CaType.AZURE_AD_CS +).extend({ + configuration: AzureAdCsCertificateAuthorityConfigurationSchema.optional() +}); diff --git a/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-types.ts b/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-types.ts new file mode 100644 index 000000000..1c4b3699d --- /dev/null +++ b/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-types.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; + +import { + AzureAdCsCertificateAuthoritySchema, + CreateAzureAdCsCertificateAuthoritySchema, + UpdateAzureAdCsCertificateAuthoritySchema +} from "./azure-ad-cs-certificate-authority-schemas"; + +export type TAzureAdCsCertificateAuthority = z.infer; + +export type TCreateAzureAdCsCertificateAuthorityDTO = z.infer; + +export type TUpdateAzureAdCsCertificateAuthorityDTO = z.infer; diff --git a/backend/src/services/certificate-authority/certificate-authority-enums.ts b/backend/src/services/certificate-authority/certificate-authority-enums.ts index 8de80495e..f9b769a86 100644 --- a/backend/src/services/certificate-authority/certificate-authority-enums.ts +++ b/backend/src/services/certificate-authority/certificate-authority-enums.ts @@ -1,6 +1,7 @@ export enum CaType { INTERNAL = "internal", - ACME = "acme" + ACME = "acme", + AZURE_AD_CS = "azure-ad-cs" } export enum InternalCaType { @@ -17,3 +18,9 @@ export enum CaStatus { export enum CaRenewalType { EXISTING = "existing" } + +export enum CaCapability { + ISSUE_CERTIFICATES = "issue-certificates", + REVOKE_CERTIFICATES = "revoke-certificates", + RENEW_CERTIFICATES = "renew-certificates" +} diff --git a/backend/src/services/certificate-authority/certificate-authority-maps.ts b/backend/src/services/certificate-authority/certificate-authority-maps.ts index d13f65138..ef844a1ed 100644 --- a/backend/src/services/certificate-authority/certificate-authority-maps.ts +++ b/backend/src/services/certificate-authority/certificate-authority-maps.ts @@ -1,6 +1,29 @@ -import { CaType } from "./certificate-authority-enums"; +import { CaCapability, CaType } from "./certificate-authority-enums"; export const CERTIFICATE_AUTHORITIES_TYPE_MAP: Record = { [CaType.INTERNAL]: "Internal", - [CaType.ACME]: "ACME" + [CaType.ACME]: "ACME", + [CaType.AZURE_AD_CS]: "Azure AD Certificate Service" +}; + +export const CERTIFICATE_AUTHORITIES_CAPABILITIES_MAP: Record = { + [CaType.INTERNAL]: [ + CaCapability.ISSUE_CERTIFICATES, + CaCapability.REVOKE_CERTIFICATES, + CaCapability.RENEW_CERTIFICATES + ], + [CaType.ACME]: [CaCapability.ISSUE_CERTIFICATES, CaCapability.REVOKE_CERTIFICATES, CaCapability.RENEW_CERTIFICATES], + [CaType.AZURE_AD_CS]: [ + CaCapability.ISSUE_CERTIFICATES, + CaCapability.RENEW_CERTIFICATES + // Note: REVOKE_CERTIFICATES intentionally omitted - not supported by ADCS connector + ] +}; + +/** + * Check if a certificate authority type supports a specific capability + */ +export const caSupportsCapability = (caType: CaType, capability: CaCapability): boolean => { + const capabilities = CERTIFICATE_AUTHORITIES_CAPABILITIES_MAP[caType] || []; + return capabilities.includes(capability); }; diff --git a/backend/src/services/certificate-authority/certificate-authority-queue.ts b/backend/src/services/certificate-authority/certificate-authority-queue.ts index afe17ec5f..0e015da03 100644 --- a/backend/src/services/certificate-authority/certificate-authority-queue.ts +++ b/backend/src/services/certificate-authority/certificate-authority-queue.ts @@ -21,6 +21,7 @@ import { TCertificateSecretDALFactory } from "../certificate/certificate-secret- import { TPkiSubscriberDALFactory } from "../pki-subscriber/pki-subscriber-dal"; import { SubscriberOperationStatus } from "../pki-subscriber/pki-subscriber-types"; import { AcmeCertificateAuthorityFns } from "./acme/acme-certificate-authority-fns"; +import { AzureAdCsCertificateAuthorityFns } from "./azure-ad-cs/azure-ad-cs-certificate-authority-fns"; import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal"; import { CaType } from "./certificate-authority-enums"; import { keyAlgorithmToAlgCfg } from "./certificate-authority-fns"; @@ -33,7 +34,7 @@ import { type TCertificateAuthorityQueueFactoryDep = { certificateAuthorityDAL: TCertificateAuthorityDALFactory; - appConnectionDAL: Pick; + appConnectionDAL: Pick; appConnectionService: Pick; externalCertificateAuthorityDAL: Pick; keyStore: Pick; @@ -82,6 +83,19 @@ export const certificateAuthorityQueueFactory = ({ projectDAL }); + const azureAdCsFns = AzureAdCsCertificateAuthorityFns({ + appConnectionDAL, + appConnectionService, + certificateAuthorityDAL, + externalCertificateAuthorityDAL, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + kmsService, + pkiSubscriberDAL, + projectDAL + }); + // TODO 1: auto-periodic rotation // TODO 2: manual rotation @@ -158,6 +172,13 @@ export const certificateAuthorityQueueFactory = ({ lastOperationMessage: "Certificate ordered successfully", lastOperationAt: new Date() }); + } else if (caType === CaType.AZURE_AD_CS) { + await azureAdCsFns.orderSubscriberCertificate(subscriberId); + await pkiSubscriberDAL.updateById(subscriberId, { + lastOperationStatus: SubscriberOperationStatus.SUCCESS, + lastOperationMessage: "Certificate ordered successfully", + lastOperationAt: new Date() + }); } } catch (e: unknown) { if (e instanceof Error) { diff --git a/backend/src/services/certificate-authority/certificate-authority-service.ts b/backend/src/services/certificate-authority/certificate-authority-service.ts index fa0fe017f..6cf55fc52 100644 --- a/backend/src/services/certificate-authority/certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/certificate-authority-service.ts @@ -22,6 +22,14 @@ import { TCreateAcmeCertificateAuthorityDTO, TUpdateAcmeCertificateAuthorityDTO } from "./acme/acme-certificate-authority-types"; +import { + AzureAdCsCertificateAuthorityFns, + castDbEntryToAzureAdCsCertificateAuthority +} from "./azure-ad-cs/azure-ad-cs-certificate-authority-fns"; +import { + TCreateAzureAdCsCertificateAuthorityDTO, + TUpdateAzureAdCsCertificateAuthorityDTO +} from "./azure-ad-cs/azure-ad-cs-certificate-authority-types"; import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal"; import { CaType } from "./certificate-authority-enums"; import { @@ -34,7 +42,7 @@ import { TInternalCertificateAuthorityServiceFactory } from "./internal/internal import { TCreateInternalCertificateAuthorityDTO } from "./internal/internal-certificate-authority-types"; type TCertificateAuthorityServiceFactoryDep = { - appConnectionDAL: Pick; + appConnectionDAL: Pick; appConnectionService: Pick; certificateAuthorityDAL: Pick< TCertificateAuthorityDALFactory, @@ -91,6 +99,19 @@ export const certificateAuthorityServiceFactory = ({ projectDAL }); + const azureAdCsFns = AzureAdCsCertificateAuthorityFns({ + appConnectionDAL, + appConnectionService, + certificateAuthorityDAL, + externalCertificateAuthorityDAL, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + kmsService, + pkiSubscriberDAL, + projectDAL + }); + const createCertificateAuthority = async ( { type, projectId, name, enableDirectIssuance, configuration, status }: TCreateCertificateAuthorityDTO, actor: OrgServiceActor @@ -146,6 +167,17 @@ export const certificateAuthorityServiceFactory = ({ }); } + if (type === CaType.AZURE_AD_CS) { + return azureAdCsFns.createCertificateAuthority({ + name, + projectId, + configuration: configuration as TCreateAzureAdCsCertificateAuthorityDTO["configuration"], + enableDirectIssuance, + status, + actor + }); + } + throw new BadRequestError({ message: "Invalid certificate authority type" }); }; @@ -205,6 +237,10 @@ export const certificateAuthorityServiceFactory = ({ return castDbEntryToAcmeCertificateAuthority(certificateAuthority); } + if (type === CaType.AZURE_AD_CS) { + return castDbEntryToAzureAdCsCertificateAuthority(certificateAuthority); + } + throw new BadRequestError({ message: "Invalid certificate authority type" }); }; @@ -249,6 +285,10 @@ export const certificateAuthorityServiceFactory = ({ return acmeFns.listCertificateAuthorities({ projectId }); } + if (type === CaType.AZURE_AD_CS) { + return azureAdCsFns.listCertificateAuthorities({ projectId }); + } + throw new BadRequestError({ message: "Invalid certificate authority type" }); }; @@ -323,6 +363,17 @@ export const certificateAuthorityServiceFactory = ({ }); } + if (type === CaType.AZURE_AD_CS) { + return azureAdCsFns.updateCertificateAuthority({ + id: certificateAuthority.id, + configuration: configuration as TUpdateAzureAdCsCertificateAuthorityDTO["configuration"], + enableDirectIssuance, + actor, + status, + name + }); + } + throw new BadRequestError({ message: "Invalid certificate authority type" }); }; @@ -384,14 +435,54 @@ export const certificateAuthorityServiceFactory = ({ return castDbEntryToAcmeCertificateAuthority(certificateAuthority); } + if (type === CaType.AZURE_AD_CS) { + return castDbEntryToAzureAdCsCertificateAuthority(certificateAuthority); + } + throw new BadRequestError({ message: "Invalid certificate authority type" }); }; + const getAzureAdcsTemplates = async ({ + caId, + projectId, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: { + caId: string; + projectId: string; + actor: OrgServiceActor["type"]; + actorId: string; + actorAuthMethod: OrgServiceActor["authMethod"]; + actorOrgId?: string; + }) => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionActions.Read, + ProjectPermissionSub.CertificateAuthorities + ); + + return azureAdCsFns.getTemplates({ + caId, + projectId + }); + }; + return { createCertificateAuthority, findCertificateAuthorityByNameAndProjectId, listCertificateAuthoritiesByProjectId, updateCertificateAuthority, - deleteCertificateAuthority + deleteCertificateAuthority, + getAzureAdcsTemplates }; }; diff --git a/backend/src/services/certificate-authority/certificate-authority-types.ts b/backend/src/services/certificate-authority/certificate-authority-types.ts index d76330bd8..13b5cec40 100644 --- a/backend/src/services/certificate-authority/certificate-authority-types.ts +++ b/backend/src/services/certificate-authority/certificate-authority-types.ts @@ -1,13 +1,23 @@ import { TAcmeCertificateAuthority, TAcmeCertificateAuthorityInput } from "./acme/acme-certificate-authority-types"; +import { + TAzureAdCsCertificateAuthority, + TCreateAzureAdCsCertificateAuthorityDTO +} from "./azure-ad-cs/azure-ad-cs-certificate-authority-types"; import { CaType } from "./certificate-authority-enums"; import { TInternalCertificateAuthority, TInternalCertificateAuthorityInput } from "./internal/internal-certificate-authority-types"; -export type TCertificateAuthority = TInternalCertificateAuthority | TAcmeCertificateAuthority; +export type TCertificateAuthority = + | TInternalCertificateAuthority + | TAcmeCertificateAuthority + | TAzureAdCsCertificateAuthority; -export type TCertificateAuthorityInput = TInternalCertificateAuthorityInput | TAcmeCertificateAuthorityInput; +export type TCertificateAuthorityInput = + | TInternalCertificateAuthorityInput + | TAcmeCertificateAuthorityInput + | TCreateAzureAdCsCertificateAuthorityDTO; export type TCreateCertificateAuthorityDTO = Omit; diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts index 5671435f6..9f1a5e5c2 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-fns.ts @@ -36,12 +36,18 @@ import { validateAndMapAltNameType } from "../certificate-authority-validators"; import { TIssueCertWithTemplateDTO } from "./internal-certificate-authority-types"; type TInternalCertificateAuthorityFnsDeps = { - certificateAuthorityDAL: Pick; + certificateAuthorityDAL: Pick< + TCertificateAuthorityDALFactory, + "findByIdWithAssociatedCa" | "findById" | "create" | "transaction" | "updateById" | "findWithAssociatedCa" + >; certificateAuthorityCertDAL: Pick; certificateAuthoritySecretDAL: Pick; certificateAuthorityCrlDAL: Pick; projectDAL: Pick; - kmsService: Pick; + kmsService: Pick< + TKmsServiceFactory, + "decryptWithKmsKey" | "encryptWithKmsKey" | "generateKmsKey" | "createCipherPairWithDataKey" + >; certificateDAL: Pick; certificateBodyDAL: Pick; certificateSecretDAL: Pick; diff --git a/backend/src/services/certificate/certificate-service.ts b/backend/src/services/certificate/certificate-service.ts index de6da16ee..7adc60d52 100644 --- a/backend/src/services/certificate/certificate-service.ts +++ b/backend/src/services/certificate/certificate-service.ts @@ -14,6 +14,8 @@ import { TCertificateBodyDALFactory } from "@app/services/certificate/certificat import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; import { TCertificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal"; import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; +import { CaCapability, CaType } from "@app/services/certificate-authority/certificate-authority-enums"; +import { caSupportsCapability } from "@app/services/certificate-authority/certificate-authority-maps"; import { TCertificateAuthoritySecretDALFactory } from "@app/services/certificate-authority/certificate-authority-secret-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TPkiCollectionDALFactory } from "@app/services/pki-collection/pki-collection-dal"; @@ -184,9 +186,11 @@ export const certificateServiceFactory = ({ const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(cert.caId); - if (ca.externalCa?.id) { + // Check if the CA type supports revocation + const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL; + if (!caSupportsCapability(caType, CaCapability.REVOKE_CERTIFICATES)) { throw new BadRequestError({ - message: "Cannot revoke external certificates" + message: "Certificate revocation is not supported by this certificate authority type" }); } @@ -218,18 +222,37 @@ export const certificateServiceFactory = ({ } ); - // rebuild CRL (TODO: move to interval-based cron job) - await rebuildCaCrl({ - caId: ca.id, - certificateAuthorityDAL, - certificateAuthorityCrlDAL, - certificateAuthoritySecretDAL, - projectDAL, - certificateDAL, - kmsService - }); + // Note: External CA revocation handling would go here for supported CA types + // Currently, only internal CAs and ACME CAs support revocation - return { revokedAt, cert, ca: expandInternalCa(ca) }; + // rebuild CRL (TODO: move to interval-based cron job) + // Only rebuild CRL for internal CAs - external CAs manage their own CRLs + if (!ca.externalCa?.id) { + await rebuildCaCrl({ + caId: ca.id, + certificateAuthorityDAL, + certificateAuthorityCrlDAL, + certificateAuthoritySecretDAL, + projectDAL, + certificateDAL, + kmsService + }); + } + + // Return appropriate CA format based on CA type + const caResult = ca.externalCa?.id + ? { + id: ca.id, + name: ca.name, + projectId: ca.projectId, + status: ca.status, + enableDirectIssuance: ca.enableDirectIssuance, + type: ca.externalCa.type, + externalCa: ca.externalCa + } + : expandInternalCa(ca); + + return { revokedAt, cert, ca: caResult }; }; /** diff --git a/backend/src/services/external-migration/external-migration-fns/vault.ts b/backend/src/services/external-migration/external-migration-fns/vault.ts index 1ebd56a80..f5f57aa1b 100644 --- a/backend/src/services/external-migration/external-migration-fns/vault.ts +++ b/backend/src/services/external-migration/external-migration-fns/vault.ts @@ -1,12 +1,21 @@ +import https from "node:https"; + import axios, { AxiosInstance } from "axios"; import { v4 as uuidv4 } from "uuid"; +import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { BadRequestError } from "@app/lib/errors"; +import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway"; import { logger } from "@app/lib/logger"; import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; import { InfisicalImportData, VaultMappingType } from "../external-migration-types"; +enum KvVersion { + V1 = "1", + V2 = "2" +} + type VaultData = { namespace: string; mount: string; @@ -14,7 +23,42 @@ type VaultData = { secretData: Record; }; -const vaultFactory = () => { +const vaultFactory = (gatewayService: Pick) => { + const $gatewayProxyWrapper = async ( + inputs: { + gatewayId: string; + targetHost?: string; + targetPort?: number; + }, + gatewayCallback: (host: string, port: number, httpsAgent?: https.Agent) => Promise + ): Promise => { + const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(inputs.gatewayId); + const [relayHost, relayPort] = relayDetails.relayAddress.split(":"); + + const callbackResult = await withGatewayProxy( + async (port, httpsAgent) => { + const res = await gatewayCallback("http://localhost", port, httpsAgent); + return res; + }, + { + protocol: GatewayProxyProtocol.Http, + targetHost: inputs.targetHost, + targetPort: inputs.targetPort, + relayHost, + relayPort: Number(relayPort), + identityId: relayDetails.identityId, + orgId: relayDetails.orgId, + tlsOptions: { + ca: relayDetails.certChain, + cert: relayDetails.certificate, + key: relayDetails.privateKey.toString() + } + } + ); + + return callbackResult; + }; + const getMounts = async (request: AxiosInstance) => { const response = await request .get<{ @@ -31,11 +75,24 @@ const vaultFactory = () => { const getPaths = async ( request: AxiosInstance, - { mountPath, secretPath = "" }: { mountPath: string; secretPath?: string } + { mountPath, secretPath = "" }: { mountPath: string; secretPath?: string }, + kvVersion: KvVersion ) => { try { - // For KV v2: /v1/{mount}/metadata/{path}?list=true - const path = secretPath ? `${mountPath}/metadata/${secretPath}` : `${mountPath}/metadata`; + if (kvVersion === KvVersion.V2) { + // For KV v2: /v1/{mount}/metadata/{path}?list=true + const path = secretPath ? `${mountPath}/metadata/${secretPath}` : `${mountPath}/metadata`; + const response = await request.get<{ + data: { + keys: string[]; + }; + }>(`/v1/${path}?list=true`); + + return response.data.data.keys; + } + + // kv version v1: /v1/{mount}?list=true + const path = secretPath ? `${mountPath}/${secretPath}` : mountPath; const response = await request.get<{ data: { keys: string[]; @@ -56,21 +113,42 @@ const vaultFactory = () => { const getSecrets = async ( request: AxiosInstance, - { mountPath, secretPath }: { mountPath: string; secretPath: string } + { mountPath, secretPath }: { mountPath: string; secretPath: string }, + kvVersion: KvVersion ) => { - // For KV v2: /v1/{mount}/data/{path} + if (kvVersion === KvVersion.V2) { + // For KV v2: /v1/{mount}/data/{path} + const response = await request + .get<{ + data: { + data: Record; // KV v2 has nested data structure + metadata: { + created_time: string; + deletion_time: string; + destroyed: boolean; + version: number; + }; + }; + }>(`/v1/${mountPath}/data/${secretPath}`) + .catch((err) => { + if (axios.isAxiosError(err)) { + logger.error(err.response?.data, "External migration: Failed to get Vault secret"); + } + throw err; + }); + + return response.data.data.data; + } + + // kv version v1 + const response = await request .get<{ - data: { - data: Record; // KV v2 has nested data structure - metadata: { - created_time: string; - deletion_time: string; - destroyed: boolean; - version: number; - }; - }; - }>(`/v1/${mountPath}/data/${secretPath}`) + data: Record; // KV v1 has flat data structure + lease_duration: number; + lease_id: string; + renewable: boolean; + }>(`/v1/${mountPath}/${secretPath}`) .catch((err) => { if (axios.isAxiosError(err)) { logger.error(err.response?.data, "External migration: Failed to get Vault secret"); @@ -78,7 +156,7 @@ const vaultFactory = () => { throw err; }); - return response.data.data.data; + return response.data.data; }; // helper function to check if a mount is KV v2 (will be useful if we add support for Vault KV v1) @@ -89,9 +167,10 @@ const vaultFactory = () => { const recursivelyGetAllPaths = async ( request: AxiosInstance, mountPath: string, + kvVersion: KvVersion, currentPath: string = "" ): Promise => { - const paths = await getPaths(request, { mountPath, secretPath: currentPath }); + const paths = await getPaths(request, { mountPath, secretPath: currentPath }, kvVersion); if (paths === null || paths.length === 0) { return []; @@ -105,7 +184,7 @@ const vaultFactory = () => { if (path.endsWith("/")) { // it's a folder so we recurse into it - const subSecrets = await recursivelyGetAllPaths(request, mountPath, fullItemPath); + const subSecrets = await recursivelyGetAllPaths(request, mountPath, kvVersion, fullItemPath); allSecrets.push(...subSecrets); } else { // it's a secret so we add it to our results @@ -119,60 +198,93 @@ const vaultFactory = () => { async function collectVaultData({ baseUrl, namespace, - accessToken + accessToken, + gatewayId }: { baseUrl: string; namespace?: string; accessToken: string; + gatewayId?: string; }): Promise { - const request = axios.create({ - baseURL: baseUrl, - headers: { - "X-Vault-Token": accessToken, - ...(namespace ? { "X-Vault-Namespace": namespace } : {}) + const getData = async (host: string, port?: number, httpsAgent?: https.Agent) => { + const allData: VaultData[] = []; + + const request = axios.create({ + baseURL: port ? `${host}:${port}` : host, + headers: { + "X-Vault-Token": accessToken, + ...(namespace ? { "X-Vault-Namespace": namespace } : {}) + }, + httpsAgent + }); + + // Get all mounts in this namespace + const mounts = await getMounts(request); + + for (const mount of Object.keys(mounts)) { + if (!mount.endsWith("/")) { + delete mounts[mount]; + } } - }); - const allData: VaultData[] = []; + for await (const [mountPath, mountInfo] of Object.entries(mounts)) { + // skip non-KV mounts + if (!mountInfo.type.startsWith("kv")) { + // eslint-disable-next-line no-continue + continue; + } - // Get all mounts in this namespace - const mounts = await getMounts(request); + const kvVersion = mountInfo.options?.version === "2" ? KvVersion.V2 : KvVersion.V1; - for (const mount of Object.keys(mounts)) { - if (!mount.endsWith("/")) { - delete mounts[mount]; + // get all paths in this mount + const paths = await recursivelyGetAllPaths(request, `${mountPath.replace(/\/$/, "")}`, kvVersion); + + const cleanMountPath = mountPath.replace(/\/$/, ""); + + for await (const secretPath of paths) { + // get the actual secret data + const secretData = await getSecrets( + request, + { + mountPath: cleanMountPath, + secretPath: secretPath.replace(`${cleanMountPath}/`, "") + }, + kvVersion + ); + + allData.push({ + namespace: namespace || "", + mount: mountPath.replace(/\/$/, ""), + path: secretPath.replace(`${cleanMountPath}/`, ""), + secretData + }); + } } + + return allData; + }; + + let data; + + if (gatewayId) { + const url = new URL(baseUrl); + + const { port, protocol, hostname } = url; + const cleanedProtocol = protocol.slice(0, -1); + + data = await $gatewayProxyWrapper( + { + gatewayId, + targetHost: `${cleanedProtocol}://${hostname}`, + targetPort: port ? Number(port) : 8200 // 8200, default port for Vault self-hosted/dedicated + }, + getData + ); + } else { + data = await getData(baseUrl); } - for await (const [mountPath, mountInfo] of Object.entries(mounts)) { - // skip non-KV mounts - if (!mountInfo.type.startsWith("kv")) { - // eslint-disable-next-line no-continue - continue; - } - - // get all paths in this mount - const paths = await recursivelyGetAllPaths(request, `${mountPath.replace(/\/$/, "")}`); - - const cleanMountPath = mountPath.replace(/\/$/, ""); - - for await (const secretPath of paths) { - // get the actual secret data - const secretData = await getSecrets(request, { - mountPath: cleanMountPath, - secretPath: secretPath.replace(`${cleanMountPath}/`, "") - }); - - allData.push({ - namespace: namespace || "", - mount: mountPath.replace(/\/$/, ""), - path: secretPath.replace(`${cleanMountPath}/`, ""), - secretData - }); - } - } - - return allData; + return data; } return { @@ -296,17 +408,22 @@ export const transformToInfisicalFormatNamespaceToProjects = ( }; }; -export const importVaultDataFn = async ({ - vaultAccessToken, - vaultNamespace, - vaultUrl, - mappingType -}: { - vaultAccessToken: string; - vaultNamespace?: string; - vaultUrl: string; - mappingType: VaultMappingType; -}) => { +export const importVaultDataFn = async ( + { + vaultAccessToken, + vaultNamespace, + vaultUrl, + mappingType, + gatewayId + }: { + vaultAccessToken: string; + vaultNamespace?: string; + vaultUrl: string; + mappingType: VaultMappingType; + gatewayId?: string; + }, + { gatewayService }: { gatewayService: Pick } +) => { await blockLocalAndPrivateIpAddresses(vaultUrl); if (mappingType === VaultMappingType.Namespace && !vaultNamespace) { @@ -315,12 +432,13 @@ export const importVaultDataFn = async ({ }); } - const vaultApi = vaultFactory(); + const vaultApi = vaultFactory(gatewayService); const vaultData = await vaultApi.collectVaultData({ accessToken: vaultAccessToken, baseUrl: vaultUrl, - namespace: vaultNamespace + namespace: vaultNamespace, + gatewayId }); const infisicalData = transformToInfisicalFormatNamespaceToProjects(vaultData, mappingType); diff --git a/backend/src/services/external-migration/external-migration-service.ts b/backend/src/services/external-migration/external-migration-service.ts index 3bbb88f5b..f1047d0ee 100644 --- a/backend/src/services/external-migration/external-migration-service.ts +++ b/backend/src/services/external-migration/external-migration-service.ts @@ -1,4 +1,5 @@ import { OrgMembershipRole } from "@app/db/schemas"; +import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors"; @@ -12,6 +13,7 @@ type TExternalMigrationServiceFactoryDep = { permissionService: TPermissionServiceFactory; externalMigrationQueue: TExternalMigrationQueueFactory; userDAL: Pick; + gatewayService: Pick; }; export type TExternalMigrationServiceFactory = ReturnType; @@ -19,7 +21,8 @@ export type TExternalMigrationServiceFactory = ReturnType { const importEnvKeyData = async ({ decryptionKey, @@ -72,6 +75,7 @@ export const externalMigrationServiceFactory = ({ vaultNamespace, mappingType, vaultUrl, + gatewayId, actor, actorId, actorOrgId, @@ -91,12 +95,18 @@ export const externalMigrationServiceFactory = ({ const user = await userDAL.findById(actorId); - const vaultData = await importVaultDataFn({ - vaultAccessToken, - vaultNamespace, - vaultUrl, - mappingType - }); + const vaultData = await importVaultDataFn( + { + vaultAccessToken, + vaultNamespace, + vaultUrl, + mappingType, + gatewayId + }, + { + gatewayService + } + ); const stringifiedJson = JSON.stringify({ data: vaultData, diff --git a/backend/src/services/external-migration/external-migration-types.ts b/backend/src/services/external-migration/external-migration-types.ts index 7c0d4c9ea..ac8ff44e2 100644 --- a/backend/src/services/external-migration/external-migration-types.ts +++ b/backend/src/services/external-migration/external-migration-types.ts @@ -31,6 +31,7 @@ export type TImportVaultDataDTO = { vaultNamespace?: string; mappingType: VaultMappingType; vaultUrl: string; + gatewayId?: string; } & Omit; export type TImportInfisicalDataCreate = { diff --git a/backend/src/services/group-project/group-project-dal.ts b/backend/src/services/group-project/group-project-dal.ts index a776f7245..263838a9d 100644 --- a/backend/src/services/group-project/group-project-dal.ts +++ b/backend/src/services/group-project/group-project-dal.ts @@ -156,6 +156,7 @@ export const groupProjectDALFactory = (db: TDbClient) => { `${TableName.GroupProjectMembershipRole}.customRoleId`, `${TableName.ProjectRoles}.id` ) + .join(TableName.OrgMembership, `${TableName.Users}.id`, `${TableName.OrgMembership}.userId`) .select( db.ref("id").withSchema(TableName.UserGroupMembership), db.ref("createdAt").withSchema(TableName.UserGroupMembership), @@ -176,7 +177,8 @@ export const groupProjectDALFactory = (db: TDbClient) => { db.ref("temporaryRange").withSchema(TableName.GroupProjectMembershipRole), db.ref("temporaryAccessStartTime").withSchema(TableName.GroupProjectMembershipRole), db.ref("temporaryAccessEndTime").withSchema(TableName.GroupProjectMembershipRole), - db.ref("name").as("projectName").withSchema(TableName.Project) + db.ref("name").as("projectName").withSchema(TableName.Project), + db.ref("isActive").withSchema(TableName.OrgMembership) ) .where({ isGhost: false }); @@ -192,7 +194,8 @@ export const groupProjectDALFactory = (db: TDbClient) => { id, userId, projectName, - createdAt + createdAt, + isActive }) => ({ isGroupMember: true, id, @@ -202,7 +205,7 @@ export const groupProjectDALFactory = (db: TDbClient) => { id: projectId, name: projectName }, - user: { email, username, firstName, lastName, id: userId, publicKey, isGhost }, + user: { email, username, firstName, lastName, id: userId, publicKey, isGhost, isOrgMembershipActive: isActive }, createdAt }), key: "id", diff --git a/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts b/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts index 7b0a19414..9cc851437 100644 --- a/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts +++ b/backend/src/services/identity-jwt-auth/identity-jwt-auth-service.ts @@ -21,7 +21,7 @@ import { UnauthorizedError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; -import { getStringValueByDot } from "@app/lib/template/dot-access"; +import { getValueByDot } from "@app/lib/template/dot-access"; import { ActorType, AuthTokenType } from "../auth/auth-type"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; @@ -189,7 +189,7 @@ export const identityJwtAuthServiceFactory = ({ if (identityJwtAuth.boundClaims) { Object.keys(identityJwtAuth.boundClaims).forEach((claimKey) => { const claimValue = (identityJwtAuth.boundClaims as Record)[claimKey]; - const value = getStringValueByDot(tokenData, claimKey) || ""; + const value = getValueByDot(tokenData, claimKey); if (!value) { throw new UnauthorizedError({ @@ -198,9 +198,7 @@ export const identityJwtAuthServiceFactory = ({ } // handle both single and multi-valued claims - if ( - !claimValue.split(", ").some((claimEntry) => doesFieldValueMatchJwtPolicy(tokenData[claimKey], claimEntry)) - ) { + if (!claimValue.split(", ").some((claimEntry) => doesFieldValueMatchJwtPolicy(value, claimEntry))) { throw new UnauthorizedError({ message: `Access denied: claim mismatch for field ${claimKey}` }); diff --git a/backend/src/services/identity-oidc-auth/identity-oidc-auth-fns.ts b/backend/src/services/identity-oidc-auth/identity-oidc-auth-fns.ts index 7d386afcb..8ed134cba 100644 --- a/backend/src/services/identity-oidc-auth/identity-oidc-auth-fns.ts +++ b/backend/src/services/identity-oidc-auth/identity-oidc-auth-fns.ts @@ -1,7 +1,16 @@ import picomatch from "picomatch"; -export const doesFieldValueMatchOidcPolicy = (fieldValue: string, policyValue: string) => - policyValue === fieldValue || picomatch.isMatch(fieldValue, policyValue); +export const doesFieldValueMatchOidcPolicy = (fieldValue: string | number | boolean, policyValue: string) => { + if (typeof fieldValue === "boolean") { + return fieldValue === (policyValue === "true"); + } + + if (typeof fieldValue === "number") { + return fieldValue === parseInt(policyValue, 10); + } + + return policyValue === fieldValue || picomatch.isMatch(fieldValue, policyValue); +}; export const doesAudValueMatchOidcPolicy = (fieldValue: string | string[], policyValue: string) => { if (Array.isArray(fieldValue)) { diff --git a/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts b/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts index 617b21a1f..6585e61f3 100644 --- a/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts +++ b/backend/src/services/identity-oidc-auth/identity-oidc-auth-service.ts @@ -22,7 +22,7 @@ import { UnauthorizedError } from "@app/lib/errors"; import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip"; -import { getStringValueByDot } from "@app/lib/template/dot-access"; +import { getValueByDot } from "@app/lib/template/dot-access"; import { ActorType, AuthTokenType } from "../auth/auth-type"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; @@ -146,7 +146,7 @@ export const identityOidcAuthServiceFactory = ({ if (identityOidcAuth.boundClaims) { Object.keys(identityOidcAuth.boundClaims).forEach((claimKey) => { const claimValue = (identityOidcAuth.boundClaims as Record)[claimKey]; - const value = getStringValueByDot(tokenData, claimKey) || ""; + const value = getValueByDot(tokenData, claimKey); if (!value) { throw new UnauthorizedError({ @@ -167,13 +167,13 @@ export const identityOidcAuthServiceFactory = ({ if (identityOidcAuth.claimMetadataMapping) { Object.keys(identityOidcAuth.claimMetadataMapping).forEach((permissionKey) => { const claimKey = (identityOidcAuth.claimMetadataMapping as Record)[permissionKey]; - const value = getStringValueByDot(tokenData, claimKey) || ""; + const value = getValueByDot(tokenData, claimKey); if (!value) { throw new UnauthorizedError({ message: `Access denied: token has no ${claimKey} field` }); } - filteredClaims[permissionKey] = value; + filteredClaims[permissionKey] = value.toString(); }); } diff --git a/backend/src/services/identity-ua/identity-ua-service.ts b/backend/src/services/identity-ua/identity-ua-service.ts index cd7211b5c..597747683 100644 --- a/backend/src/services/identity-ua/identity-ua-service.ts +++ b/backend/src/services/identity-ua/identity-ua-service.ts @@ -8,10 +8,18 @@ import { validatePrivilegeChangeOperation } from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; -import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors"; +import { + BadRequestError, + NotFoundError, + PermissionBoundaryError, + RateLimitError, + UnauthorizedError +} from "@app/lib/errors"; import { checkIPAgainstBlocklist, extractIPDetails, isValidIpOrCidr, TIp } from "@app/lib/ip"; +import { logger } from "@app/lib/logger"; import { ActorType, AuthTokenType } from "../auth/auth-type"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; @@ -22,6 +30,7 @@ import { TIdentityUaClientSecretDALFactory } from "./identity-ua-client-secret-d import { TIdentityUaDALFactory } from "./identity-ua-dal"; import { TAttachUaDTO, + TClearUaLockoutsDTO, TCreateUaClientSecretDTO, TGetUaClientSecretsDTO, TGetUaDTO, @@ -38,30 +47,33 @@ type TIdentityUaServiceFactoryDep = { identityOrgMembershipDAL: TIdentityOrgDALFactory; permissionService: Pick; licenseService: Pick; + keyStore: Pick< + TKeyStoreFactory, + "setItemWithExpiry" | "getItem" | "deleteItem" | "getKeysByPattern" | "deleteItems" | "acquireLock" + >; }; export type TIdentityUaServiceFactory = ReturnType; +type LockoutObject = { + lockedOut: boolean; + failedAttempts: number; +}; + export const identityUaServiceFactory = ({ identityUaDAL, identityUaClientSecretDAL, identityAccessTokenDAL, identityOrgMembershipDAL, permissionService, - licenseService + licenseService, + keyStore }: TIdentityUaServiceFactoryDep) => { const login = async (clientId: string, clientSecret: string, ip: string) => { const identityUa = await identityUaDAL.findOne({ clientId }); if (!identityUa) { - throw new NotFoundError({ - message: "No identity with specified client ID was found" - }); - } - - const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityUa.identityId }); - if (!identityMembershipOrg) { - throw new NotFoundError({ - message: "No identity with the org membership was found" + throw new UnauthorizedError({ + message: "Invalid credentials" }); } @@ -69,119 +81,184 @@ export const identityUaServiceFactory = ({ ipAddress: ip, trustedIps: identityUa.clientSecretTrustedIps as TIp[] }); - const clientSecretPrefix = clientSecret.slice(0, 4); - const clientSecrtInfo = await identityUaClientSecretDAL.find({ - identityUAId: identityUa.id, - isClientSecretRevoked: false, - clientSecretPrefix - }); - let validClientSecretInfo: (typeof clientSecrtInfo)[0] | null = null; - for await (const info of clientSecrtInfo) { - const isMatch = await crypto.hashing().compareHash(clientSecret, info.clientSecretHash); + const LOCKOUT_KEY = `lockout:identity:${identityUa.identityId}:${IdentityAuthMethod.UNIVERSAL_AUTH}:${clientId}`; - if (isMatch) { - validClientSecretInfo = info; - break; - } + let lock: Awaited>; + try { + lock = await keyStore.acquireLock([KeyStorePrefixes.IdentityLockoutLock(LOCKOUT_KEY)], 500, { + retryCount: 3, + retryDelay: 300, + retryJitter: 100 + }); + } catch (e) { + logger.info( + `identity login failed to acquire lock [identityId=${identityUa.identityId}] [authMethod=${IdentityAuthMethod.UNIVERSAL_AUTH}]` + ); + throw new RateLimitError({ message: "Rate limit exceeded" }); } - if (!validClientSecretInfo) throw new UnauthorizedError({ message: "Invalid credentials" }); + try { + const lockoutRaw = await keyStore.getItem(LOCKOUT_KEY); - const { clientSecretTTL, clientSecretNumUses, clientSecretNumUsesLimit } = validClientSecretInfo; - if (Number(clientSecretTTL) > 0) { - const clientSecretCreated = new Date(validClientSecretInfo.createdAt); - const ttlInMilliseconds = Number(clientSecretTTL) * 1000; - const currentDate = new Date(); - const expirationTime = new Date(clientSecretCreated.getTime() + ttlInMilliseconds); + let lockout: LockoutObject | undefined; + if (lockoutRaw) { + lockout = JSON.parse(lockoutRaw) as LockoutObject; + } - if (currentDate > expirationTime) { + if (lockout && lockout.lockedOut) { + throw new UnauthorizedError({ + message: "This identity auth method is temporarily locked, please try again later" + }); + } + + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId: identityUa.identityId }); + if (!identityMembershipOrg) { + throw new UnauthorizedError({ + message: "Invalid credentials" + }); + } + + const clientSecretPrefix = clientSecret.slice(0, 4); + const clientSecretInfo = await identityUaClientSecretDAL.find({ + identityUAId: identityUa.id, + isClientSecretRevoked: false, + clientSecretPrefix + }); + + let validClientSecretInfo: (typeof clientSecretInfo)[0] | null = null; + for await (const info of clientSecretInfo) { + const isMatch = await crypto.hashing().compareHash(clientSecret, info.clientSecretHash); + + if (isMatch) { + validClientSecretInfo = info; + break; + } + } + + if (!validClientSecretInfo) { + if (identityUa.lockoutEnabled) { + if (!lockout) { + lockout = { + lockedOut: false, + failedAttempts: 0 + }; + } + + lockout.failedAttempts += 1; + if (lockout.failedAttempts >= identityUa.lockoutThreshold) { + lockout.lockedOut = true; + } + + await keyStore.setItemWithExpiry( + LOCKOUT_KEY, + lockout.lockedOut ? identityUa.lockoutDurationSeconds : identityUa.lockoutCounterResetSeconds, + JSON.stringify(lockout) + ); + } + + throw new UnauthorizedError({ message: "Invalid credentials" }); + } else if (lockout) { + await keyStore.deleteItem(LOCKOUT_KEY); + } + + const { clientSecretTTL, clientSecretNumUses, clientSecretNumUsesLimit } = validClientSecretInfo; + if (Number(clientSecretTTL) > 0) { + const clientSecretCreated = new Date(validClientSecretInfo.createdAt); + const ttlInMilliseconds = Number(clientSecretTTL) * 1000; + const currentDate = new Date(); + const expirationTime = new Date(clientSecretCreated.getTime() + ttlInMilliseconds); + + if (currentDate > expirationTime) { + await identityUaClientSecretDAL.updateById(validClientSecretInfo.id, { + isClientSecretRevoked: true + }); + + throw new UnauthorizedError({ + message: "Access denied due to expired client secret" + }); + } + } + + if (clientSecretNumUsesLimit > 0 && clientSecretNumUses >= clientSecretNumUsesLimit) { + // number of times client secret can be used for + // a login operation reached await identityUaClientSecretDAL.updateById(validClientSecretInfo.id, { isClientSecretRevoked: true }); - throw new UnauthorizedError({ - message: "Access denied due to expired client secret" + message: "Access denied due to client secret usage limit reached" }); } - } - if (clientSecretNumUsesLimit > 0 && clientSecretNumUses === clientSecretNumUsesLimit) { - // number of times client secret can be used for - // a login operation reached - await identityUaClientSecretDAL.updateById(validClientSecretInfo.id, { - isClientSecretRevoked: true + const accessTokenTTLParams = + Number(identityUa.accessTokenPeriod) === 0 + ? { + accessTokenTTL: identityUa.accessTokenTTL, + accessTokenMaxTTL: identityUa.accessTokenMaxTTL + } + : { + accessTokenTTL: identityUa.accessTokenPeriod, + // We set a very large Max TTL for periodic tokens to ensure that clients (even outdated ones) can always renew their token + // without them having to update their SDKs, CLIs, etc. This workaround sets it to 30 years to emulate "forever" + accessTokenMaxTTL: 1000000000 + }; + + const identityAccessToken = await identityUaDAL.transaction(async (tx) => { + const uaClientSecretDoc = await identityUaClientSecretDAL.incrementUsage(validClientSecretInfo!.id, tx); + await identityOrgMembershipDAL.updateById( + identityMembershipOrg.id, + { + lastLoginAuthMethod: IdentityAuthMethod.UNIVERSAL_AUTH, + lastLoginTime: new Date() + }, + tx + ); + const newToken = await identityAccessTokenDAL.create( + { + identityId: identityUa.identityId, + isAccessTokenRevoked: false, + identityUAClientSecretId: uaClientSecretDoc.id, + accessTokenNumUses: 0, + accessTokenNumUsesLimit: identityUa.accessTokenNumUsesLimit, + accessTokenPeriod: identityUa.accessTokenPeriod, + authMethod: IdentityAuthMethod.UNIVERSAL_AUTH, + ...accessTokenTTLParams + }, + tx + ); + + return newToken; }); - throw new UnauthorizedError({ - message: "Access denied due to client secret usage limit reached" - }); - } - const accessTokenTTLParams = - Number(identityUa.accessTokenPeriod) === 0 - ? { - accessTokenTTL: identityUa.accessTokenTTL, - accessTokenMaxTTL: identityUa.accessTokenMaxTTL - } - : { - accessTokenTTL: identityUa.accessTokenPeriod, - // We set a very large Max TTL for periodic tokens to ensure that clients (even outdated ones) can always renew their token - // without them having to update their SDKs, CLIs, etc. This workaround sets it to 30 years to emulate "forever" - accessTokenMaxTTL: 1000000000 - }; - - const identityAccessToken = await identityUaDAL.transaction(async (tx) => { - const uaClientSecretDoc = await identityUaClientSecretDAL.incrementUsage(validClientSecretInfo!.id, tx); - await identityOrgMembershipDAL.updateById( - identityMembershipOrg.id, - { - lastLoginAuthMethod: IdentityAuthMethod.UNIVERSAL_AUTH, - lastLoginTime: new Date() - }, - tx - ); - const newToken = await identityAccessTokenDAL.create( + const appCfg = getConfig(); + const accessToken = crypto.jwt().sign( { identityId: identityUa.identityId, - isAccessTokenRevoked: false, - identityUAClientSecretId: uaClientSecretDoc.id, - accessTokenNumUses: 0, - accessTokenNumUsesLimit: identityUa.accessTokenNumUsesLimit, - accessTokenPeriod: identityUa.accessTokenPeriod, - authMethod: IdentityAuthMethod.UNIVERSAL_AUTH, - ...accessTokenTTLParams - }, - tx + clientSecretId: validClientSecretInfo.id, + identityAccessTokenId: identityAccessToken.id, + authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN + } as TIdentityAccessTokenJwtPayload, + appCfg.AUTH_SECRET, + // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error + Number(identityAccessToken.accessTokenTTL) === 0 + ? undefined + : { + expiresIn: Number(identityAccessToken.accessTokenTTL) + } ); - return newToken; - }); - - const appCfg = getConfig(); - const accessToken = crypto.jwt().sign( - { - identityId: identityUa.identityId, - clientSecretId: validClientSecretInfo.id, - identityAccessTokenId: identityAccessToken.id, - authTokenType: AuthTokenType.IDENTITY_ACCESS_TOKEN - } as TIdentityAccessTokenJwtPayload, - appCfg.AUTH_SECRET, - // akhilmhdh: for non-expiry tokens you should not even set the value, including undefined. Even for undefined jsonwebtoken throws error - Number(identityAccessToken.accessTokenTTL) === 0 - ? undefined - : { - expiresIn: Number(identityAccessToken.accessTokenTTL) - } - ); - - return { - accessToken, - identityUa, - validClientSecretInfo, - identityAccessToken, - identityMembershipOrg, - ...accessTokenTTLParams - }; + return { + accessToken, + identityUa, + validClientSecretInfo, + identityAccessToken, + identityMembershipOrg, + ...accessTokenTTLParams + }; + } finally { + await lock.release(); + } }; const attachUniversalAuth = async ({ @@ -196,7 +273,11 @@ export const identityUaServiceFactory = ({ actor, actorOrgId, isActorSuperAdmin, - accessTokenPeriod + accessTokenPeriod, + lockoutEnabled, + lockoutThreshold, + lockoutDurationSeconds, + lockoutCounterResetSeconds }: TAttachUaDTO) => { await validateIdentityUpdateForSuperAdminPrivileges(identityId, isActorSuperAdmin); @@ -266,7 +347,11 @@ export const identityUaServiceFactory = ({ accessTokenTTL, accessTokenNumUsesLimit, accessTokenTrustedIps: JSON.stringify(reformattedAccessTokenTrustedIps), - accessTokenPeriod + accessTokenPeriod, + lockoutEnabled, + lockoutThreshold, + lockoutDurationSeconds, + lockoutCounterResetSeconds }, tx ); @@ -286,7 +371,11 @@ export const identityUaServiceFactory = ({ actorId, actorAuthMethod, actor, - actorOrgId + actorOrgId, + lockoutEnabled, + lockoutThreshold, + lockoutDurationSeconds, + lockoutCounterResetSeconds }: TUpdateUaDTO) => { const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); @@ -362,7 +451,11 @@ export const identityUaServiceFactory = ({ accessTokenPeriod, accessTokenTrustedIps: reformattedAccessTokenTrustedIps ? JSON.stringify(reformattedAccessTokenTrustedIps) - : undefined + : undefined, + lockoutEnabled, + lockoutThreshold, + lockoutDurationSeconds, + lockoutCounterResetSeconds }); return { ...updatedUaAuth, orgId: identityMembershipOrg.orgId }; }; @@ -713,6 +806,38 @@ export const identityUaServiceFactory = ({ return { ...updatedClientSecret, identityId, orgId: identityMembershipOrg.orgId }; }; + const clearUniversalAuthLockouts = async ({ + identityId, + actorId, + actor, + actorOrgId, + actorAuthMethod + }: TClearUaLockoutsDTO) => { + const identityMembershipOrg = await identityOrgMembershipDAL.findOne({ identityId }); + if (!identityMembershipOrg) throw new NotFoundError({ message: `Failed to find identity with ID ${identityId}` }); + + if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.UNIVERSAL_AUTH)) { + throw new BadRequestError({ + message: "The identity does not have universal auth" + }); + } + + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + identityMembershipOrg.orgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Edit, OrgPermissionSubjects.Identity); + + const deleted = await keyStore.deleteItems({ + pattern: `lockout:identity:${identityId}:${IdentityAuthMethod.UNIVERSAL_AUTH}:*` + }); + + return { deleted, identityId, orgId: identityMembershipOrg.orgId }; + }; + return { login, attachUniversalAuth, @@ -722,6 +847,7 @@ export const identityUaServiceFactory = ({ createUniversalAuthClientSecret, getUniversalAuthClientSecrets, revokeUniversalAuthClientSecret, - getUniversalAuthClientSecretById + getUniversalAuthClientSecretById, + clearUniversalAuthLockouts }; }; diff --git a/backend/src/services/identity-ua/identity-ua-types.ts b/backend/src/services/identity-ua/identity-ua-types.ts index f7938e0f7..8e7644b58 100644 --- a/backend/src/services/identity-ua/identity-ua-types.ts +++ b/backend/src/services/identity-ua/identity-ua-types.ts @@ -9,6 +9,10 @@ export type TAttachUaDTO = { clientSecretTrustedIps: { ipAddress: string }[]; accessTokenTrustedIps: { ipAddress: string }[]; isActorSuperAdmin?: boolean; + lockoutEnabled: boolean; + lockoutThreshold: number; + lockoutDurationSeconds: number; + lockoutCounterResetSeconds: number; } & Omit; export type TUpdateUaDTO = { @@ -19,6 +23,10 @@ export type TUpdateUaDTO = { accessTokenPeriod?: number; clientSecretTrustedIps?: { ipAddress: string }[]; accessTokenTrustedIps?: { ipAddress: string }[]; + lockoutEnabled?: boolean; + lockoutThreshold?: number; + lockoutDurationSeconds?: number; + lockoutCounterResetSeconds?: number; } & Omit; export type TGetUaDTO = { @@ -45,6 +53,10 @@ export type TRevokeUaClientSecretDTO = { clientSecretId: string; } & Omit; +export type TClearUaLockoutsDTO = { + identityId: string; +} & Omit; + export type TGetUniversalAuthClientSecretByIdDTO = { identityId: string; clientSecretId: string; diff --git a/backend/src/services/identity/identity-service.ts b/backend/src/services/identity/identity-service.ts index 7c76520b3..969d00331 100644 --- a/backend/src/services/identity/identity-service.ts +++ b/backend/src/services/identity/identity-service.ts @@ -8,6 +8,7 @@ import { validatePrivilegeChangeOperation } from "@app/ee/services/permission/permission-fns"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { TKeyStoreFactory } from "@app/keystore/keystore"; import { BadRequestError, NotFoundError, PermissionBoundaryError } from "@app/lib/errors"; import { TIdentityProjectDALFactory } from "@app/services/identity-project/identity-project-dal"; @@ -32,6 +33,7 @@ type TIdentityServiceFactoryDep = { identityProjectDAL: Pick; permissionService: Pick; licenseService: Pick; + keyStore: Pick; }; export type TIdentityServiceFactory = ReturnType; @@ -42,7 +44,8 @@ export const identityServiceFactory = ({ identityOrgMembershipDAL, identityProjectDAL, permissionService, - licenseService + licenseService, + keyStore }: TIdentityServiceFactoryDep) => { const createIdentity = async ({ name, @@ -255,7 +258,20 @@ export const identityServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity); - return identity; + const activeLockouts = await keyStore.getKeysByPattern(`lockout:identity:${id}:*`); + + const activeLockoutAuthMethods = new Set(); + activeLockouts.forEach((key) => { + const parts = key.split(":"); + if (parts.length > 3) { + activeLockoutAuthMethods.add(parts[3]); + } + }); + + return { + ...identity, + identity: { ...identity.identity, activeLockoutAuthMethods: Array.from(activeLockoutAuthMethods) } + }; }; const deleteIdentity = async ({ diff --git a/backend/src/services/org-membership/org-membership-dal.ts b/backend/src/services/org-membership/org-membership-dal.ts index 8f2ca01f0..2d7992d34 100644 --- a/backend/src/services/org-membership/org-membership-dal.ts +++ b/backend/src/services/org-membership/org-membership-dal.ts @@ -124,12 +124,12 @@ export const orgMembershipDALFactory = (db: TDbClient) => { void qb .whereNull(`${TableName.OrgMembership}.lastInvitedAt`) .whereBetween(`${TableName.OrgMembership}.createdAt`, [twelveMonthsAgo, oneWeekAgo]); - }) - .orWhere((qb) => { // lastInvitedAt is older than 1 week ago AND createdAt is younger than 1 month ago - void qb - .where(`${TableName.OrgMembership}.lastInvitedAt`, "<", oneWeekAgo) - .where(`${TableName.OrgMembership}.createdAt`, ">", oneMonthAgo); + void qb.orWhere((qbInner) => { + void qbInner + .where(`${TableName.OrgMembership}.lastInvitedAt`, "<", oneWeekAgo) + .where(`${TableName.OrgMembership}.createdAt`, ">", oneMonthAgo); + }); }); return memberships; @@ -153,10 +153,64 @@ export const orgMembershipDALFactory = (db: TDbClient) => { } }; + const findOrgMembershipsWithUsersByOrgId = async (orgId: string) => { + try { + const members = await db + .replicaNode()(TableName.OrgMembership) + .where(`${TableName.OrgMembership}.orgId`, orgId) + .join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) + .leftJoin( + TableName.UserEncryptionKey, + `${TableName.UserEncryptionKey}.userId`, + `${TableName.Users}.id` + ) + .leftJoin(TableName.IdentityMetadata, (queryBuilder) => { + void queryBuilder + .on(`${TableName.OrgMembership}.userId`, `${TableName.IdentityMetadata}.userId`) + .andOn(`${TableName.OrgMembership}.orgId`, `${TableName.IdentityMetadata}.orgId`); + }) + .select( + db.ref("id").withSchema(TableName.OrgMembership), + db.ref("inviteEmail").withSchema(TableName.OrgMembership), + db.ref("orgId").withSchema(TableName.OrgMembership), + db.ref("role").withSchema(TableName.OrgMembership), + db.ref("roleId").withSchema(TableName.OrgMembership), + db.ref("status").withSchema(TableName.OrgMembership), + db.ref("isActive").withSchema(TableName.OrgMembership), + db.ref("email").withSchema(TableName.Users), + db.ref("username").withSchema(TableName.Users), + db.ref("firstName").withSchema(TableName.Users), + db.ref("lastName").withSchema(TableName.Users), + db.ref("isEmailVerified").withSchema(TableName.Users), + db.ref("id").withSchema(TableName.Users).as("userId") + ) + .where({ isGhost: false }); + + return members.map((member) => ({ + id: member.id, + orgId: member.orgId, + role: member.role, + status: member.status, + isActive: member.isActive, + inviteEmail: member.inviteEmail, + user: { + id: member.userId, + email: member.email, + username: member.username, + firstName: member.firstName, + lastName: member.lastName + } + })); + } catch (error) { + throw new DatabaseError({ error, name: "Find org memberships with users by org id" }); + } + }; + return { ...orgMembershipOrm, findOrgMembershipById, findRecentInvitedMemberships, - updateLastInvitedAtByIds + updateLastInvitedAtByIds, + findOrgMembershipsWithUsersByOrgId }; }; diff --git a/backend/src/services/org/org-schema.ts b/backend/src/services/org/org-schema.ts index ae82cd1bc..4a3bdb06e 100644 --- a/backend/src/services/org/org-schema.ts +++ b/backend/src/services/org/org-schema.ts @@ -8,6 +8,7 @@ export const sanitizedOrganizationSchema = OrganizationsSchema.pick({ createdAt: true, updatedAt: true, authEnforced: true, + googleSsoAuthEnforced: true, scimEnabled: true, kmsDefaultKeyId: true, defaultMembershipRole: true, diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index ba76d0a93..356a8451c 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -364,6 +364,7 @@ export const orgServiceFactory = ({ name, slug, authEnforced, + googleSsoAuthEnforced, scimEnabled, defaultMembershipRoleSlug, enforceMfa, @@ -430,6 +431,21 @@ export const orgServiceFactory = ({ } } + if (googleSsoAuthEnforced !== undefined) { + if (!plan.enforceGoogleSSO) { + throw new BadRequestError({ + message: "Failed to enforce Google SSO due to plan restriction. Upgrade plan to enforce Google SSO." + }); + } + ForbiddenError.from(permission).throwUnlessCan(OrgPermissionActions.Edit, OrgPermissionSubjects.Sso); + } + + if (authEnforced && googleSsoAuthEnforced) { + throw new BadRequestError({ + message: "SAML/OIDC auth enforcement and Google SSO auth enforcement cannot be enabled at the same time." + }); + } + if (authEnforced) { const samlCfg = await samlConfigDAL.findOne({ orgId, @@ -460,6 +476,21 @@ export const orgServiceFactory = ({ } } + if (googleSsoAuthEnforced) { + if (googleSsoAuthEnforced && currentOrg.authEnforced) { + throw new BadRequestError({ + message: "Google SSO auth enforcement cannot be enabled when SAML/OIDC auth enforcement is enabled." + }); + } + + if (!currentOrg.googleSsoAuthLastUsed) { + throw new BadRequestError({ + message: + "Google SSO auth enforcement cannot be enabled because Google SSO has not been used yet. Please log in via Google SSO at least once before enforcing it for your organization." + }); + } + } + let defaultMembershipRole: string | undefined; if (defaultMembershipRoleSlug) { defaultMembershipRole = await getDefaultOrgMembershipRoleForUpdateOrg({ @@ -474,6 +505,7 @@ export const orgServiceFactory = ({ name, slug: slug ? slugify(slug) : undefined, authEnforced, + googleSsoAuthEnforced, scimEnabled, defaultMembershipRole, enforceMfa, diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index 645692145..1a27d131f 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -74,6 +74,7 @@ export type TUpdateOrgDTO = { name: string; slug: string; authEnforced: boolean; + googleSsoAuthEnforced: boolean; scimEnabled: boolean; defaultMembershipRoleSlug: string; enforceMfa: boolean; diff --git a/backend/src/services/pki-subscriber/pki-subscriber-schema.ts b/backend/src/services/pki-subscriber/pki-subscriber-schema.ts index 337f81d8c..7b22ab645 100644 --- a/backend/src/services/pki-subscriber/pki-subscriber-schema.ts +++ b/backend/src/services/pki-subscriber/pki-subscriber-schema.ts @@ -18,7 +18,8 @@ export const sanitizedPkiSubscriber = PkiSubscribersSchema.pick({ lastOperationAt: true, enableAutoRenewal: true, autoRenewalPeriodInDays: true, - lastAutoRenewAt: true + lastAutoRenewAt: true, + properties: true }).extend({ supportsImmediateCertIssuance: z.boolean().optional() }); diff --git a/backend/src/services/pki-subscriber/pki-subscriber-service.ts b/backend/src/services/pki-subscriber/pki-subscriber-service.ts index a3e6ec78c..391b5b2a8 100644 --- a/backend/src/services/pki-subscriber/pki-subscriber-service.ts +++ b/backend/src/services/pki-subscriber/pki-subscriber-service.ts @@ -109,6 +109,7 @@ export const pkiSubscriberServiceFactory = ({ extendedKeyUsages, enableAutoRenewal, autoRenewalPeriodInDays, + properties, projectId, actorId, actorAuthMethod, @@ -157,7 +158,8 @@ export const pkiSubscriberServiceFactory = ({ keyUsages, extendedKeyUsages, enableAutoRenewal, - autoRenewalPeriodInDays + autoRenewalPeriodInDays, + properties }); return newSubscriber; @@ -221,6 +223,7 @@ export const pkiSubscriberServiceFactory = ({ extendedKeyUsages, enableAutoRenewal, autoRenewalPeriodInDays, + properties, actorId, actorAuthMethod, actor, @@ -275,7 +278,8 @@ export const pkiSubscriberServiceFactory = ({ keyUsages, extendedKeyUsages, enableAutoRenewal, - autoRenewalPeriodInDays + autoRenewalPeriodInDays, + properties }); return updatedSubscriber; @@ -360,7 +364,7 @@ export const pkiSubscriberServiceFactory = ({ throw new BadRequestError({ message: "CA is disabled" }); } - if (ca.externalCa?.id && ca.externalCa.type === CaType.ACME) { + if (ca.externalCa?.id && (ca.externalCa.type === CaType.ACME || ca.externalCa.type === CaType.AZURE_AD_CS)) { await certificateAuthorityQueue.orderCertificateForSubscriber({ subscriberId: subscriber.id, caType: ca.externalCa.type diff --git a/backend/src/services/pki-subscriber/pki-subscriber-types.ts b/backend/src/services/pki-subscriber/pki-subscriber-types.ts index 6881eea74..60b6f51b8 100644 --- a/backend/src/services/pki-subscriber/pki-subscriber-types.ts +++ b/backend/src/services/pki-subscriber/pki-subscriber-types.ts @@ -18,6 +18,7 @@ export type TCreatePkiSubscriberDTO = { extendedKeyUsages: CertExtendedKeyUsage[]; enableAutoRenewal?: boolean; autoRenewalPeriodInDays?: number; + properties?: TPkiSubscriberProperties; } & TProjectPermission; export type TGetPkiSubscriberDTO = { @@ -36,6 +37,7 @@ export type TUpdatePkiSubscriberDTO = { extendedKeyUsages?: CertExtendedKeyUsage[]; enableAutoRenewal?: boolean; autoRenewalPeriodInDays?: number; + properties?: TPkiSubscriberProperties; } & TProjectPermission; export type TDeletePkiSubscriberDTO = { @@ -69,3 +71,13 @@ export enum SubscriberOperationStatus { SUCCESS = "success", FAILED = "failed" } + +export type TPkiSubscriberProperties = { + azureTemplateType?: string; + organization?: string; + organizationalUnit?: string; + country?: string; + state?: string; + locality?: string; + emailAddress?: string; +}; diff --git a/backend/src/services/project-membership/project-membership-dal.ts b/backend/src/services/project-membership/project-membership-dal.ts index 7dfd1f65c..dd503c2a3 100644 --- a/backend/src/services/project-membership/project-membership-dal.ts +++ b/backend/src/services/project-membership/project-membership-dal.ts @@ -21,6 +21,14 @@ export const projectMembershipDALFactory = (db: TDbClient) => { .where({ [`${TableName.ProjectMembership}.projectId` as "projectId"]: projectId }) .join(TableName.Project, `${TableName.ProjectMembership}.projectId`, `${TableName.Project}.id`) .join(TableName.Users, `${TableName.ProjectMembership}.userId`, `${TableName.Users}.id`) + .join(TableName.OrgMembership, (qb) => { + qb.on(`${TableName.Users}.id`, "=", `${TableName.OrgMembership}.userId`).andOn( + `${TableName.OrgMembership}.orgId`, + "=", + `${TableName.Project}.orgId` + ); + }) + .where((qb) => { if (filter.usernames) { void qb.whereIn("username", filter.usernames); @@ -90,7 +98,8 @@ export const projectMembershipDALFactory = (db: TDbClient) => { db.ref("temporaryRange").withSchema(TableName.ProjectUserMembershipRole), db.ref("temporaryAccessStartTime").withSchema(TableName.ProjectUserMembershipRole), db.ref("temporaryAccessEndTime").withSchema(TableName.ProjectUserMembershipRole), - db.ref("name").as("projectName").withSchema(TableName.Project) + db.ref("name").as("projectName").withSchema(TableName.Project), + db.ref("isActive").withSchema(TableName.OrgMembership) ) .where({ isGhost: false }) .orderBy(`${TableName.Users}.username` as "username"); @@ -107,12 +116,22 @@ export const projectMembershipDALFactory = (db: TDbClient) => { id, userId, projectName, - createdAt + createdAt, + isActive }) => ({ id, userId, projectId, - user: { email, username, firstName, lastName, id: userId, publicKey, isGhost }, + user: { + email, + username, + firstName, + lastName, + id: userId, + publicKey, + isGhost, + isOrgMembershipActive: isActive + }, project: { id: projectId, name: projectName diff --git a/backend/src/services/project-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index b9e502922..7cf665141 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -97,7 +97,6 @@ export const projectMembershipServiceFactory = ({ const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId, { roles }); - // projectMembers[0].project if (includeGroupMembers) { const groupMembers = await groupProjectDAL.findAllProjectGroupMembers(projectId); const allMembers = [ diff --git a/backend/src/services/secret-folder/secret-folder-service.ts b/backend/src/services/secret-folder/secret-folder-service.ts index 90cc25710..c5eb0adde 100644 --- a/backend/src/services/secret-folder/secret-folder-service.ts +++ b/backend/src/services/secret-folder/secret-folder-service.ts @@ -30,6 +30,7 @@ import { TDeleteFolderDTO, TDeleteManyFoldersDTO, TGetFolderByIdDTO, + TGetFolderByPathDTO, TGetFolderDTO, TGetFoldersDeepByEnvsDTO, TUpdateFolderDTO, @@ -1398,6 +1399,31 @@ export const secretFolderServiceFactory = ({ }; }; + const getFolderByPath = async ( + { projectId, environment, secretPath }: TGetFolderByPathDTO, + actor: OrgServiceActor + ) => { + // folder check is allowed to be read by anyone + // permission is to check if user has access + await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + projectId, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager + }); + + const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath); + + if (!folder) + throw new NotFoundError({ + message: `Could not find folder with path "${secretPath}" in environment "${environment}" for project with ID "${projectId}"` + }); + + return folder; + }; + return { createFolder, updateFolder, @@ -1405,6 +1431,7 @@ export const secretFolderServiceFactory = ({ deleteFolder, getFolders, getFolderById, + getFolderByPath, getProjectFolderCount, getFoldersMultiEnv, getFoldersDeepByEnvs, diff --git a/backend/src/services/secret-folder/secret-folder-types.ts b/backend/src/services/secret-folder/secret-folder-types.ts index ae8e2c5dc..eed815da5 100644 --- a/backend/src/services/secret-folder/secret-folder-types.ts +++ b/backend/src/services/secret-folder/secret-folder-types.ts @@ -91,3 +91,9 @@ export type TDeleteManyFoldersDTO = { idOrName: string; }>; }; + +export type TGetFolderByPathDTO = { + projectId: string; + environment: string; + secretPath: string; +}; diff --git a/backend/src/services/secret-sync/checkly/checkly-sync-fns.ts b/backend/src/services/secret-sync/checkly/checkly-sync-fns.ts index eded130bb..822773948 100644 --- a/backend/src/services/secret-sync/checkly/checkly-sync-fns.ts +++ b/backend/src/services/secret-sync/checkly/checkly-sync-fns.ts @@ -23,56 +23,120 @@ export const ChecklySyncFns = { const config = secretSync.destinationConfig; - const variables = await ChecklyPublicAPI.getVariables(secretSync.connection, config.accountId); + if (config.groupId) { + // Handle group environment variables + const groupVars = await ChecklyPublicAPI.getCheckGroupEnvironmentVariables( + secretSync.connection, + config.accountId, + config.groupId + ); - const checklySecrets = Object.fromEntries(variables!.map((variable) => [variable.key, variable])); + const checklyGroupSecrets = Object.fromEntries(groupVars.map((variable) => [variable.key, variable])); - for await (const key of Object.keys(secretMap)) { - try { + // Prepare all variables to update at once + const updatedVariables = { ...checklyGroupSecrets }; + + for (const key of Object.keys(secretMap)) { const entry = secretMap[key]; - // If value is empty, we skip the upsert - checkly does not allow empty values + // If value is empty, we skip adding it - checkly does not allow empty values if (entry.value.trim() === "") { - // Delete the secret from Checkly if its empty + // Delete the secret from the group if it's empty if (!disableSecretDeletion) { - await ChecklyPublicAPI.deleteVariable(secretSync.connection, config.accountId, { - key - }); + delete updatedVariables[key]; } continue; // Skip empty values } - await ChecklyPublicAPI.upsertVariable(secretSync.connection, config.accountId, { + // Add or update the variable + updatedVariables[key] = { key, value: entry.value, - secret: true, locked: true - }); + }; + } + + // Remove secrets that are not in the secretMap if deletion is enabled + if (!disableSecretDeletion) { + for (const key of Object.keys(checklyGroupSecrets)) { + // eslint-disable-next-line no-continue + if (!matchesSchema(key, environment?.slug || "", keySchema)) continue; + + if (!secretMap[key]) { + delete updatedVariables[key]; + } + } + } + + // Update all group environment variables at once + try { + await ChecklyPublicAPI.updateCheckGroupEnvironmentVariables( + secretSync.connection, + config.accountId, + config.groupId, + Object.values(updatedVariables) + ); } catch (error) { + if (error instanceof SecretSyncError) throw error; + throw new SecretSyncError({ error, - secretKey: key + secretKey: "group_update" }); } - } + } else { + // Handle global variables (existing logic) + const variables = await ChecklyPublicAPI.getVariables(secretSync.connection, config.accountId); - if (disableSecretDeletion) return; + const checklySecrets = Object.fromEntries(variables!.map((variable) => [variable.key, variable])); - for await (const key of Object.keys(checklySecrets)) { - try { - // eslint-disable-next-line no-continue - if (!matchesSchema(key, environment?.slug || "", keySchema)) continue; + for await (const key of Object.keys(secretMap)) { + try { + const entry = secretMap[key]; - if (!secretMap[key]) { - await ChecklyPublicAPI.deleteVariable(secretSync.connection, config.accountId, { - key + // If value is empty, we skip the upsert - checkly does not allow empty values + if (entry.value.trim() === "") { + // Delete the secret from Checkly if its empty + if (!disableSecretDeletion) { + await ChecklyPublicAPI.deleteVariable(secretSync.connection, config.accountId, { + key + }); + } + continue; // Skip empty values + } + + await ChecklyPublicAPI.upsertVariable(secretSync.connection, config.accountId, { + key, + value: entry.value, + secret: true, + locked: true + }); + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key + }); + } + } + + if (disableSecretDeletion) return; + + for await (const key of Object.keys(checklySecrets)) { + try { + // eslint-disable-next-line no-continue + if (!matchesSchema(key, environment?.slug || "", keySchema)) continue; + + if (!secretMap[key]) { + await ChecklyPublicAPI.deleteVariable(secretSync.connection, config.accountId, { + key + }); + } + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: key }); } - } catch (error) { - throw new SecretSyncError({ - error, - secretKey: key - }); } } }, @@ -80,23 +144,54 @@ export const ChecklySyncFns = { async removeSecrets(secretSync: TChecklySyncWithCredentials, secretMap: TSecretMap) { const config = secretSync.destinationConfig; - const variables = await ChecklyPublicAPI.getVariables(secretSync.connection, config.accountId); + if (config.groupId) { + // Handle group environment variables + const groupVars = await ChecklyPublicAPI.getCheckGroupEnvironmentVariables( + secretSync.connection, + config.accountId, + config.groupId + ); - const checklySecrets = Object.fromEntries(variables!.map((variable) => [variable.key, variable])); + const checklyGroupSecrets = Object.fromEntries(groupVars.map((variable) => [variable.key, variable])); + + // Filter out the secrets to remove + const remainingVariables = Object.keys(checklyGroupSecrets) + .filter((key) => !(key in secretMap)) + .map((key) => checklyGroupSecrets[key]); - for await (const secret of Object.keys(checklySecrets)) { try { - if (secret in secretMap) { - await ChecklyPublicAPI.deleteVariable(secretSync.connection, config.accountId, { - key: secret - }); - } + await ChecklyPublicAPI.updateCheckGroupEnvironmentVariables( + secretSync.connection, + config.accountId, + config.groupId, + remainingVariables + ); } catch (error) { throw new SecretSyncError({ error, - secretKey: secret + secretKey: "group_remove" }); } + } else { + // Handle global variables (existing logic) + const variables = await ChecklyPublicAPI.getVariables(secretSync.connection, config.accountId); + + const checklySecrets = Object.fromEntries(variables!.map((variable) => [variable.key, variable])); + + for await (const secret of Object.keys(checklySecrets)) { + try { + if (secret in secretMap) { + await ChecklyPublicAPI.deleteVariable(secretSync.connection, config.accountId, { + key: secret + }); + } + } catch (error) { + throw new SecretSyncError({ + error, + secretKey: secret + }); + } + } } } }; diff --git a/backend/src/services/secret-sync/checkly/checkly-sync-schemas.ts b/backend/src/services/secret-sync/checkly/checkly-sync-schemas.ts index 04f444357..fc511b2d7 100644 --- a/backend/src/services/secret-sync/checkly/checkly-sync-schemas.ts +++ b/backend/src/services/secret-sync/checkly/checkly-sync-schemas.ts @@ -11,7 +11,17 @@ import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types" const ChecklySyncDestinationConfigSchema = z.object({ accountId: z.string().min(1, "Account ID is required").max(255, "Account ID must be less than 255 characters"), - accountName: z.string().min(1, "Account Name is required").max(255, "Account ID must be less than 255 characters") + accountName: z + .string() + .min(1, "Account Name is required") + .max(255, "Account ID must be less than 255 characters") + .optional(), + groupId: z.string().min(1, "Group ID is required").max(255, "Group ID must be less than 255 characters").optional(), + groupName: z + .string() + .min(1, "Group Name is required") + .max(255, "Group Name must be less than 255 characters") + .optional() }); const ChecklySyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: false }; diff --git a/backend/src/services/secret-sync/github/github-sync-fns.ts b/backend/src/services/secret-sync/github/github-sync-fns.ts index e2cf8f6e8..2cae048aa 100644 --- a/backend/src/services/secret-sync/github/github-sync-fns.ts +++ b/backend/src/services/secret-sync/github/github-sync-fns.ts @@ -207,7 +207,7 @@ export const GithubSyncFns = { const token = connection.method === GitHubConnectionMethod.OAuth ? connection.credentials.accessToken - : await getGitHubAppAuthToken(connection); + : await getGitHubAppAuthToken(connection, gatewayService); const encryptedSecrets = await getEncryptedSecrets(secretSync, gatewayService); const publicKey = await getPublicKey(secretSync, gatewayService, token); @@ -264,7 +264,7 @@ export const GithubSyncFns = { const token = connection.method === GitHubConnectionMethod.OAuth ? connection.credentials.accessToken - : await getGitHubAppAuthToken(connection); + : await getGitHubAppAuthToken(connection, gatewayService); const encryptedSecrets = await getEncryptedSecrets(secretSync, gatewayService); diff --git a/backend/src/services/secret-sync/hc-vault/hc-vault-sync-fns.ts b/backend/src/services/secret-sync/hc-vault/hc-vault-sync-fns.ts index 724eec7be..9168c96a6 100644 --- a/backend/src/services/secret-sync/hc-vault/hc-vault-sync-fns.ts +++ b/backend/src/services/secret-sync/hc-vault/hc-vault-sync-fns.ts @@ -1,9 +1,13 @@ import { isAxiosError } from "axios"; -import { request } from "@app/lib/config/request"; +import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { removeTrailingSlash } from "@app/lib/fn"; -import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; -import { getHCVaultAccessToken, getHCVaultInstanceUrl } from "@app/services/app-connection/hc-vault"; +import { + getHCVaultAccessToken, + getHCVaultInstanceUrl, + requestWithHCVaultGateway, + THCVaultConnection +} from "@app/services/app-connection/hc-vault"; import { THCVaultListVariables, THCVaultListVariablesResponse, @@ -14,19 +18,20 @@ import { SecretSyncError } from "@app/services/secret-sync/secret-sync-errors"; import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; -const listHCVaultVariables = async ({ instanceUrl, namespace, mount, accessToken, path }: THCVaultListVariables) => { - await blockLocalAndPrivateIpAddresses(instanceUrl); - +const listHCVaultVariables = async ( + { instanceUrl, namespace, mount, accessToken, path }: THCVaultListVariables, + connection: THCVaultConnection, + gatewayService: Pick +) => { try { - const { data } = await request.get( - `${instanceUrl}/v1/${removeTrailingSlash(mount)}/data/${path}`, - { - headers: { - "X-Vault-Token": accessToken, - ...(namespace ? { "X-Vault-Namespace": namespace } : {}) - } + const { data } = await requestWithHCVaultGateway(connection, gatewayService, { + url: `${instanceUrl}/v1/${removeTrailingSlash(mount)}/data/${path}`, + method: "GET", + headers: { + "X-Vault-Token": accessToken, + ...(namespace ? { "X-Vault-Namespace": namespace } : {}) } - ); + }); return data.data.data; } catch (error: unknown) { @@ -39,33 +44,29 @@ const listHCVaultVariables = async ({ instanceUrl, namespace, mount, accessToken }; // Hashicorp Vault updates all variables in one batch. This is to respect their versioning -const updateHCVaultVariables = async ({ - path, - instanceUrl, - namespace, - accessToken, - mount, - data -}: TPostHCVaultVariable) => { - await blockLocalAndPrivateIpAddresses(instanceUrl); - - return request.post( - `${instanceUrl}/v1/${removeTrailingSlash(mount)}/data/${path}`, - { - data +const updateHCVaultVariables = async ( + { path, instanceUrl, namespace, accessToken, mount, data }: TPostHCVaultVariable, + connection: THCVaultConnection, + gatewayService: Pick +) => { + return requestWithHCVaultGateway(connection, gatewayService, { + url: `${instanceUrl}/v1/${removeTrailingSlash(mount)}/data/${path}`, + method: "POST", + headers: { + "X-Vault-Token": accessToken, + ...(namespace ? { "X-Vault-Namespace": namespace } : {}), + "Content-Type": "application/json" }, - { - headers: { - "X-Vault-Token": accessToken, - ...(namespace ? { "X-Vault-Namespace": namespace } : {}), - "Content-Type": "application/json" - } - } - ); + data: { data } + }); }; export const HCVaultSyncFns = { - syncSecrets: async (secretSync: THCVaultSyncWithCredentials, secretMap: TSecretMap) => { + syncSecrets: async ( + secretSync: THCVaultSyncWithCredentials, + secretMap: TSecretMap, + gatewayService: Pick + ) => { const { connection, environment, @@ -74,16 +75,20 @@ export const HCVaultSyncFns = { } = secretSync; const { namespace } = connection.credentials; - const accessToken = await getHCVaultAccessToken(connection); + const accessToken = await getHCVaultAccessToken(connection, gatewayService); const instanceUrl = await getHCVaultInstanceUrl(connection); - const variables = await listHCVaultVariables({ - instanceUrl, - accessToken, - namespace, - mount, - path - }); + const variables = await listHCVaultVariables( + { + instanceUrl, + accessToken, + namespace, + mount, + path + }, + connection, + gatewayService + ); let tainted = false; for (const entry of Object.entries(secretMap)) { @@ -110,24 +115,36 @@ export const HCVaultSyncFns = { if (!tainted) return; try { - await updateHCVaultVariables({ accessToken, instanceUrl, namespace, mount, path, data: variables }); + await updateHCVaultVariables( + { accessToken, instanceUrl, namespace, mount, path, data: variables }, + connection, + gatewayService + ); } catch (error) { throw new SecretSyncError({ error }); } }, - removeSecrets: async (secretSync: THCVaultSyncWithCredentials, secretMap: TSecretMap) => { + removeSecrets: async ( + secretSync: THCVaultSyncWithCredentials, + secretMap: TSecretMap, + gatewayService: Pick + ) => { const { connection, destinationConfig: { mount, path } } = secretSync; const { namespace } = connection.credentials; - const accessToken = await getHCVaultAccessToken(connection); + const accessToken = await getHCVaultAccessToken(connection, gatewayService); const instanceUrl = await getHCVaultInstanceUrl(connection); - const variables = await listHCVaultVariables({ instanceUrl, namespace, accessToken, mount, path }); + const variables = await listHCVaultVariables( + { instanceUrl, namespace, accessToken, mount, path }, + connection, + gatewayService + ); for await (const [key] of Object.entries(variables)) { if (key in secretMap) { @@ -136,30 +153,41 @@ export const HCVaultSyncFns = { } try { - await updateHCVaultVariables({ accessToken, instanceUrl, namespace, mount, path, data: variables }); + await updateHCVaultVariables( + { accessToken, instanceUrl, namespace, mount, path, data: variables }, + connection, + gatewayService + ); } catch (error) { throw new SecretSyncError({ error }); } }, - getSecrets: async (secretSync: THCVaultSyncWithCredentials) => { + getSecrets: async ( + secretSync: THCVaultSyncWithCredentials, + gatewayService: Pick + ) => { const { connection, destinationConfig: { mount, path } } = secretSync; const { namespace } = connection.credentials; - const accessToken = await getHCVaultAccessToken(connection); + const accessToken = await getHCVaultAccessToken(connection, gatewayService); const instanceUrl = await getHCVaultInstanceUrl(connection); - const variables = await listHCVaultVariables({ - instanceUrl, - namespace, - accessToken, - mount, - path - }); + const variables = await listHCVaultVariables( + { + instanceUrl, + namespace, + accessToken, + mount, + path + }, + connection, + gatewayService + ); return Object.fromEntries(Object.entries(variables).map(([key, value]) => [key, { value }])); } diff --git a/backend/src/services/secret-sync/render/render-sync-enums.ts b/backend/src/services/secret-sync/render/render-sync-enums.ts index dc0af3b91..098f90de0 100644 --- a/backend/src/services/secret-sync/render/render-sync-enums.ts +++ b/backend/src/services/secret-sync/render/render-sync-enums.ts @@ -1,5 +1,6 @@ export enum RenderSyncScope { - Service = "service" + Service = "service", + EnvironmentGroup = "environment-group" } export enum RenderSyncType { diff --git a/backend/src/services/secret-sync/render/render-sync-fns.ts b/backend/src/services/secret-sync/render/render-sync-fns.ts index 71347f998..4ab67bdd9 100644 --- a/backend/src/services/secret-sync/render/render-sync-fns.ts +++ b/backend/src/services/secret-sync/render/render-sync-fns.ts @@ -1,11 +1,13 @@ /* eslint-disable no-await-in-loop */ -import { isAxiosError } from "axios"; +import { AxiosRequestConfig, isAxiosError } from "axios"; import { request } from "@app/lib/config/request"; +import { BadRequestError } from "@app/lib/errors"; import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; +import { RenderSyncScope } from "./render-sync-enums"; import { TRenderSecret, TRenderSyncWithCredentials } from "./render-sync-types"; const MAX_RETRIES = 5; @@ -27,6 +29,80 @@ const makeRequestWithRetry = async (requestFn: () => Promise, attempt = 0) } }; +async function getSecrets(input: { destination: TRenderSyncWithCredentials["destinationConfig"]; token: string }) { + const req: AxiosRequestConfig = { + baseURL: `${IntegrationUrls.RENDER_API_URL}/v1`, + method: "GET", + headers: { + Authorization: `Bearer ${input.token}`, + Accept: "application/json" + } + }; + + switch (input.destination.scope) { + case RenderSyncScope.Service: { + req.url = `/services/${input.destination.serviceId}/env-vars`; + + const allSecrets: TRenderSecret[] = []; + let cursor: string | undefined; + + do { + // eslint-disable-next-line @typescript-eslint/no-loop-func + const { data } = await makeRequestWithRetry(() => + request.request< + { + envVar: { + key: string; + value: string; + }; + cursor: string; + }[] + >({ + ...req, + params: { + cursor + } + }) + ); + + const secrets = data.map((item) => ({ + key: item.envVar.key, + value: item.envVar.value + })); + + allSecrets.push(...secrets); + + if (data.length > 0 && data[data.length - 1]?.cursor) { + cursor = data[data.length - 1].cursor; + } else { + cursor = undefined; + } + } while (cursor); + + return allSecrets; + } + case RenderSyncScope.EnvironmentGroup: { + req.url = `/env-groups/${input.destination.environmentGroupId}`; + + const res = await makeRequestWithRetry(() => + request.request<{ + envVars: { + key: string; + value: string; + }[]; + }>(req) + ); + + return res.data.envVars.map((item) => ({ + key: item.key, + value: item.value + })); + } + default: + throw new BadRequestError({ message: "Unknown render sync destination scope" }); + } +} + const getRenderEnvironmentSecrets = async (secretSync: TRenderSyncWithCredentials): Promise => { const { destinationConfig, @@ -35,45 +111,12 @@ const getRenderEnvironmentSecrets = async (secretSync: TRenderSyncWithCredential } } = secretSync; - const baseUrl = `${IntegrationUrls.RENDER_API_URL}/v1/services/${destinationConfig.serviceId}/env-vars`; - const allSecrets: TRenderSecret[] = []; - let cursor: string | undefined; + const secrets = await getSecrets({ + destination: destinationConfig, + token: apiKey + }); - do { - const url = cursor ? `${baseUrl}?cursor=${cursor}` : baseUrl; - - const { data } = await makeRequestWithRetry(() => - request.get< - { - envVar: { - key: string; - value: string; - }; - cursor: string; - }[] - >(url, { - headers: { - Authorization: `Bearer ${apiKey}`, - Accept: "application/json" - } - }) - ); - - const secrets = data.map((item) => ({ - key: item.envVar.key, - value: item.envVar.value - })); - - allSecrets.push(...secrets); - - if (data.length > 0 && data[data.length - 1]?.cursor) { - cursor = data[data.length - 1].cursor; - } else { - cursor = undefined; - } - } while (cursor); - - return allSecrets; + return secrets; }; const batchUpdateEnvironmentSecrets = async ( @@ -87,14 +130,91 @@ const batchUpdateEnvironmentSecrets = async ( } } = secretSync; - await makeRequestWithRetry(() => - request.put(`${IntegrationUrls.RENDER_API_URL}/v1/services/${destinationConfig.serviceId}/env-vars`, envVars, { - headers: { - Authorization: `Bearer ${apiKey}`, - Accept: "application/json" + const req: AxiosRequestConfig = { + baseURL: `${IntegrationUrls.RENDER_API_URL}/v1`, + method: "PUT", + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json" + } + }; + + switch (destinationConfig.scope) { + case RenderSyncScope.Service: { + await makeRequestWithRetry(() => + request.request({ + ...req, + url: `/services/${destinationConfig.serviceId}/env-vars`, + data: envVars + }) + ); + break; + } + + case RenderSyncScope.EnvironmentGroup: { + for await (const variable of envVars) { + await makeRequestWithRetry(() => + request.request({ + ...req, + url: `/env-groups/${destinationConfig.environmentGroupId}/env-vars/${variable.key}`, + data: { + value: variable.value + } + }) + ); } - }) - ); + break; + } + + default: + throw new BadRequestError({ message: "Unknown render sync destination scope" }); + } +}; + +const deleteEnvironmentSecret = async ( + secretSync: TRenderSyncWithCredentials, + envVar: { key: string; value: string } +): Promise => { + const { + destinationConfig, + connection: { + credentials: { apiKey } + } + } = secretSync; + + const req: AxiosRequestConfig = { + baseURL: `${IntegrationUrls.RENDER_API_URL}/v1`, + method: "DELETE", + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json" + } + }; + + switch (destinationConfig.scope) { + case RenderSyncScope.Service: { + await makeRequestWithRetry(() => + request.request({ + ...req, + url: `/services/${destinationConfig.serviceId}/env-vars/${envVar.key}` + }) + ); + break; + } + + case RenderSyncScope.EnvironmentGroup: { + await makeRequestWithRetry(() => + request.request({ + ...req, + url: `/env-groups/${destinationConfig.environmentGroupId}/env-vars/${envVar.key}` + }) + ); + break; + } + + default: + throw new BadRequestError({ message: "Unknown render sync destination scope" }); + } }; const redeployService = async (secretSync: TRenderSyncWithCredentials) => { @@ -105,18 +225,50 @@ const redeployService = async (secretSync: TRenderSyncWithCredentials) => { } } = secretSync; - await makeRequestWithRetry(() => - request.post( - `${IntegrationUrls.RENDER_API_URL}/v1/services/${destinationConfig.serviceId}/deploys`, - {}, - { - headers: { - Authorization: `Bearer ${apiKey}`, - Accept: "application/json" - } + const req: AxiosRequestConfig = { + baseURL: `${IntegrationUrls.RENDER_API_URL}/v1`, + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json" + } + }; + + switch (destinationConfig.scope) { + case RenderSyncScope.Service: { + await makeRequestWithRetry(() => + request.request({ + ...req, + method: "POST", + url: `/services/${destinationConfig.serviceId}/deploys`, + data: {} + }) + ); + break; + } + + case RenderSyncScope.EnvironmentGroup: { + const { data } = await request.request<{ serviceLinks: { id: string }[] }>({ + ...req, + method: "GET", + url: `/env-groups/${destinationConfig.environmentGroupId}` + }); + + for await (const link of data.serviceLinks) { + // eslint-disable-next-line @typescript-eslint/no-loop-func + await makeRequestWithRetry(() => + request.request({ + ...req, + url: `/services/${link.id}/deploys`, + data: {} + }) + ); } - ) - ); + break; + } + + default: + throw new BadRequestError({ message: "Unknown render sync destination scope" }); + } }; export const RenderSyncFns = { @@ -169,14 +321,15 @@ export const RenderSyncFns = { const finalEnvVars: Array<{ key: string; value: string }> = []; for (const renderSecret of renderSecrets) { - if (!(renderSecret.key in secretMap)) { + if (renderSecret.key in secretMap) { finalEnvVars.push({ key: renderSecret.key, value: renderSecret.value }); } } - await batchUpdateEnvironmentSecrets(secretSync, finalEnvVars); + + await Promise.all(finalEnvVars.map((el) => deleteEnvironmentSecret(secretSync, el))); if (secretSync.syncOptions.autoRedeployServices) { await redeployService(secretSync); diff --git a/backend/src/services/secret-sync/render/render-sync-schemas.ts b/backend/src/services/secret-sync/render/render-sync-schemas.ts index 0d6e93987..0bc116255 100644 --- a/backend/src/services/secret-sync/render/render-sync-schemas.ts +++ b/backend/src/services/secret-sync/render/render-sync-schemas.ts @@ -17,6 +17,14 @@ const RenderSyncDestinationConfigSchema = z.discriminatedUnion("scope", [ scope: z.literal(RenderSyncScope.Service).describe(SecretSyncs.DESTINATION_CONFIG.RENDER.scope), serviceId: z.string().min(1, "Service ID is required").describe(SecretSyncs.DESTINATION_CONFIG.RENDER.serviceId), type: z.nativeEnum(RenderSyncType).describe(SecretSyncs.DESTINATION_CONFIG.RENDER.type) + }), + z.object({ + scope: z.literal(RenderSyncScope.EnvironmentGroup).describe(SecretSyncs.DESTINATION_CONFIG.RENDER.scope), + environmentGroupId: z + .string() + .min(1, "Environment Group ID is required") + .describe(SecretSyncs.DESTINATION_CONFIG.RENDER.environmentGroupId), + type: z.nativeEnum(RenderSyncType).describe(SecretSyncs.DESTINATION_CONFIG.RENDER.type) }) ]); diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts index 3ff7cefbc..5b083baf6 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -244,7 +244,7 @@ export const SecretSyncFns = { case SecretSync.Windmill: return WindmillSyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.HCVault: - return HCVaultSyncFns.syncSecrets(secretSync, schemaSecretMap); + return HCVaultSyncFns.syncSecrets(secretSync, schemaSecretMap, gatewayService); case SecretSync.TeamCity: return TeamCitySyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.OCIVault: @@ -283,7 +283,7 @@ export const SecretSyncFns = { }, getSecrets: async ( secretSync: TSecretSyncWithCredentials, - { kmsService, appConnectionDAL }: TSyncSecretDeps + { kmsService, appConnectionDAL, gatewayService }: TSyncSecretDeps ): Promise => { let secretMap: TSecretMap; switch (secretSync.destination) { @@ -341,7 +341,7 @@ export const SecretSyncFns = { secretMap = await WindmillSyncFns.getSecrets(secretSync); break; case SecretSync.HCVault: - secretMap = await HCVaultSyncFns.getSecrets(secretSync); + secretMap = await HCVaultSyncFns.getSecrets(secretSync, gatewayService); break; case SecretSync.TeamCity: secretMap = await TeamCitySyncFns.getSecrets(secretSync); @@ -451,7 +451,7 @@ export const SecretSyncFns = { case SecretSync.Windmill: return WindmillSyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.HCVault: - return HCVaultSyncFns.removeSecrets(secretSync, schemaSecretMap); + return HCVaultSyncFns.removeSecrets(secretSync, schemaSecretMap, gatewayService); case SecretSync.TeamCity: return TeamCitySyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.OCIVault: diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts index c2a72f2e6..8d9c6958a 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-dal.ts @@ -684,9 +684,9 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { throw new BadRequestError({ message: "Missing personal user id" }); } void bd.orWhere({ - key: el.key, - type: el.type, - userId: el.type === SecretType.Personal ? el.userId : null + [`${TableName.SecretV2}.key` as "key"]: el.key, + [`${TableName.SecretV2}.type` as "type"]: el.type, + [`${TableName.SecretV2}.userId` as "userId"]: el.type === SecretType.Personal ? el.userId : null }); }); }) @@ -695,12 +695,60 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => { `${TableName.SecretV2}.id`, `${TableName.SecretRotationV2SecretMapping}.secretId` ) + + .leftJoin( + TableName.SecretV2JnTag, + `${TableName.SecretV2}.id`, + `${TableName.SecretV2JnTag}.${TableName.SecretV2}Id` + ) + .leftJoin( + TableName.SecretTag, + `${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`, + `${TableName.SecretTag}.id` + ) + .leftJoin(TableName.ResourceMetadata, `${TableName.SecretV2}.id`, `${TableName.ResourceMetadata}.secretId`) + .select(db.ref("id").withSchema(TableName.SecretTag).as("tagId")) + .select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor")) + .select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug")) + .select( + db.ref("id").withSchema(TableName.ResourceMetadata).as("metadataId"), + db.ref("key").withSchema(TableName.ResourceMetadata).as("metadataKey"), + db.ref("value").withSchema(TableName.ResourceMetadata).as("metadataValue") + ) .select(selectAllTableCols(TableName.SecretV2)) .select(db.ref("rotationId").withSchema(TableName.SecretRotationV2SecretMapping)); - return secrets.map((secret) => ({ - ...secret, - isRotatedSecret: Boolean(secret.rotationId) - })); + + const docs = sqlNestRelationships({ + data: secrets, + key: "id", + parentMapper: (secret) => ({ + ...secret, + isRotatedSecret: Boolean(secret.rotationId) + }), + childrenMapper: [ + { + key: "tagId", + label: "tags" as const, + mapper: ({ tagId: id, tagColor: color, tagSlug: slug }) => ({ + id, + color, + slug, + name: slug + }) + }, + { + key: "metadataId", + label: "secretMetadata" as const, + mapper: ({ metadataKey, metadataValue, metadataId }) => ({ + id: metadataId, + key: metadataKey, + value: metadataValue + }) + } + ] + }); + + return docs; } catch (error) { throw new DatabaseError({ error, name: "find by secret keys" }); } diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts index 101fd9b17..b23cd0c56 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts @@ -1074,12 +1074,22 @@ export const secretV2BridgeServiceFactory = ({ currentPath: path }); - if (!deepPaths) return { secrets: [], imports: [] }; + if (!deepPaths?.length) { + throw new NotFoundError({ + message: `Folder with path '${path}' in environment '${environment}' was not found. Please ensure the environment slug and secret path is correct.`, + name: "SecretPathNotFound" + }); + } paths = deepPaths.map(({ folderId, path: p }) => ({ folderId, path: p })); } else { const folder = await folderDAL.findBySecretPath(projectId, environment, path); - if (!folder) return { secrets: [], imports: [] }; + if (!folder) { + throw new NotFoundError({ + message: `Folder with path '${path}' in environment '${environment}' was not found. Please ensure the environment slug and secret path is correct.`, + name: "SecretPathNotFound" + }); + } paths = [{ folderId: folder.id, path }]; } diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index f776b4527..6bafa3ba6 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -637,7 +637,12 @@ export const secretServiceFactory = ({ } }); - if (!deepPaths) return { secrets: [], imports: [] }; + if (!deepPaths?.length) { + throw new NotFoundError({ + message: `Folder with path '${path}' in environment '${environment}' was not found. Please ensure the environment slug and secret path is correct.`, + name: "SecretPathNotFound" + }); + } paths = deepPaths.map(({ folderId, path: p }) => ({ folderId, path: p })); } else { @@ -647,7 +652,12 @@ export const secretServiceFactory = ({ }); const folder = await folderDAL.findBySecretPath(projectId, environment, path); - if (!folder) return { secrets: [], imports: [] }; + if (!folder) { + throw new NotFoundError({ + message: `Folder with path '${path}' in environment '${environment}' was not found. Please ensure the environment slug and secret path is correct.`, + name: "SecretPathNotFound" + }); + } paths = [{ folderId: folder.id, path }]; } diff --git a/backend/src/services/user/user-service.ts b/backend/src/services/user/user-service.ts index 30bf750c3..b0d7be0fe 100644 --- a/backend/src/services/user/user-service.ts +++ b/backend/src/services/user/user-service.ts @@ -2,6 +2,7 @@ import { ForbiddenError } from "@casl/ability"; import { OrgPermissionActions, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { crypto } from "@app/lib/crypto"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-service"; @@ -9,9 +10,10 @@ import { TokenType } from "@app/services/auth-token/auth-token-types"; import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; -import { AuthMethod } from "../auth/auth-type"; +import { AuthMethod, AuthTokenType } from "../auth/auth-type"; import { TGroupProjectDALFactory } from "../group-project/group-project-dal"; import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; +import { TUserAliasDALFactory } from "../user-alias/user-alias-dal"; import { TUserDALFactory } from "./user-dal"; import { TListUserGroupsDTO, TUpdateUserMfaDTO } from "./user-types"; @@ -37,6 +39,7 @@ type TUserServiceFactoryDep = { projectMembershipDAL: Pick; smtpService: Pick; permissionService: TPermissionServiceFactory; + userAliasDAL: Pick; }; export type TUserServiceFactory = ReturnType; @@ -48,22 +51,38 @@ export const userServiceFactory = ({ groupProjectDAL, tokenService, smtpService, - permissionService + permissionService, + userAliasDAL }: TUserServiceFactoryDep) => { - const sendEmailVerificationCode = async (username: string) => { + const sendEmailVerificationCode = async (token: string) => { + const { authType, aliasId, username, authTokenType } = crypto.jwt().decode(token) as { + authType: string; + aliasId?: string; + username: string; + authTokenType: AuthTokenType; + }; + if (authTokenType !== AuthTokenType.PROVIDER_TOKEN) throw new BadRequestError({ name: "Invalid auth token type" }); + // akhilmhdh: case sensitive email resolution const users = await userDAL.findUserByUsername(username); const user = users?.length > 1 ? users.find((el) => el.username === username) : users?.[0]; if (!user) throw new NotFoundError({ name: `User with username '${username}' not found` }); + let { isEmailVerified } = user; + if (aliasId) { + const userAlias = await userAliasDAL.findOne({ userId: user.id, aliasType: authType, id: aliasId }); + if (!userAlias) throw new NotFoundError({ name: `User alias with ID '${aliasId}' not found` }); + isEmailVerified = userAlias.isEmailVerified; + } if (!user.email) throw new BadRequestError({ name: "Failed to send email verification code due to no email on user" }); - if (user.isEmailVerified) + if (isEmailVerified) throw new BadRequestError({ name: "Failed to send email verification code due to email already verified" }); - const token = await tokenService.createTokenForUser({ + const userToken = await tokenService.createTokenForUser({ type: TokenType.TOKEN_EMAIL_VERIFICATION, - userId: user.id + userId: user.id, + aliasId }); await smtpService.sendMail({ @@ -71,7 +90,7 @@ export const userServiceFactory = ({ subjectLine: "Infisical confirmation code", recipients: [user.email], substitutions: { - code: token + code: userToken } }); }; @@ -95,15 +114,21 @@ export const userServiceFactory = ({ if (!user) throw new NotFoundError({ name: `User with username '${username}' not found` }); if (!user.email) throw new BadRequestError({ name: "Failed to verify email verification code due to no email on user" }); - if (user.isEmailVerified) - throw new BadRequestError({ name: "Failed to verify email verification code due to email already verified" }); - await tokenService.validateTokenForUser({ + const token = await tokenService.validateTokenForUser({ type: TokenType.TOKEN_EMAIL_VERIFICATION, userId: user.id, code }); + if (token?.aliasId) { + const userAlias = await userAliasDAL.findOne({ userId: user.id, id: token.aliasId }); + if (!userAlias) throw new NotFoundError({ name: `User alias with ID '${token.aliasId}' not found` }); + if (userAlias?.isEmailVerified) + throw new BadRequestError({ name: "Failed to verify email verification code due to email already verified" }); + + await userAliasDAL.updateById(token.aliasId, { isEmailVerified: true }); + } const userEmails = user?.email ? await userDAL.find({ email: user.email }) : []; await userDAL.updateById(user.id, { diff --git a/docs/.eslintrc.js b/docs/.eslintrc.js new file mode 100644 index 000000000..e44343a1c --- /dev/null +++ b/docs/.eslintrc.js @@ -0,0 +1,40 @@ +module.exports = { + env: { + browser: true, + es2021: true, + node: true, + }, + extends: [ + 'eslint:recommended', + 'plugin:react/recommended', + 'plugin:react/jsx-runtime', + ], + parser: '@babel/eslint-parser', + parserOptions: { + ecmaVersion: 2021, + sourceType: 'module', + ecmaFeatures: { + jsx: true, + }, + requireConfigFile: false, + babelOptions: { + presets: ['@babel/preset-react'], + }, + }, + plugins: ['react'], + rules: { + 'react/jsx-uses-react': 'error', + 'react/jsx-uses-vars': 'error', + }, + settings: { + react: { + version: 'detect', + }, + }, + ignorePatterns: [ + 'node_modules/', + 'dist/', + 'build/', + '*.config.js', + ], +}; \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/azure-adcs/available.mdx b/docs/api-reference/endpoints/app-connections/azure-adcs/available.mdx new file mode 100644 index 000000000..6b23a07e9 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-adcs/available.mdx @@ -0,0 +1,4 @@ +--- +title: "Available" +openapi: "GET /api/v1/app-connections/azure-adcs/available" +--- diff --git a/docs/api-reference/endpoints/app-connections/azure-adcs/create.mdx b/docs/api-reference/endpoints/app-connections/azure-adcs/create.mdx new file mode 100644 index 000000000..132ee409c --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-adcs/create.mdx @@ -0,0 +1,10 @@ +--- +title: "Create" +openapi: "POST /api/v1/app-connections/azure-adcs" +--- + + + Azure ADCS Connections must be created through the Infisical UI. + Check out the configuration docs for [Azure ADCS Connections](/integrations/app-connections/azure-adcs) for a step-by-step + guide. + \ No newline at end of file diff --git a/docs/api-reference/endpoints/app-connections/azure-adcs/delete.mdx b/docs/api-reference/endpoints/app-connections/azure-adcs/delete.mdx new file mode 100644 index 000000000..4d7037959 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-adcs/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/azure-adcs/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/azure-adcs/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/azure-adcs/get-by-id.mdx new file mode 100644 index 000000000..46a57d890 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-adcs/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/azure-adcs/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/azure-adcs/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/azure-adcs/get-by-name.mdx new file mode 100644 index 000000000..179ef9076 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-adcs/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/azure-adcs/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/azure-adcs/list.mdx b/docs/api-reference/endpoints/app-connections/azure-adcs/list.mdx new file mode 100644 index 000000000..c0f679ee1 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-adcs/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/azure-adcs" +--- diff --git a/docs/api-reference/endpoints/app-connections/azure-adcs/update.mdx b/docs/api-reference/endpoints/app-connections/azure-adcs/update.mdx new file mode 100644 index 000000000..4c4dfc91d --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/azure-adcs/update.mdx @@ -0,0 +1,10 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/azure-adcs/{connectionId}" +--- + + + Azure ADCS Connections must be updated through the Infisical UI. + Check out the configuration docs for [Azure ADCS Connections](/integrations/app-connections/azure-adcs) for a step-by-step + guide. + diff --git a/docs/changelog/overview.mdx b/docs/changelog/overview.mdx index 11a058019..7d8282727 100644 --- a/docs/changelog/overview.mdx +++ b/docs/changelog/overview.mdx @@ -26,7 +26,7 @@ The changelog below reflects new product developments and updates on a monthly b - Revamped UI for Access Controls, Access Tree, Policies, and Approval Workflows. - Released [TLS Certificate Authentication method](https://infisical.com/docs/documentation/platform/identities/tls-cert-auth). - Added ability to copy session tokens in the Infisical Dashboard. -- Expanded resource support for [Infisical Terraform Provider](https://infisical.com/docs/integrations/frameworks/terraform). +- Expanded resource support for [Infisical Terraform Provider](https://registry.terraform.io/providers/Infisical/infisical/latest/docs). ## May 2025 @@ -62,7 +62,7 @@ The changelog below reflects new product developments and updates on a monthly b ## March 2025 - Released [Infisical Gateway](https://infisical.com/docs/documentation/platform/gateways/overview) for secure access to private resources without needing direct inbound connections to private networks. -- Enhanced [Terraform](https://infisical.com/docs/integrations/frameworks/terraform#terraform) capabilities with token authentication, ability to import existing Infisical secrets as resources, and support for project templates. +- Enhanced [Terraform](https://registry.terraform.io/providers/Infisical/infisical/latest/docs) capabilities with token authentication, ability to import existing Infisical secrets as resources, and support for project templates. - Self-hosted improvements: Usage and billing visibility for enabled features, ability to delete users, and support for multiple super admins. - UI and UX updates: Improved secret import interface on the overview page, password reset without backup PDF. - CLI enhancements: Various improvements including multiline secret support and ability to pass headers. @@ -93,7 +93,7 @@ The changelog below reflects new product developments and updates on a monthly b - Added support for OIDC group mapping in [Keycloak](https://infisical.com/docs/documentation/platform/sso/keycloak-oidc/overview), enabling automatic mapping of Keycloak groups to Infisical for role-based access control. - Enhanced [Kubernetes operator](https://infisical.com/docs/integrations/platforms/kubernetes/overview#kubernetes-operator) with namespaced group support, bi-directional secret sync (push to Infisical), [dynamic secrets](https://infisical.com/docs/documentation/platform/dynamic-secrets/overview#dynamic-secrets) capabilities, and support for multiple operator instances. - Restructured navigation with dedicated sections for Secrets Management, [Certificate Management (PKI)](https://infisical.com/docs/documentation/platform/pki/overview), [Key Management (KMS)](https://infisical.com/docs/documentation/platform/kms/overview#key-management-service-kms), and [SSH Key Management](https://infisical.com/docs/documentation/platform/ssh). -- Added [ephemeral Terraform resource](https://infisical.com/docs/integrations/frameworks/terraform#terraform-provider) support and improved secret sync architecture. +- Added [ephemeral Terraform resource](https://registry.terraform.io/providers/Infisical/infisical/latest/docs) support and improved secret sync architecture. - Released [.NET provider](https://github.com/Infisical/infisical-dotnet-configuration) with first-party Azure authentication support and Azure CLI integration. - Implemented secret Access Visibility allowing users to view all entities with access to specific secrets in the secret side panel. - Added secret filtering by metadata and SSH assigned certificates (Version 1). @@ -212,7 +212,7 @@ The changelog below reflects new product developments and updates on a monthly b - Completed Postgres migration initiative with restructed Fastify-based backend. - Reduced size of Infisical Node.js SDK by ≈90%. - Added secret fallback support to all SDK's. -- Added Machine Identity support to [Terraform Provider](https://github.com/Infisical/terraform-provider-infisical). +- Added Machine Identity support to [Terraform Provider](https://registry.terraform.io/providers/Infisical/infisical/latest/docs). - Released [.NET SDK](https://infisical.com/docs/sdks/languages/csharp). - Added symmetric encryption support to all SDK's. - Fixed secret reminders bug, where reminders were not being updated correctly. @@ -276,7 +276,7 @@ The changelog below reflects new product developments and updates on a monthly b ## June 2023 -- Released the [Terraform Provider](https://infisical.com/docs/integrations/frameworks/terraform#5-run-terraform). +- Released the [Terraform Provider](https://registry.terraform.io/providers/Infisical/infisical/latest/docs). - Updated the usage and billing page. Added the free trial for the professional tier. - Added native integrations with [Checkly](https://infisical.com/docs/integrations/cloud/checkly), [Hashicorp Vault](https://infisical.com/docs/integrations/cloud/hashicorp-vault), and [Cloudflare Pages](https://infisical.com/docs/integrations/cloud/cloudflare-pages). - Completed a penetration test with a `very good` result. diff --git a/docs/contributing/getting-started/overview.mdx b/docs/contributing/getting-started/overview.mdx index 79bd97c95..35912fc8c 100644 --- a/docs/contributing/getting-started/overview.mdx +++ b/docs/contributing/getting-started/overview.mdx @@ -10,7 +10,7 @@ should approach the development and contribution process. Infisical has two major code-bases. One for the platform code, and one for SDKs. The contribution process has some key differences between the two, so we've split the documentation into two sections: - The [Infisical Platform](https://github.com/Infisical/infisical), the Infisical platform itself. -- The [Infisical SDK](https://github.com/Infisical/sdk), the official Infisical client SDKs. +- The [Infisical SDK](https://infisical.com/docs/sdks/overview), the official Infisical client SDKs. diff --git a/docs/contributing/sdk/developing.mdx b/docs/contributing/sdk/developing.mdx deleted file mode 100644 index 82fd28be8..000000000 --- a/docs/contributing/sdk/developing.mdx +++ /dev/null @@ -1,408 +0,0 @@ ---- -title: "Local development" -description: "This guide will help you contribute to the Infisical SDK." ---- - -## Fork and clone the repo - -[Fork](https://docs.github.com/en/get-started/quickstart/fork-a-repo) the [repository](https://github.com/Infisical/sdk) to your own GitHub account and then [clone](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository) it to your local device. - -Once, you've done that, create a new branch: - -```console -git checkout -b MY_BRANCH_NAME -``` - -## Set up environment variables - -Start by creating a .env file at the root of the Infisical directory then copy the contents of the file below into the .env file. - - - ```env - # This is required for running tests locally. - # Rename this file to ".env" and fill in the values below. - - # Please make sure that the machine identity has access to the project you are testing in. - # https://infisical.com/docs/documentation/platform/identities/universal-auth - INFISICAL_UNIVERSAL_CLIENT_ID=MACHINE_IDENTITY_CLIENT_ID - INFISICAL_UNIVERSAL_CLIENT_SECRET=MACHINE_IDENTITY_CLIENT_SECRET - - # The ID of the Infisical project where we will create the test secrets. - # NOTE: The project must have a dev environment. (This is created by default when you create a project.) - INFISICAL_PROJECT_ID=INFISICAL_TEST_PROJECT_ID - - # The Infisical site URL. If you are testing with a local Infisical instance, then this should be set to "http://localhost:8080". - INFISICAL_SITE_URL=https://app.infisical.com - -```` - - - - The above values are required for running tests locally. Before opening a pull request, make sure to run `cargo test` to ensure that all tests pass. - - - -## Guidelines - -### Predictable and consistent -When adding new functionality (such as new functions), it's very important that the functionality is added to _all_ the SDK's. This is to ensure that the SDK's are predictable and consistent across all languages. If you are adding new functionality, please make sure to add it to all the SDK's. - -### Handling errors -Error handling is very important when writing SDK's. We want to make sure that the SDK's are easy to use, and that the user gets a good understanding of what went wrong when something fails. When adding new functionality, please make sure to add proper error handling. [Read more about error handling here](#error-handling). - -### Tests -If you add new functionality or modify existing functionality, please write tests thats properly cover the new functionality. You can run tests locally by running `cargo test` from the root directory. You must always run tests before opening a pull request. - -### Code style -Please follow the default rust styling guide when writing code for the base SDK. [Read more about rust code style here](https://doc.rust-lang.org/nightly/style-guide/#the-default-rust-style). - - -## Prerequisites for contributing - -### Understanding the terms - -In the guide we use some terms that might be unfamiliar to you. Here's a quick explanation of the terms we use: -- **Base SDK**: The base SDK is the SDK that all other SDK's are built on top of. The base SDK is written in Rust, and is responsible for executing commands and parsing the input and output to and from JSON. -- **Commands**: Commands are what's being sent from the target language to the command handler. The command handler uses the command to execute the corresponding function in the base SDK. Commands are in reality just a JSON string that tells the command handler what function to execute, and what input to use. -- **Command handler**: The command handler is the part of the base SDK that takes care of executing commands. It also takes care of parsing the input and output to and from JSON. -- **Target language**: The target language refers to the actual SDK code. For example, the [Node.js SDK](https://www.npmjs.com/package/@infisical/sdk) is a "target language", and so is the [Python SDK](https://pypi.org/project/infisical-python/). - - -### Understanding the execution flow -After the target language SDK is initiated, it uses language-specific bindings to interact with the base SDK. -These bindings are instantiated, setting up the interface for command execution. A client within the command handler is created, which issues commands to the base SDK. -When a command is executed, it is first validated. If valid, the command handler locates the corresponding command to perform. If the command executes successfully, the command handler returns the output to the target language SDK, where it is parsed and returned to the user. -If the command handler fails to validate the input, an error will be returned to the target language SDK. - - - - - - - - -### Rust knowledge - -Contributing to the SDK requires intermediate to advanced knowledge of Rust concepts such as lifetimes, traits, generics, and async/await _(futures)_, and more. - -### Rust setup -The base SDK is written in rust. Therefore you must have rustc and cargo installed. You can install rustc and cargo by following the instructions [here](https://www.rust-lang.org/tools/install). - -You shouldn't have to use the rust cross compilation toolchain, as all compilation is done through a collection of Github Actions. However. If you need to test cross compilation, please do so with Github Actions. - -### Tests -If you add new functionality or modify existing functionality, please write tests thats properly cover the new functionality. You can run tests locally by running `cargo test` from the root directory. - -### Language-specific crates -The language-specific crates should ideally never have to be modified, as they are simply a wrapper for the `infisical-json` crate, which executes "commands" from the base SDK. If you need to create a new target-language specific crate, please try to create native bindings for the target language. Some languages don't have direct support for native bindings (Java as an example). In those cases we can use the C bindings (`crates/infisical-c`) in the target language. - - - - -## Generate types -Having almost seemless type safety from the base SDK to the target language is critical, as writing types for each language has a lot of drawbacks such as duplicated code, and lots of overhead trying to keep the types up-to-date and in sync across a large collection of languages. Therefore we decided to use [QuickType](https://quicktype.io/) and [Serde](https://serde.rs/) to help us generate types for each language. In our Rust base SDK (`crates/infisical`), we define all the inputs/outputs. - -If you are interested in reading about QuickType works under the hood, you can [read more here](http://blog.quicktype.io/under-the-hood/). - -This is an example of a type defined in Rust (both input and output). For this to become a generated type, you'll need to add it to our schema generator. More on that further down. -```rust -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -#[derive(Serialize, Deserialize, Debug, JsonSchema)] -#[serde(rename_all = "camelCase")] -// Input: -pub struct CreateSecretOptions { - pub environment: String, // environment - pub secret_comment: Option, // secretComment - pub path: Option, // secretPath - pub secret_value: String, // secretValue - pub skip_multiline_encoding: Option, // skipMultilineEncoding - pub r#type: Option, // shared / personal - pub project_id: String, // workspaceId - pub secret_name: String, // secretName (PASSED AS PARAMETER IN REQUEST) -} - -// Output: -#[derive(Serialize, Deserialize, Debug, JsonSchema)] -#[serde(rename_all = "camelCase")] -pub struct CreateSecretResponse { - pub secret: Secret, // "Secret" is defined elsewhere. -} -```` - -### Adding input types to the schema generator - -You will _only_ have to define outputs in our schema generator, then QuickType will take care of the rest behind the scenes. You can find the Rust crate that takes care of type generation here: `crates/sdk-schemas/src/main.rs`. - -Simply add the output _(also called response)_, to the `write_schema_for_response!` macro. This will let QuickType know that it should generate types for the given structs. The main function will look something like this: - -```rust -fn main() -> Result<()> { - // Input types for new Client - write_schema_for!(infisical_json::client::ClientSettings); - // Input types for Client::run_command - write_schema_for!(infisical_json::command::Command); - - // Output types for Client::run_command - // Only add structs which are direct results of SDK commands. - write_schema_for_response! { - infisical::manager::secrets::GetSecretResponse, - infisical::manager::secrets::ListSecretsResponse, - infisical::manager::secrets::UpdateSecretResponse, - infisical::manager::secrets::DeleteSecretResponse, - infisical::manager::secrets::CreateSecretResponse, // <-- This is the output from the above example! - infisical::auth::AccessTokenSuccessResponse - }; - - Ok(()) -} -``` - -### Generating the types for the target language - -Once you've added the output to the schema generator, you can generate the types for the target language by running the following command from the root directory: - -```console -$ npm install -$ npm run schemas -``` - -If you change any of the structs defined in the base SDK, you will need to run this script to re-generate the types. - -This command will run the `schemas.ts` file found in the `support/scripts` folder. If you are adding a new language, it's important that you add the language to the code. - -This is an example of how how we generate types for Node.js: - -```ts -const ts = await quicktype({ - inputData, - lang: "typescript", - rendererOptions: {} -}); -await ensureDir("./languages/node/src/infisical_client"); -writeToFile("./languages/node/src/infisical_client/schemas.ts", ts.lines); -``` - -## Building bindings -We've tried to streamline the building process as much as possible. So you shouldn't have to worry much about building bindings, as it should just be a few commands. - -### Node.js -Building bindings for Node.js is very straight foward. The command below will generate NAPI bindings for Node.js, and move the bindings to the correct folder. We use [NAPI-RS](https://napi.rs/) to generate the bindings. - -```console -$ cd languages/node -$ npm run build -``` - -### Python -To generate and use python bindings you will need to run the following commands. -The Python SDK is located inside the crates folder. This is a limitation of the maturin tool, forcing us to structure the project in this way. - -```console -$ pip install -U pip maturin -$ cd crates/infisical-py -$ python3 -m venv .venv -$ source .venv/bin/activate -$ maturin develop -``` - - - After running the commands above, it's very important that you rename the generated .so file to `infisical_py.so`. After renaming it you also need to move it into the root of the `crates/infisical-py` folder. - - -### Java -Java uses the C bindings to interact with the base SDK. To build and use the C bindings in Java, please follow the instructions below. - -```console -$ cd crates/infisical-c -$ cargo build --release -$ cd ../../languages/java -``` - - After generating the C bindings, the generated .so or .dll has been created in the `/target` directory at the root of the project. - You have to manually move the generated file into the `languages/java/src/main/resources` directory. - - -## Error handling - -### Error handling in the base SDK - -The base SDK should never panic. If an error occurs, we should return a `Result` with an error message. We have a custom Result type defined in the `error.rs` file in the base SDK. - -All our errors are defined in an enum called `Error`. The `Error` enum is defined in the `error.rs` file in the base SDK. The `Error` enum is used in the `Result` type, which is used as the return type for all functions in the base SDK. - -```rust -#[derive(Debug, Error)] -pub enum Error { - // Secret not found - #[error("Secret with name '{}' not found.", .secret_name)] - SecretNotFound { secret_name: String }, - - // .. other errors - - // Errors that are not specific to the base SDK. - #[error(transparent)] - Reqwest(#[from] reqwest::Error), - #[error(transparent)] - Serde(#[from] serde_json::Error), - #[error(transparent)] - Io(#[from] std::io::Error), -} -``` - -### Returning an error - -You can find many examples of how we return errors in the SDK code. A relevant example is for creating secrets, which can be found in `crates/infisical/src/api/secrets/create_secret.rs`. When the error happened due to a request error to our API, we have an API error handler. This prevents duplicate code and keeps error handling consistent across the SDK. You can find the api error handler in the `error.rs` file. - -### Error handling in the target language SDK's. - -All data sent to the target language SDK has the same format. The format is an object with 3 fields: `success (boolean)`, `data (could be anything or nothing)`, and `errorMessage (string or null)`. - -The `success` field is used to determine if the request was successful or not. The `data` field is used to return data from the SDK. The `errorMessage` field is used to return an error message if the request was not successful. - -This means that if the success if false or if the error message is not null, something went wrong and we should throw an error on the target-language level, with the error message. - -## Command handler - -### What is the command handler - -The command handler (the `infisical-json` crate), takes care of executing commands sent from the target language. It also takes care of parsing the input and output to and from JSON. The command handler is the only part of the base SDK that should be aware of JSON. The rest of the base SDK should be completely unaware of JSON, and only work with the Rust structs defined in the base SDK. - -The command handler exposes a function called `run_command`, which is what we use in the target language to execute commands. The function takes a json string as input, and returns a json string as output. We use helper functions generated by QuickType to convert the input and output to and from JSON. - -### Creating new SDK methods - -Creating new commands is necessary when adding new methods to the SDK's. Defining a new command is a 3-step process in most cases. - -#### 1. Define the input and output structs - -Earlier in this guide, we defined the input and output structs for the `CreateSecret` command. We will use that as an example here as well. - -#### 2. Creating the method in the base SDK - -The first step is to create the method in the base SDK. This step will be different depending on what method you are adding. In this example we're going to assume you're adding a function for creating a new secret. - -After you created the function for creating the secret, you'll need need to add it to the ClientSecrets implementation. We do it this way to keep the code organized and easy to read. The ClientSecrets struct is located in the `crates/infisical/src/manager/secrets.rs` file. - -```rust -pub struct ClientSecrets<'a> { - pub(crate) client: &'a mut crate::Client, -} - -impl<'a> ClientSecrets<'a> { - pub async fn create(&mut self, input: &CreateSecretOptions) -> Result { - create_secret(self.client, input).await // <-- This is the function you created! - } -} - -impl<'a> Client { - pub fn secrets(&'a mut self) -> ClientSecrets<'a> { - ClientSecrets { client: self } - } -} -``` - -#### 3. Define a new command - -We define new commands in the `crates/infisical-json/src/command.rs` file. The `Command` enum is what we use to define new commands. - -In the codesnippet below we define a new command called `CreateSecret`. The `CreateSecret` command takes a `CreateSecretOptions` struct as input. We don't have to define the output, because QuickType's converter helps us with figuring out the return type for each command. - -````rust -```rust -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; - -#[derive(Serialize, Deserialize, JsonSchema, Debug)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -pub enum Command { - GetSecret(GetSecretOptions), - ListSecrets(ListSecretsOptions), - CreateSecret(CreateSecretOptions), // <-- The new command! - UpdateSecret(UpdateSecretOptions), - DeleteSecret(DeleteSecretOptions), -} -```` - -#### 4. Add the command to the command handler - -After defining the command, we need to add it to the command handler itself. This takes place in the `crates/infisical-json/src/client.rs` file. The `run_command` function is what we use to execute commands. - -In the Client implementation we try to parse the JSON string into a `Command` enum. If the parsing is successful, we match the command and execute the corresponding function. - -```rust -match cmd { - Command::GetSecret(req) => self.0.secrets().get(&req).await.into_string(), - Command::ListSecrets(req) => self.0.secrets().list(&req).await.into_string(), - Command::UpdateSecret(req) => self.0.secrets().update(&req).await.into_string(), - Command::DeleteSecret(req) => self.0.secrets().delete(&req).await.into_string(), - - // This is the new command: - Command::CreateSecret(req) => self.0.secrets().create(&req).await.into_string(), -} -``` - -#### 5. Implementing the new command in the target language SDK's - -We did it! We've now added a new command to the base SDK. The last step is to implement the new command in the target language SDK's. The process is a little different from language to language, but in this example we're going to assume that we're adding a new command to the Node.js SDK. - -First you'll need to generate the new type schemas, we added a new command, input struct, and output struct. [Read more about generating types here](#generating-the-types-for-the-target-language). - -Secondly you need to build the new node bindings so we can use the new functionality in the Node.js SDK. You can do this by running the following command from the `languages/node` directory: - -```console -$ npm install -$ npm run build -``` - -The build command will execute a build script in the `infisical-napi` crate, and move the generated bindings to the appropriate folder. - -After building the new bindings, you can access the new functionality in the Node.js SDK source. - -```ts -// 'binding' is a js file that makes it easier to access the methods in the bindings. (it's auto generated when running npm run build) -import * as rust from "../../binding"; -// We can import the newly generated types from the schemas.ts file. (Generated with QuickType!) -import type { CreateSecretOptions, CreateSecretResponse } from "./schemas"; -// This is the QuickType converter that we use to create commands with! It takes care of all JSON parsing and serialization. -import { Convert, ClientSettings } from "./schemas"; - -export class InfisicalClient { - #client: rust.Client; - - constructor(settings: ClientSettings) { - const settingsJson = settings == null ? null : Convert.clientSettingsToJson(settings); - this.#client = new rust.InfisicalClient(settingsJson); - } - - // ... getSecret - // ... listSecrets - // ... updateSecret - // ... deleteSecret - - async createSecret(options: CreateSecretOptions): Promise { - // The runCommand will return a JSON string, which we can parse into a CreateSecretResponse. - const command = await this.#client.runCommand( - Convert.commandToJson({ - createSecret: options - }) - ); - const response = Convert.toResponseForCreateSecretResponse(command); // <-- This is the QuickType converter in action! - - // If the response is not successful or the data is null, we throw an error. - if (!response.success || response.data == null) { - throw new Error(response.errorMessage ?? "Something went wrong"); - } - - // To make it easier to work with the response, we return the secret directly. - return response.data.secret; - } -} -``` - -And that's it! We've now added a new command to the base SDK, and implemented it in the Node.js SDK. The process is very similar for all other languages, but the code will look a little different. - -## Conclusion - -The SDK has a lot of moving parts, and it can be a little overwhelming at first. But once you get the hang of it, it's actually quite simple. If you have any questions, feel free to reach out to us on [Slack](https://infisical.com/slack), or [open an issue](https://github.com/Infisical/sdk/issues) on GitHub. diff --git a/docs/docs.json b/docs/docs.json index 8cc458764..08121fa08 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -98,6 +98,7 @@ { "group": "App Connections", "pages": [ + "integrations/app-connections", "integrations/app-connections/overview", { "group": "Connections", @@ -106,6 +107,7 @@ "integrations/app-connections/auth0", "integrations/app-connections/aws", "integrations/app-connections/azure-app-configuration", + "integrations/app-connections/azure-adcs", "integrations/app-connections/azure-client-secrets", "integrations/app-connections/azure-devops", "integrations/app-connections/azure-key-vault", @@ -183,6 +185,7 @@ { "group": "User Authentication", "pages": [ + "integrations/user-authentication", "documentation/platform/auth-methods/email-password", { "group": "SSO", @@ -242,6 +245,7 @@ { "group": "Machine Identities", "pages": [ + "integrations/machine-authentication", "documentation/platform/identities/alicloud-auth", "documentation/platform/identities/aws-auth", "documentation/platform/identities/azure-auth", @@ -310,7 +314,8 @@ "self-hosting/guides/mongo-to-postgres", "self-hosting/guides/custom-certificates", "self-hosting/guides/automated-bootstrapping", - "self-hosting/guides/production-hardening" + "self-hosting/guides/production-hardening", + "self-hosting/guides/monitoring-telemetry" ] }, { @@ -341,10 +346,7 @@ }, { "group": "Architecture", - "pages": [ - "internals/architecture/components", - "internals/architecture/cloud" - ] + "pages": ["internals/architecture/components", "internals/architecture/cloud"] }, "internals/security", "internals/service-tokens" @@ -369,10 +371,6 @@ "contributing/platform/backend/how-to-create-a-feature", "contributing/platform/backend/folder-structure" ] - }, - { - "group": "Contributing to SDK", - "pages": ["contributing/sdk/developing"] } ] } @@ -422,6 +420,7 @@ { "group": "Secret Rotation", "pages": [ + "integrations/secret-rotations", "documentation/platform/secret-rotation/overview", "documentation/platform/secret-rotation/auth0-client-secret", "documentation/platform/secret-rotation/aws-iam-user-secret", @@ -437,6 +436,7 @@ { "group": "Dynamic Secrets", "pages": [ + "integrations/dynamic-secrets", "documentation/platform/dynamic-secrets/overview", "documentation/platform/dynamic-secrets/aws-elasticache", "documentation/platform/dynamic-secrets/aws-iam", @@ -507,6 +507,7 @@ { "group": "Secret Syncs", "pages": [ + "integrations/secret-syncs", "integrations/secret-syncs/overview", { "group": "Syncs", @@ -563,10 +564,7 @@ "integrations/cloud/gcp-secret-manager", { "group": "Cloudflare", - "pages": [ - "integrations/cloud/cloudflare-pages", - "integrations/cloud/cloudflare-workers" - ] + "pages": ["integrations/cloud/cloudflare-pages", "integrations/cloud/cloudflare-workers"] }, "integrations/cloud/terraform-cloud", "integrations/cloud/databricks", @@ -615,6 +613,7 @@ { "group": "Framework Integrations", "pages": [ + "integrations/framework-integrations", "integrations/frameworks/spring-boot-maven", "integrations/frameworks/react", "integrations/frameworks/vue", @@ -660,9 +659,7 @@ "documentation/platform/secret-scanning/overview", { "group": "Concepts", - "pages": [ - "documentation/platform/secret-scanning/concepts/secret-scanning" - ] + "pages": ["documentation/platform/secret-scanning/concepts/secret-scanning"] } ] }, @@ -689,6 +686,7 @@ "documentation/platform/pki/subscribers", "documentation/platform/pki/certificates", "documentation/platform/pki/acme-ca", + "documentation/platform/pki/azure-adcs", "documentation/platform/pki/est", "documentation/platform/pki/alerting", { @@ -711,18 +709,13 @@ "documentation/platform/ssh/overview", { "group": "Concepts", - "pages": [ - "documentation/platform/ssh/concepts/ssh-certificates" - ] + "pages": ["documentation/platform/ssh/concepts/ssh-certificates"] } ] }, { "group": "Platform Reference", - "pages": [ - "documentation/platform/ssh/usage", - "documentation/platform/ssh/host-groups" - ] + "pages": ["documentation/platform/ssh/usage", "documentation/platform/ssh/host-groups"] } ] }, @@ -769,11 +762,7 @@ "cli/commands/reset", { "group": "infisical scan", - "pages": [ - "cli/commands/scan", - "cli/commands/scan-git-changes", - "cli/commands/scan-install" - ] + "pages": ["cli/commands/scan", "cli/commands/scan-git-changes", "cli/commands/scan-install"] } ] }, @@ -1107,9 +1096,7 @@ "pages": [ { "group": "Kubernetes", - "pages": [ - "api-reference/endpoints/dynamic-secrets/kubernetes/create-lease" - ] + "pages": ["api-reference/endpoints/dynamic-secrets/kubernetes/create-lease"] }, "api-reference/endpoints/dynamic-secrets/create", "api-reference/endpoints/dynamic-secrets/update", @@ -1395,6 +1382,18 @@ "api-reference/endpoints/app-connections/aws/delete" ] }, + { + "group": "Azure ADCS", + "pages": [ + "api-reference/endpoints/app-connections/azure-adcs/list", + "api-reference/endpoints/app-connections/azure-adcs/available", + "api-reference/endpoints/app-connections/azure-adcs/get-by-id", + "api-reference/endpoints/app-connections/azure-adcs/get-by-name", + "api-reference/endpoints/app-connections/azure-adcs/create", + "api-reference/endpoints/app-connections/azure-adcs/update", + "api-reference/endpoints/app-connections/azure-adcs/delete" + ] + }, { "group": "Azure App Configuration", "pages": [ @@ -2452,7 +2451,7 @@ "sdks/languages/node", "sdks/languages/python", "sdks/languages/java", - "sdks/languages/csharp", + "sdks/languages/dotnet", "sdks/languages/cpp", "sdks/languages/rust", "sdks/languages/go", @@ -2568,7 +2567,7 @@ }, { "label": "Terraform", - "href": "https://infisical.com/docs/integrations/frameworks/terraform" + "href": "https://registry.terraform.io/providers/Infisical/infisical/latest/docs" }, { "label": "Ansible", @@ -2680,5 +2679,11 @@ "koala": { "publicApiKey": "pk_b50d7184e0e39ddd5cdb43cf6abeadd9b97d" } - } + }, + "redirects": [ + { + "source": "/sdks/languages/csharp", + "destination": "/sdks/languages/dotnet" + } + ] } diff --git a/docs/documentation/platform/access-controls/access-requests.mdx b/docs/documentation/platform/access-controls/access-requests.mdx index cc0add8d8..cf2643db8 100644 --- a/docs/documentation/platform/access-controls/access-requests.mdx +++ b/docs/documentation/platform/access-controls/access-requests.mdx @@ -25,6 +25,11 @@ This functionality works in the following way: {/* ![Access Request Review](/images/platform/access-controls/review-access-request.png) */} ![Access Request Bypass](/images/platform/access-controls/access-request-bypass.png) + + Optionally, approvers can edit the duration of an access request to reduce how long access will be granted by clicking the **Edit** icon next to the duration. + ![Edit Access Request](/images/platform/access-controls/edit-access-request.png) + + If the access request matches with a policy that allows break-glass approval bypasses, the requester may bypass the policy and get access to the resource diff --git a/docs/documentation/platform/external-migrations/vault.mdx b/docs/documentation/platform/external-migrations/vault.mdx index ef139e8e4..8254e104f 100644 --- a/docs/documentation/platform/external-migrations/vault.mdx +++ b/docs/documentation/platform/external-migrations/vault.mdx @@ -8,12 +8,12 @@ description: "Learn how to migrate secrets from Vault to Infisical." Migrating from Vault Self-Hosted or Dedicated Vault is a straight forward process with our inbuilt migration option. In order to migrate from Vault, you'll need to provide Infisical an access token to your Vault instance. -Currently the Vault migration only supports migrating secrets from the KV v2 secrets engine. If you're using a different secrets engine, please open an issue on our [GitHub repository](https://github.com/infisical/infisical/issues). +Currently the Vault migration only supports migrating secrets from the KV V2 and V1 secrets engine. If you're using a different secrets engine, please open an issue on our [GitHub repository](https://github.com/infisical/infisical/issues). ### Prerequisites -- A Vault instance with the KV v2 secrets engine enabled. +- A Vault instance with the KV secret engine enabled. - An access token to your Vault instance. diff --git a/docs/documentation/platform/github-org-sync.mdx b/docs/documentation/platform/github-org-sync.mdx index 519e12db8..71dd9dbf6 100644 --- a/docs/documentation/platform/github-org-sync.mdx +++ b/docs/documentation/platform/github-org-sync.mdx @@ -45,6 +45,64 @@ Once configured, the GitHub Organization Synchronization feature functions as fo When a user logs in via the GitHub OAuth flow and selects the configured organization, the system will then automatically synchronize the teams they are a part of in GitHub with corresponding groups in Infisical. +## Manual Team Sync + +You can manually synchronize GitHub teams for all organization members who have previously logged in with GitHub. This bulk sync operation updates team memberships without requiring users to log in again. + + + + To perform manual syncs, you'll need to create a GitHub Personal Access Token with the appropriate permissions. GitHub offers two types of tokens: + + + + 1. Go to [GitHub Settings → Personal Access Tokens → Tokens (classic)](https://github.com/settings/tokens) + 2. Click **Generate new token** → **Generate new token (classic)** + 3. Give your token a descriptive name (e.g., "Infisical GitHub Sync") + 4. Set an appropriate expiration date + 5. Select the **read:org** scope - Required to read organization team information + 6. Click **Generate token** + 7. Copy the token immediately (you won't be able to see it again) + + ![Classic Token Creation](../../images/platform/external-syncs/github-classic-token.png) + + + 1. Go to [GitHub Settings → Personal Access Tokens → Fine-grained tokens](https://github.com/settings/personal-access-tokens/new) + 2. Click **Generate new token** + 3. Give your token a descriptive name (e.g., "Infisical GitHub Sync") + 4. Set an appropriate expiration date + 5. Select your organization under **Resource owner** + 6. Under **Organization permissions**, set **Members** to **Read** + 7. Click **Generate token** + 8. Copy the token immediately (you won't be able to see it again) + + ![Fine-grained Token Creation](../../images/platform/external-syncs/github-fine-grained-token.png) + + + + + + 1. Navigate to the **Single Sign-On (SSO)** page and select the **Provisioning** tab. + 2. Click the **Configure** button next to your GitHub Organization configuration. + 3. In the configuration modal, you'll find an optional **GitHub Access Token** field. + 4. Paste the token you generated in the previous step. + 5. Click **Update** to save the configuration. + + ![Token Configuration Modal](../../images/platform/external-syncs/github-token-config-modal.png) + + + + Once you have configured the GitHub access token: + + 1. Navigate to the **Single Sign-On (SSO)** page and select the **Provisioning** tab. + 2. You'll see a **Sync Now** section with a button to trigger the manual sync. + 3. Click **Sync Now** to synchronize GitHub teams for all organization members. + + ![Manual Sync Button](../../images/platform/external-syncs/github-manual-sync-button.png) + + The sync operation will process all organization members who have previously logged in with GitHub and update their team memberships accordingly. + + + ## Troubleshooting diff --git a/docs/documentation/platform/pki/acme-ca.mdx b/docs/documentation/platform/pki/acme-ca.mdx index 8b1fa10db..bf130da9e 100644 --- a/docs/documentation/platform/pki/acme-ca.mdx +++ b/docs/documentation/platform/pki/acme-ca.mdx @@ -147,6 +147,8 @@ In the following steps, we explore how to set up ACME Certificate Authority inte - **Directory URL**: Enter the ACME v2 directory URL for your chosen CA provider (e.g., `https://acme-v02.api.letsencrypt.org/directory` for Let's Encrypt). - **Account Email**: Email address to associate with your ACME account. This email will receive important notifications about your certificates. - **Enable Direct Issuance**: Toggle on to allow direct certificate issuance without requiring subscribers. + - **EAB Key Identifier (KID)**: (Optional) The Key Identifier (KID) provided by your ACME CA for External Account Binding (EAB). This is required by some ACME providers (e.g., ZeroSSL, DigiCert) to link your ACME account to an external account you've pre-registered with them. + - **EAB HMAC Key**: (Optional) The HMAC Key provided by your ACME CA for External Account Binding (EAB). This key is used in conjunction with the KID to prove ownership of the external account during ACME account registration. Finally, press **Create** to register the ACME CA with Infisical. @@ -277,6 +279,19 @@ Let's Encrypt is a free, automated, and open Certificate Authority that provides Always test your ACME integration using Let's Encrypt's staging environment first. This allows you to verify your DNS configuration and certificate issuance process without consuming your production rate limits. +## Example: DigiCert Integration + +DigiCert is a leading commercial Certificate Authority providing a wide range of trusted SSL/TLS certificates. Infisical can integrate with [DigiCert's ACME](https://docs.digicert.com/en/certcentral/certificate-tools/certificate-lifecycle-automation-guides/third-party-acme-integration/request-and-manage-certificates-with-acme.html) service to automate the provisioning and management of these certificates. + +- **Directory URL**: `https://acme.digicert.com/v2/acme/directory` +- **External Account Binding (EAB)**: Required. You will need a Key Identifier (KID) and HMAC Key from your DigiCert account to register the ACME CA in Infisical. +- **Certificate Validity**: Typically 90 days, with automatic renewal through Infisical. +- **Trusted By**: All major browsers and operating systems. + + + When integrating with DigiCert ACME, ensure you have obtained the necessary External Account Binding (EAB) Key Identifier (KID) and HMAC Key from your DigiCert account. + + ## FAQ diff --git a/docs/documentation/platform/pki/azure-adcs.mdx b/docs/documentation/platform/pki/azure-adcs.mdx new file mode 100644 index 000000000..b0cea565e --- /dev/null +++ b/docs/documentation/platform/pki/azure-adcs.mdx @@ -0,0 +1,206 @@ +--- +title: "Certificates with Azure ADCS" +description: "Learn how to issue and manage certificates using Microsoft Active Directory Certificate Services (ADCS) with Infisical." +--- + +Issue and manage certificates using Microsoft Active Directory Certificate Services (ADCS) for enterprise-grade certificate management integrated with your existing Windows infrastructure. + +## Prerequisites + +Before setting up ADCS integration, ensure you have: + +- Microsoft Active Directory Certificate Services (ADCS) server running and accessible +- Domain administrator account with certificate management permissions +- ADCS web enrollment enabled on your server +- Network connectivity from Infisical to the ADCS server +- **IP whitelisting**: Your ADCS server must allow connections from Infisical's IP addresses + - For Infisical Cloud instances, see [Networking Configuration](/documentation/setup/networking) for the list of IPs to whitelist + - For self-hosted instances, whitelist your Infisical server's IP address +- Azure ADCS app connection configured (see [Azure ADCS Connection](/integrations/app-connections/azure-adcs)) + +## Complete Workflow: From Setup to Certificate Issuance + +This section walks you through the complete end-to-end process of setting up Azure ADCS integration and issuing your first certificate. + + + + In your Infisical project, go to your **Certificate Project** → **Certificate Authority** to access the external CAs page. + + ![External CA Page](/images/platform/pki/azure-adcs/azure-adcs-external-ca-page.png) + + + + Click **Create CA** and configure: + - **Type**: Choose **Azure AD Certificate Service** + - **Name**: Friendly name for this CA (e.g., "Production ADCS CA") + - **App Connection**: Choose your ADCS connection from the dropdown + + ![External CA Form](/images/platform/pki/azure-adcs/azure-adcs-external-ca-form.png) + + + + Once created, your Azure ADCS Certificate Authority will appear in the list and be ready for use. + + ![External CA Created](/images/platform/pki/azure-adcs/azure-adcs-external-ca-created.png) + + + + Go to **Subscribers** to access the subscribers page. + + ![Subscribers Page](/images/platform/pki/azure-adcs/azure-adcs-subscribers-page.png) + + + + Click **Add Subscriber** and configure: + - **Name**: Unique subscriber name (e.g., "web-server-certs") + - **Certificate Authority**: Select your ADCS CA + - **Common Name**: Certificate CN (e.g., "api.example.com") + - **Certificate Template**: Select from dynamically loaded ADCS templates + - **Subject Alternative Names**: DNS names, IP addresses, or email addresses + - **TTL**: Certificate validity period (e.g., "1y" for 1 year) + - **Additional Subject Fields**: Organization, OU, locality, state, country, email (if required by template) + + ![Subscribers Form](/images/platform/pki/azure-adcs/azure-adcs-subscribers-form.png) + + + + Your subscriber is now created and ready to issue certificates. + + ![Subscriber Created](/images/platform/pki/azure-adcs/azure-adcs-subscribers-created.png) + + + + Click into your subscriber and click **Order Certificate** to generate a new certificate using your ADCS template. + + ![Issue New Certificate](/images/platform/pki/azure-adcs/azure-adcs-subscriber-issue-new-certificate.png) + + + + Your certificate has been successfully issued by the ADCS server and is ready for use. + + ![Certificate Created](/images/platform/pki/azure-adcs/azure-adcs-certificate-created.png) + + + + Navigate to **Certificates** to view detailed information about all issued certificates, including expiration dates, serial numbers, and certificate chains. + + ![Certificates Page](/images/platform/pki/azure-adcs/azure-adcs-certificates-page.png) + + + +## Certificate Templates + +Infisical automatically retrieves available certificate templates from your ADCS server, ensuring you can only select templates that are properly configured and accessible. The system dynamically discovers templates during the certificate authority setup and certificate issuance process. + +### Common Template Types + +ADCS templates you might see include: +- **Web Server**: For SSL/TLS certificates with server authentication +- **Computer**: For machine authentication certificates +- **User**: For client authentication certificates +- **Basic EFS**: For Encrypting File System certificates +- **EFS Recovery Agent**: For EFS data recovery +- **Administrator**: For administrative certificates +- **Subordinate Certification Authority**: For issuing CA certificates + +### Template Requirements + +Ensure your ADCS templates are configured with: +- **Enroll permissions** for your connection account +- **Auto-enroll permissions** if using automated workflows +- **Subject name requirements** matching your certificate requests +- **Key usage extensions** appropriate for your use case + + +**Dynamic Template Discovery**: Infisical queries your ADCS server in real-time to populate available templates. Only templates you have permission to use will be displayed during certificate issuance. + + +## Certificate Issuance Limitations + +### Immediate Issuance Only + + +**Manual Approval Not Supported**: Infisical currently supports only **immediate certificate issuance**. Certificates that require manual approval or are held by ADCS policies cannot be issued through Infisical yet. + + +For successful certificate issuance, ensure your ADCS templates and policies are configured to: +- **Auto-approve** certificate requests without manual intervention +- **Not require** administrator approval for the templates you plan to use +- **Allow** the connection account to request and receive certificates immediately + +### What Happens with Manual Approval + +If a certificate request requires manual approval: +1. The request will be submitted to ADCS successfully +2. Infisical will attempt to retrieve the certificate with exponential backoff (up to 5 retries over ~1 minute) +3. If the certificate is not approved within this timeframe, the request will **fail** +4. **No background polling**: Currently, Infisical does not check for certificates that might be approved hours or days later + + +**Future Enhancement**: Background polling for delayed certificate approvals is planned for future releases. + + +### Certificate Revocation + + +Certificate revocation is **not supported** by the Azure ADCS connector due to security and complexity considerations. + + +## Advanced Configuration + +### Custom Validity Periods + +Enable custom certificate validity periods on your ADCS server: + +```cmd +# Run on ADCS server as Administrator +certutil -setreg policy\EditFlags +EDITF_ATTRIBUTEENDDATE +net stop certsvc +net start certsvc +``` + +This allows Infisical to control certificate expiration dates directly. + +## Troubleshooting + +### Common Issues + +**Certificate Request Denied** +- Verify ADCS template permissions for your connection account +- Check template subject name requirements +- Ensure template allows the requested key algorithm and size + +**Revocation Service Unavailable** +- Verify IIS is running and the revocation endpoint is accessible +- Check IIS application pool permissions +- Test endpoint connectivity from Infisical + +**Template Not Found** +- Verify template exists on ADCS server and is published +- Check that your connection account has enrollment permissions for the template +- Ensure the template is properly configured and available in the ADCS web enrollment interface +- Templates are dynamically loaded - refresh the PKI Subscriber form if templates don't appear + +**Certificate Request Pending/Timeout** +- Check if your ADCS template requires manual approval - Infisical only supports immediate issuance +- Verify the certificate template is configured for auto-approval +- Ensure your connection account has sufficient permissions to request certificates without approval +- Review ADCS server policies that might be holding the certificate request + +**Network Connectivity Issues** +- Verify your ADCS server's firewall allows connections from Infisical +- For Infisical Cloud: Ensure Infisical's IP addresses are whitelisted (see [Networking Configuration](/documentation/setup/networking)) +- For self-hosted: Whitelist your Infisical server's IP address on the ADCS server +- Test HTTPS connectivity to the ADCS web enrollment endpoint +- Check for any network security appliances blocking the connection + +**Authentication Failures** +- Verify ADCS connection credentials +- Check domain account permissions +- Ensure network connectivity to ADCS server + +**SSL/TLS Certificate Errors** +- For ADCS servers with self-signed or private certificates: disable "Reject Unauthorized" in the SSL tab of your Azure ADCS app connection, or provide the certificate in PEM format +- Common SSL errors: `UNABLE_TO_VERIFY_LEAF_SIGNATURE`, `SELF_SIGNED_CERT_IN_CHAIN`, `CERT_HAS_EXPIRED` +- The SSL configuration applies to all HTTPS communications between Infisical and your ADCS server +- Only HTTPS URLs are supported - HTTP connections are not allowed for security reasons diff --git a/docs/documentation/platform/secrets-mgmt/concepts/secrets-delivery.mdx b/docs/documentation/platform/secrets-mgmt/concepts/secrets-delivery.mdx index 7ad11948c..df3885055 100644 --- a/docs/documentation/platform/secrets-mgmt/concepts/secrets-delivery.mdx +++ b/docs/documentation/platform/secrets-mgmt/concepts/secrets-delivery.mdx @@ -22,7 +22,7 @@ The table below provides a quick overview of which delivery method may be suitab | Kubernetes (file-based, with rotation) | [Kubernetes CSI Provider](/integrations/platforms/kubernetes-csi) | Mounted files | Uses CSI driver to mount secrets as files with automatic rotation | | Image builds (VMs or containers) | [Packer Plugin](/integrations/frameworks/packer) | Env vars or files | Inject secrets at image build time | | Ansible automation | [Ansible Collection](/integrations/platforms/ansible) | Variables | Runtime secret fetching in playbooks using lookup plugin | -| Terraform / Pulumi | [Terraform Provider](/integrations/frameworks/terraform), [Pulumi](/integrations/frameworks/pulumi) | Inputs / ephemeral resources | Use ephemeral for security; avoids storing secrets in state | +| Terraform / Pulumi | [Terraform Provider](https://registry.terraform.io/providers/Infisical/infisical/latest/docs), [Pulumi](/integrations/frameworks/pulumi) | Inputs / ephemeral resources | Use ephemeral for security; avoids storing secrets in state | | Third-party platforms (GitHub, AWS, etc.) | [Secret Syncs](/integrations/secret-syncs/overview) | Preloaded secrets | Push secrets to platforms that can't fetch directly from Infisical | From here, you can explore the delivery method that best matches your environment: @@ -90,7 +90,7 @@ This is useful when external systems require secrets to be available ahead of ti Infisical integrates with common IaC and automation tools to help you securely inject secrets into your infrastructure provisioning workflows: -- [Terraform](/integrations/frameworks/terraform): Use the official Infisical Terraform provider to fetch secrets either as ephemeral resources (never written to state files) or as traditional data sources. Ideal for managing cloud infrastructure while keeping secrets secure and version-safe. +- [Terraform](https://registry.terraform.io/providers/Infisical/infisical/latest/docs): Use the official Infisical Terraform provider to fetch secrets either as ephemeral resources (never written to state files) or as traditional data sources. Ideal for managing cloud infrastructure while keeping secrets secure and version-safe. - [Pulumi](/integrations/frameworks/pulumi): Integrate Infisical into Pulumi projects using the Terraform Bridge, allowing you to fetch and manage secrets in TypeScript, Go, Python, or C# — without changing your existing workflows. - [Ansible](/integrations/platforms/ansible): Retrieve secrets from Infisical at runtime using the official Ansible Collection and lookup plugin. Works well for dynamic configuration during playbook execution. - [Packer](/integrations/frameworks/packer): Inject secrets into VM or container images at build time using the Infisical Packer Plugin — useful for provisioning base images that require secure configuration values. diff --git a/docs/images/app-connections/azure-adcs/azure-adcs-app-connection-created.png b/docs/images/app-connections/azure-adcs/azure-adcs-app-connection-created.png new file mode 100644 index 000000000..dff826130 Binary files /dev/null and b/docs/images/app-connections/azure-adcs/azure-adcs-app-connection-created.png differ diff --git a/docs/images/app-connections/azure-adcs/azure-adcs-app-connection-form-ssl.png b/docs/images/app-connections/azure-adcs/azure-adcs-app-connection-form-ssl.png new file mode 100644 index 000000000..c919a1a87 Binary files /dev/null and b/docs/images/app-connections/azure-adcs/azure-adcs-app-connection-form-ssl.png differ diff --git a/docs/images/app-connections/azure-adcs/azure-adcs-app-connection-form.png b/docs/images/app-connections/azure-adcs/azure-adcs-app-connection-form.png new file mode 100644 index 000000000..002836a58 Binary files /dev/null and b/docs/images/app-connections/azure-adcs/azure-adcs-app-connection-form.png differ diff --git a/docs/images/app-connections/azure-adcs/azure-adcs-select-connection.png b/docs/images/app-connections/azure-adcs/azure-adcs-select-connection.png new file mode 100644 index 000000000..16321c3dd Binary files /dev/null and b/docs/images/app-connections/azure-adcs/azure-adcs-select-connection.png differ diff --git a/docs/images/app-connections/hashicorp-vault/vault-infisical-connect-modal.png b/docs/images/app-connections/hashicorp-vault/vault-infisical-connect-modal.png index 872d11cab..ebe9fc2ad 100644 Binary files a/docs/images/app-connections/hashicorp-vault/vault-infisical-connect-modal.png and b/docs/images/app-connections/hashicorp-vault/vault-infisical-connect-modal.png differ diff --git a/docs/images/platform/access-controls/edit-access-request.png b/docs/images/platform/access-controls/edit-access-request.png new file mode 100644 index 000000000..c5db2f5f9 Binary files /dev/null and b/docs/images/platform/access-controls/edit-access-request.png differ diff --git a/docs/images/platform/external-syncs/github-classic-token.png b/docs/images/platform/external-syncs/github-classic-token.png new file mode 100644 index 000000000..f59c4f362 Binary files /dev/null and b/docs/images/platform/external-syncs/github-classic-token.png differ diff --git a/docs/images/platform/external-syncs/github-fine-grained-token.png b/docs/images/platform/external-syncs/github-fine-grained-token.png new file mode 100644 index 000000000..4924fedd0 Binary files /dev/null and b/docs/images/platform/external-syncs/github-fine-grained-token.png differ diff --git a/docs/images/platform/external-syncs/github-manual-sync-button.png b/docs/images/platform/external-syncs/github-manual-sync-button.png new file mode 100644 index 000000000..291b9f033 Binary files /dev/null and b/docs/images/platform/external-syncs/github-manual-sync-button.png differ diff --git a/docs/images/platform/external-syncs/github-org-sync-manual-sync-token.png b/docs/images/platform/external-syncs/github-org-sync-manual-sync-token.png new file mode 100644 index 000000000..5f81232cf Binary files /dev/null and b/docs/images/platform/external-syncs/github-org-sync-manual-sync-token.png differ diff --git a/docs/images/platform/external-syncs/github-org-sync-manual-sync.png b/docs/images/platform/external-syncs/github-org-sync-manual-sync.png new file mode 100644 index 000000000..3ff168629 Binary files /dev/null and b/docs/images/platform/external-syncs/github-org-sync-manual-sync.png differ diff --git a/docs/images/platform/external-syncs/github-token-config-modal.png b/docs/images/platform/external-syncs/github-token-config-modal.png new file mode 100644 index 000000000..106738d3c Binary files /dev/null and b/docs/images/platform/external-syncs/github-token-config-modal.png differ diff --git a/docs/images/platform/pki/azure-adcs/azure-adcs-certificate-created.png b/docs/images/platform/pki/azure-adcs/azure-adcs-certificate-created.png new file mode 100644 index 000000000..e82b597eb Binary files /dev/null and b/docs/images/platform/pki/azure-adcs/azure-adcs-certificate-created.png differ diff --git a/docs/images/platform/pki/azure-adcs/azure-adcs-certificates-page.png b/docs/images/platform/pki/azure-adcs/azure-adcs-certificates-page.png new file mode 100644 index 000000000..b5666eaf0 Binary files /dev/null and b/docs/images/platform/pki/azure-adcs/azure-adcs-certificates-page.png differ diff --git a/docs/images/platform/pki/azure-adcs/azure-adcs-external-ca-created.png b/docs/images/platform/pki/azure-adcs/azure-adcs-external-ca-created.png new file mode 100644 index 000000000..3326f6bac Binary files /dev/null and b/docs/images/platform/pki/azure-adcs/azure-adcs-external-ca-created.png differ diff --git a/docs/images/platform/pki/azure-adcs/azure-adcs-external-ca-form.png b/docs/images/platform/pki/azure-adcs/azure-adcs-external-ca-form.png new file mode 100644 index 000000000..3c5ccf079 Binary files /dev/null and b/docs/images/platform/pki/azure-adcs/azure-adcs-external-ca-form.png differ diff --git a/docs/images/platform/pki/azure-adcs/azure-adcs-external-ca-page.png b/docs/images/platform/pki/azure-adcs/azure-adcs-external-ca-page.png new file mode 100644 index 000000000..685b40492 Binary files /dev/null and b/docs/images/platform/pki/azure-adcs/azure-adcs-external-ca-page.png differ diff --git a/docs/images/platform/pki/azure-adcs/azure-adcs-subscriber-issue-new-certificate.png b/docs/images/platform/pki/azure-adcs/azure-adcs-subscriber-issue-new-certificate.png new file mode 100644 index 000000000..8a007adcd Binary files /dev/null and b/docs/images/platform/pki/azure-adcs/azure-adcs-subscriber-issue-new-certificate.png differ diff --git a/docs/images/platform/pki/azure-adcs/azure-adcs-subscribers-created.png b/docs/images/platform/pki/azure-adcs/azure-adcs-subscribers-created.png new file mode 100644 index 000000000..61bb140b8 Binary files /dev/null and b/docs/images/platform/pki/azure-adcs/azure-adcs-subscribers-created.png differ diff --git a/docs/images/platform/pki/azure-adcs/azure-adcs-subscribers-form.png b/docs/images/platform/pki/azure-adcs/azure-adcs-subscribers-form.png new file mode 100644 index 000000000..7e80c3763 Binary files /dev/null and b/docs/images/platform/pki/azure-adcs/azure-adcs-subscribers-form.png differ diff --git a/docs/images/platform/pki/azure-adcs/azure-adcs-subscribers-page.png b/docs/images/platform/pki/azure-adcs/azure-adcs-subscribers-page.png new file mode 100644 index 000000000..ce7cad5c8 Binary files /dev/null and b/docs/images/platform/pki/azure-adcs/azure-adcs-subscribers-page.png differ diff --git a/docs/images/platform/pki/ca/external-ca/create-external-ca-form.png b/docs/images/platform/pki/ca/external-ca/create-external-ca-form.png index ef572bfa7..bc32c23f6 100644 Binary files a/docs/images/platform/pki/ca/external-ca/create-external-ca-form.png and b/docs/images/platform/pki/ca/external-ca/create-external-ca-form.png differ diff --git a/docs/images/secret-syncs/checkly/checkly-sync-destination.png b/docs/images/secret-syncs/checkly/checkly-sync-destination.png index bb3df8d94..522418a3e 100644 Binary files a/docs/images/secret-syncs/checkly/checkly-sync-destination.png and b/docs/images/secret-syncs/checkly/checkly-sync-destination.png differ diff --git a/docs/integrations/app-connections.mdx b/docs/integrations/app-connections.mdx new file mode 100644 index 000000000..9f4b5b80f --- /dev/null +++ b/docs/integrations/app-connections.mdx @@ -0,0 +1,8 @@ +--- +sidebarTitle: "Explore Options" +description: "Browse and search through all available app connections for Infisical." +--- + +import { AppConnectionsBrowser } from "/snippets/AppConnectionsBrowser.jsx"; + + \ No newline at end of file diff --git a/docs/integrations/app-connections/azure-adcs.mdx b/docs/integrations/app-connections/azure-adcs.mdx new file mode 100644 index 000000000..7bc174135 --- /dev/null +++ b/docs/integrations/app-connections/azure-adcs.mdx @@ -0,0 +1,70 @@ +--- +title: "Azure ADCS Connection" +description: "Learn how to configure an Azure ADCS Connection for Infisical certificate management." +--- + +Connect Infisical to Microsoft Active Directory Certificate Services (ADCS) for automated certificate issuance and management. + +## Prerequisites + +- Microsoft Active Directory Certificate Services (ADCS) server running and accessible +- Domain administrator account with certificate management permissions +- Network connectivity from Infisical to the ADCS server +- ADCS web enrollment enabled on your server + +## Connection Setup + + + + Navigate to the **App Connections** tab on the **Organization Settings** page. + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + + Select the **Azure ADCS Connection** option from the connection options modal. + ![Select Azure ADCS Connection](/images/app-connections/azure-adcs/azure-adcs-select-connection.png) + + + **Configuration Tab:** + - **Name**: Friendly name for this ADCS connection (e.g., "Production ADCS") + - **ADCS URL**: Your ADCS HTTPS URL (e.g., `https://adcs.yourdomain.com/certsrv`) - only HTTPS is supported + - **Username**: Domain administrator username (format: `DOMAIN\username` or `username@domain.com`) + - **Password**: Password for the domain administrator account + + **SSL Tab:** + - **SSL Certificate**: Optional PEM certificate for custom CA certificates or self-signed certificates + - **Reject Unauthorized**: Whether to reject connections with invalid SSL certificates (it's recommended to keep it enabled) + + + **Getting the SSL Certificate**: If your ADCS server uses a self-signed certificate or a certificate from a custom CA, you can obtain the certificate using OpenSSL or PowerShell: + + **Using OpenSSL:** + ```bash + openssl s_client -connect your-adcs-server:443 -showcerts + ``` + + **Using PowerShell (from inside your ADCS Server):** + ```powershell + Import-Module WebAdministration -ErrorAction SilentlyContinue + $binding = Get-WebBinding -Protocol https -Port 443 + if ($binding -and $binding.certificateHash) { + $cert = Get-ChildItem -Path "Cert:\LocalMachine\My\$($binding.certificateHash)" + if ($cert) { + $certBase64 = [System.Convert]::ToBase64String($cert.RawData) + $certPem = "-----BEGIN CERTIFICATE-----`n" + ($certBase64 -replace '(.{64})', '$1`n') + "`n-----END CERTIFICATE-----" + Write-Output $certPem + } + } + ``` + + Copy the certificate content (including `-----BEGIN CERTIFICATE-----` and `-----END CERTIFICATE-----` lines) and paste it into the SSL Certificate field. + + + And click **Connect to ADCS** to establish the connection. + ![Connect to ADCS](/images/app-connections/azure-adcs/azure-adcs-app-connection-form.png) + ![Connect to ADCS - SSL](/images/app-connections/azure-adcs/azure-adcs-app-connection-form-ssl.png) + + + Your **Azure ADCS Connection** is now available for use in your Infisical projects. ![Azure ADCS Connection Created](/images/app-connections/azure-adcs/azure-adcs-app-connection-created.png) + + + diff --git a/docs/integrations/app-connections/hashicorp-vault.mdx b/docs/integrations/app-connections/hashicorp-vault.mdx index 6e2ff68bf..c49b53ab8 100644 --- a/docs/integrations/app-connections/hashicorp-vault.mdx +++ b/docs/integrations/app-connections/hashicorp-vault.mdx @@ -149,6 +149,7 @@ Infisical supports two methods for connecting to Hashicorp Vault. - **Name**: The name of the connection being created. Must be slug-friendly. - **Description**: An optional description to provide details about this connection. + - **Gateway (optional):** The gateway connected to your private network. All requests made to your Vault instance will be made through the configured gateway. - **Instance URL**: The URL of your Hashicorp Vault instance. - **Namespace (optional)**: The namespace within your vault. Self-hosted and enterprise clusters may not use namespaces. - **Role ID**: The Role ID generated in the steps above. @@ -157,6 +158,7 @@ Infisical supports two methods for connecting to Hashicorp Vault. - **Name**: The name of the connection being created. Must be slug-friendly. - **Description**: An optional description to provide details about this connection. + - **Gateway (optional):** The gateway connected to your private network. All requests made to your Vault instance will be made through the configured gateway. - **Instance URL**: The URL of your Hashicorp Vault instance. - **Namespace (optional)**: The namespace within your vault. Self-hosted and enterprise clusters may not use namespaces. - **Access Token**: The Access Token generated in the steps above. diff --git a/docs/integrations/app-connections/overview.mdx b/docs/integrations/app-connections/overview.mdx index 92e6ab9d2..e698c4302 100644 --- a/docs/integrations/app-connections/overview.mdx +++ b/docs/integrations/app-connections/overview.mdx @@ -79,4 +79,4 @@ in the UI or by passing the associated `connectionId` when generating resources ## Platform Managed Credentials Some App Connections support the ability to have their credentials managed by Infisical. By enabling this option, -Infisical will modify the credentials to prevent external use of the configured access entity. \ No newline at end of file +Infisical will modify the credentials to prevent external use of the configured access entity. diff --git a/docs/integrations/dynamic-secrets.mdx b/docs/integrations/dynamic-secrets.mdx new file mode 100644 index 000000000..84ff43ab5 --- /dev/null +++ b/docs/integrations/dynamic-secrets.mdx @@ -0,0 +1,9 @@ +--- +title: "Dynamic Secrets" +sidebarTitle: "Explore Options" +description: "Browse and search through all available dynamic secrets for Infisical." +--- + +import { DynamicSecretsBrowser } from "/snippets/DynamicSecretsBrowser.jsx"; + + diff --git a/docs/integrations/framework-integrations.mdx b/docs/integrations/framework-integrations.mdx new file mode 100644 index 000000000..dfe78119a --- /dev/null +++ b/docs/integrations/framework-integrations.mdx @@ -0,0 +1,9 @@ +--- +title: "Framework Integrations" +sidebarTitle: "Explore Options" +description: "Browse and search through all available framework integrations for Infisical." +--- + +import { FrameworkIntegrationsBrowser } from "/snippets/FrameworkIntegrationsBrowser.jsx"; + + \ No newline at end of file diff --git a/docs/integrations/frameworks/pulumi.mdx b/docs/integrations/frameworks/pulumi.mdx index 11a8e0cb7..c2dbdb103 100644 --- a/docs/integrations/frameworks/pulumi.mdx +++ b/docs/integrations/frameworks/pulumi.mdx @@ -7,7 +7,7 @@ Infisical can be integrated with Pulumi by leveraging Pulumi’s [Terraform Brid which allows Terraform providers to be used seamlessly within Pulumi projects. This enables infrastructure and platform teams to manage Infisical secrets and resources using Pulumi’s familiar programming languages (including TypeScript, Python, Go, and C#), without any change to existing workflows. -The Terraform Bridge wraps the [Infisical Terraform provider](/integrations/frameworks/terraform) and exposes its resources (such as `infisical_secret`, `infisical_project`, and `infisical_service_token`) +The Terraform Bridge wraps the [Infisical Terraform provider](https://registry.terraform.io/providers/Infisical/infisical/latest/docs) and exposes its resources (such as `infisical_secret`, `infisical_project`, and `infisical_service_token`) in a Pulumi-compatible interface. This makes it easy to integrate secret management directly into Pulumi-based IaC pipelines, ensuring secrets stay in sync with the rest of your cloud infrastructure. Authentication is handled through the same methods as Terraform: using environment variables such as `INFISICAL_TOKEN` and `INFISICAL_SITE_URL`. diff --git a/docs/integrations/frameworks/terraform.mdx b/docs/integrations/frameworks/terraform.mdx index 898a77b00..313132d0b 100644 --- a/docs/integrations/frameworks/terraform.mdx +++ b/docs/integrations/frameworks/terraform.mdx @@ -1,8 +1,9 @@ --- title: "Terraform" description: "Learn how to fetch secrets from Infisical with Terraform using both traditional data sources and ephemeral resources" +url: "https://registry.terraform.io/providers/Infisical/infisical/latest/docs" --- - +{/* This guide demonstrates how to use Infisical to manage secrets in your Terraform infrastructure code, supporting both traditional data sources and ephemeral resources for enhanced security. It uses: - Infisical (you can use [Infisical Cloud](https://app.infisical.com) or a [self-hosted instance of Infisical](https://infisical.com/docs/self-hosting/overview)) to store your secrets @@ -234,4 +235,4 @@ For detailed instructions on setting up OIDC authentication with GitHub Actions, See also: - [Machine Identity setup guide](/documentation/platform/identities/machine-identities) - [Terraform Provider Registry](https://registry.terraform.io/providers/Infisical/infisical/latest/docs) -- [GitOps Best Practices](https://www.infisical.com/blog/gitops-best-practices) +- [GitOps Best Practices](https://www.infisical.com/blog/gitops-best-practices) */} diff --git a/docs/integrations/machine-authentication.mdx b/docs/integrations/machine-authentication.mdx new file mode 100644 index 000000000..da4ba1042 --- /dev/null +++ b/docs/integrations/machine-authentication.mdx @@ -0,0 +1,9 @@ +--- +title: "Machine Authentication" +sidebarTitle: "Explore Options" +description: "Browse and search through all available machine authentication methods for Infisical." +--- + +import { MachineAuthenticationBrowser } from "/snippets/MachineAuthenticationBrowser.jsx"; + + \ No newline at end of file diff --git a/docs/integrations/overview.mdx b/docs/integrations/overview.mdx index 5dc06daed..ed7d47b30 100644 --- a/docs/integrations/overview.mdx +++ b/docs/integrations/overview.mdx @@ -7,55 +7,55 @@ Integrations allow environment variables to be synced from Infisical into your l Missing an integration? [Throw in a request](https://github.com/Infisical/infisical/issues). -| Integration | Type | Status | -| -------------------------------------------------------------- | ---------------------- | ----------- | -| [Docker](/integrations/platforms/docker) | Platform | Available | -| [Docker-Compose](/integrations/platforms/docker-compose) | Platform | Available | -| [Kubernetes](/integrations/platforms/kubernetes) | Platform | Available | -| [Terraform](/integrations/frameworks/terraform) | Infrastructure as code | Available | -| [PM2](/integrations/platforms/pm2) | Platform | Available | -| [Heroku](/integrations/cloud/heroku) | Cloud | Available | -| [Vercel](/integrations/cloud/vercel) | Cloud | Available | -| [Netlify](/integrations/cloud/netlify) | Cloud | Available | -| [Render](/integrations/cloud/render) | Cloud | Available | -| [Laravel Forge](/integrations/cloud/laravel-forge) | Cloud | Available | -| [Railway](/integrations/cloud/railway) | Cloud | Available | -| [Terraform Cloud](/integrations/cloud/terraform-cloud) | Cloud | Available | -| [TeamCity](/integrations/cloud/teamcity) | Cloud | Available | -| [Fly.io](/integrations/cloud/flyio) | Cloud | Available | -| [Supabase](/integrations/cloud/supabase) | Cloud | Available | -| [Northflank](/integrations/cloud/northflank) | Cloud | Available | -| [Cloudflare Pages](/integrations/cloud/cloudflare-pages) | Cloud | Available | -| [Cloudflare Workers](/integrations/cloud/cloudflare-workers) | Cloud | Available | -| [Checkly](/integrations/cloud/checkly) | Cloud | Available | -| [Qovery](/integrations/cloud/qovery) | Cloud | Available | -| [HashiCorp Vault](/integrations/cloud/hashicorp-vault) | Cloud | Available | -| [AWS Parameter Store](/integrations/cloud/aws-parameter-store) | Cloud | Available | -| [AWS Secrets Manager](/integrations/cloud/aws-secret-manager) | Cloud | Available | -| [Azure Key Vault](/integrations/cloud/azure-key-vault) | Cloud | Available | -| [GCP Secret Manager](/integrations/cloud/gcp-secret-manager) | Cloud | Available | -| [Windmill](/integrations/cloud/windmill) | Cloud | Available | -| [Bitbucket](/integrations/cicd/bitbucket) | CI/CD | Available | -| [Codefresh](/integrations/cicd/codefresh) | CI/CD | Available | -| [GitHub Actions](/integrations/cicd/githubactions) | CI/CD | Available | -| [GitLab](/integrations/cicd/gitlab) | CI/CD | Available | -| [CircleCI](/integrations/cicd/circleci) | CI/CD | Available | -| [Travis CI](/integrations/cicd/travisci) | CI/CD | Available | -| [Rundeck](/integrations/cicd/rundeck) | CI/CD | Available | -| [Octopus Deploy](/integrations/cicd/octopus-deploy) | CI/CD | Available | -| [React](/integrations/frameworks/react) | Framework | Available | -| [Vue](/integrations/frameworks/vue) | Framework | Available | -| [Express](/integrations/frameworks/express) | Framework | Available | -| [Next.js](/integrations/frameworks/nextjs) | Framework | Available | -| [NestJS](/integrations/frameworks/nestjs) | Framework | Available | -| [SvelteKit](/integrations/frameworks/sveltekit) | Framework | Available | -| [Nuxt](/integrations/frameworks/nuxt) | Framework | Available | -| [Gatsby](/integrations/frameworks/gatsby) | Framework | Available | -| [Remix](/integrations/frameworks/remix) | Framework | Available | -| [Vite](/integrations/frameworks/vite) | Framework | Available | -| [Fiber](/integrations/frameworks/fiber) | Framework | Available | -| [Django](/integrations/frameworks/django) | Framework | Available | -| [Flask](/integrations/frameworks/flask) | Framework | Available | -| [Laravel](/integrations/frameworks/laravel) | Framework | Available | -| [Ruby on Rails](/integrations/frameworks/rails) | Framework | Available | -| Jenkins | CI/CD | Available | +| Integration | Type | Status | +| ------------------------------------------------------------------------------------- | ---------------------- | ---------------------------------- | +| [Docker](/integrations/platforms/docker) | Platform | Available | +| [Docker-Compose](/integrations/platforms/docker-compose) | Platform | Available | +| [Kubernetes](/integrations/platforms/kubernetes) | Platform | Available | +| [Terraform](https://registry.terraform.io/providers/Infisical/infisical/latest/docs) | Infrastructure as code | Available | +| [PM2](/integrations/platforms/pm2) | Platform | Available | +| [Heroku](/integrations/cloud/heroku) | Cloud | Available | +| [Vercel](/integrations/cloud/vercel) | Cloud | Available | +| [Netlify](/integrations/cloud/netlify) | Cloud | Available | +| [Render](/integrations/cloud/render) | Cloud | Available | +| [Laravel Forge](/integrations/cloud/laravel-forge) | Cloud | Available | +| [Railway](/integrations/cloud/railway) | Cloud | Available | +| [Terraform Cloud](/integrations/cloud/terraform-cloud) | Cloud | Available | +| [TeamCity](/integrations/cloud/teamcity) | Cloud | Available | +| [Fly.io](/integrations/cloud/flyio) | Cloud | Available | +| [Supabase](/integrations/cloud/supabase) | Cloud | Available | +| [Northflank](/integrations/cloud/northflank) | Cloud | Available | +| [Cloudflare Pages](/integrations/cloud/cloudflare-pages) | Cloud | Available | +| [Cloudflare Workers](/integrations/cloud/cloudflare-workers) | Cloud | Available | +| [Checkly](/integrations/cloud/checkly) | Cloud | Available | +| [Qovery](/integrations/cloud/qovery) | Cloud | Available | +| [HashiCorp Vault](/integrations/cloud/hashicorp-vault) | Cloud | Available | +| [AWS Parameter Store](/integrations/cloud/aws-parameter-store) | Cloud | Available | +| [AWS Secrets Manager](/integrations/cloud/aws-secret-manager) | Cloud | Available | +| [Azure Key Vault](/integrations/cloud/azure-key-vault) | Cloud | Available | +| [GCP Secret Manager](/integrations/cloud/gcp-secret-manager) | Cloud | Available | +| [Windmill](/integrations/cloud/windmill) | Cloud | Available | +| [Bitbucket](/integrations/cicd/bitbucket) | CI/CD | Available | +| [Codefresh](/integrations/cicd/codefresh) | CI/CD | Available | +| [GitHub Actions](/integrations/cicd/githubactions) | CI/CD | Available | +| [GitLab](/integrations/cicd/gitlab) | CI/CD | Available | +| [CircleCI](/integrations/cicd/circleci) | CI/CD | Available | +| [Travis CI](/integrations/cicd/travisci) | CI/CD | Available | +| [Rundeck](/integrations/cicd/rundeck) | CI/CD | Available | +| [Octopus Deploy](/integrations/cicd/octopus-deploy) | CI/CD | Available | +| [React](/integrations/frameworks/react) | Framework | Available | +| [Vue](/integrations/frameworks/vue) | Framework | Available | +| [Express](/integrations/frameworks/express) | Framework | Available | +| [Next.js](/integrations/frameworks/nextjs) | Framework | Available | +| [NestJS](/integrations/frameworks/nestjs) | Framework | Available | +| [SvelteKit](/integrations/frameworks/sveltekit) | Framework | Available | +| [Nuxt](/integrations/frameworks/nuxt) | Framework | Available | +| [Gatsby](/integrations/frameworks/gatsby) | Framework | Available | +| [Remix](/integrations/frameworks/remix) | Framework | Available | +| [Vite](/integrations/frameworks/vite) | Framework | Available | +| [Fiber](/integrations/frameworks/fiber) | Framework | Available | +| [Django](/integrations/frameworks/django) | Framework | Available | +| [Flask](/integrations/frameworks/flask) | Framework | Available | +| [Laravel](/integrations/frameworks/laravel) | Framework | Available | +| [Ruby on Rails](/integrations/frameworks/rails) | Framework | Available | +| Jenkins | CI/CD | Available | diff --git a/docs/integrations/platforms/ansible.mdx b/docs/integrations/platforms/ansible.mdx index 321dbec6e..85f63079f 100644 --- a/docs/integrations/platforms/ansible.mdx +++ b/docs/integrations/platforms/ansible.mdx @@ -27,22 +27,73 @@ $ ansible-galaxy collection install infisical.vault The python module dependencies are not installed by ansible-galaxy. They can be manually installed using pip: ```bash -$ pip install infisical-python +$ pip install infisicalsdk ``` ## Using this collection You can either call modules by their Fully Qualified Collection Name (FQCN), such as `infisical.vault.read_secrets`, or you can call modules by their short name if you list the `infisical.vault` collection in the playbook's collections keyword: +### Authentication -```bash +The Infisical Ansible Collection supports [Universal Auth](/documentation/platform/identities/universal-auth) and [OIDC](/documentation/platform/identities/oidc-auth/general) for authenticating against Infisical. + + + + + Using Universal Auth for authentication is the most straight-forward way to get started with using the Ansible collection. + + To use Universal Auth, you need to provide the Client ID and Client Secret of your Infisical Machine Identity. + + ```yaml + lookup('infisical.vault.read_secrets', auth_method="universal-auth", universal_auth_client_id='', universal_auth_client_secret='' ...rest) + ``` + + You can also provide the `auth_method`, `universal_auth_client_id`, and `universal_auth_client_secret` parameters through environment variables: + + | Parameter Name | Environment Variable Name | + | ------------------------------ | ---------------------------------------- | + | `auth_method` | `INFISICAL_AUTH_METHOD` | + | `universal_auth_client_id` | `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID` | + | `universal_auth_client_secret` | `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET` | + + + + To use OIDC Auth, you'll need to provide the ID of your machine identity, and the OIDC JWT to be used for authentication. + + + Please note that in order to use OIDC Auth, you must have `1.0.10` or newer of the `infisicalsdk` package installed. + + + ```yaml + lookup('infisical.vault.read_secrets', auth_method="oidc-auth", identity_id='', jwt='' ...rest) + ``` + You can also provide the `auth_method`, `identity_id`, and `jwt` parameters through environment variables: + + | Parameter Name | Environment Variable Name | + | --------------- | ------------------------- | + | auth_method | `INFISICAL_AUTH_METHOD` | + | identity_id | `INFISICAL_IDENTITY_ID` | + | jwt | `INFISICAL_JWT` | + + + + +### Examples + +```yaml --- vars: read_all_secrets_within_scope: "{{ lookup('infisical.vault.read_secrets', universal_auth_client_id='<>', universal_auth_client_secret='<>', project_id='<>', path='/', env_slug='dev', url='https://spotify.infisical.com') }}" # [{ "key": "HOST", "value": "google.com" }, { "key": "SMTP", "value": "gmail.smtp.edu" }] + + read_all_secrets_as_dict: "{{ lookup('infisical.vault.read_secrets', as_dict=True, universal_auth_client_id='<>', universal_auth_client_secret='<>', project_id='<>', path='/', env_slug='dev', url='https://spotify.infisical.com') }}" + # { "SECRET_KEY_1": "secret-value-1", "SECRET_KEY_2": "secret-value-2" } -> Can be accessed as secrets.SECRET_KEY_1 + + read_secret_by_name_within_scope: "{{ lookup('infisical.vault.read_secrets', universal_auth_client_id='<>', universal_auth_client_secret='<>', project_id='<>', path='/', env_slug='dev', secret_name='HOST', url='https://spotify.infisical.com') }}" - # [{ "key": "HOST", "value": "google.com" }] + # { "key": "HOST", "value": "google.com" } ``` diff --git a/docs/integrations/platforms/docker.mdx b/docs/integrations/platforms/docker.mdx index 8e429461a..7b46e8495 100644 --- a/docs/integrations/platforms/docker.mdx +++ b/docs/integrations/platforms/docker.mdx @@ -6,32 +6,10 @@ description: "Learn how to use Infisical to inject environment variables into a This approach allows you to inject secrets from Infisical directly into your application. This is achieved by installing the Infisical CLI into your docker image and modifying your start command to execute with Infisical. -## Add the Infisical CLI to your Dockerfile +## Install the Infisical CLI to your Dockerfile - - - ```dockerfile - RUN apk add --no-cache bash curl && curl -1sLf \ - 'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.alpine.sh' | bash \ - && apk add infisical - ``` +To install the CLI, follow the instructions for your chosen distribution [here](/cli/overview). - - - ```dockerfile - RUN curl -1sLf \ - 'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.rpm.sh' | sh \ - && yum install -y infisical - ``` - - - ```dockerfile - RUN apt-get update && apt-get install -y bash curl && curl -1sLf \ - 'https://dl.cloudsmith.io/public/infisical/infisical-cli/setup.deb.sh' | bash \ - && apt-get update && apt-get install -y infisical - ``` - - #### We recommend you to set the version of the CLI to a specific version. This will help keep your CLI version consistent across reinstalls. [View versions](https://cloudsmith.io/~infisical/repos/infisical-cli/packages/) diff --git a/docs/integrations/secret-rotations.mdx b/docs/integrations/secret-rotations.mdx new file mode 100644 index 000000000..00d1cbb6a --- /dev/null +++ b/docs/integrations/secret-rotations.mdx @@ -0,0 +1,8 @@ +--- +sidebarTitle: "Explore Options" +description: "Browse and search through all available secret rotations for Infisical." +--- + +import { RotationsBrowser } from "/snippets/RotationsBrowser.jsx"; + + \ No newline at end of file diff --git a/docs/integrations/secret-syncs.mdx b/docs/integrations/secret-syncs.mdx new file mode 100644 index 000000000..6d71517f1 --- /dev/null +++ b/docs/integrations/secret-syncs.mdx @@ -0,0 +1,8 @@ +--- +sidebarTitle: "Explore Options" +description: "Browse and search through all available secret syncs for Infisical." +--- + +import { SecretSyncsBrowser } from "/snippets/SecretSyncsBrowser.jsx"; + + \ No newline at end of file diff --git a/docs/integrations/secret-syncs/checkly.mdx b/docs/integrations/secret-syncs/checkly.mdx index 4599fe634..07382fb35 100644 --- a/docs/integrations/secret-syncs/checkly.mdx +++ b/docs/integrations/secret-syncs/checkly.mdx @@ -37,6 +37,7 @@ description: "Learn how to configure a Checkly Sync for Infisical." - **Checkly Connection**: The Checkly Connection to authenticate with. - **Account**: The Checkly account to sync secrets to. + - **Group**: The Checkly check group to sync secrets to (Optional). Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. diff --git a/docs/integrations/secret-syncs/render.mdx b/docs/integrations/secret-syncs/render.mdx index 341d84ad5..c81048668 100644 --- a/docs/integrations/secret-syncs/render.mdx +++ b/docs/integrations/secret-syncs/render.mdx @@ -30,8 +30,9 @@ description: "Learn how to configure a Render Sync for Infisical." ![Configure Destination](/images/secret-syncs/render/render-sync-destination.png) - **Render Connection**: The Render Connection to authenticate with. - - **Scope**: Select **Service**. - - **Service**: Choose the Render service you want to sync secrets to. + - **Scope**: Select **Service** or **Environment Group**. + - **Service**: Choose the Render service you want to sync secrets to. + - **Environment Group**: Choose the Render environment group you want to sync secrets to. 5. Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. ![Configure Options](/images/secret-syncs/render/render-sync-options.png) diff --git a/docs/integrations/user-authentication.mdx b/docs/integrations/user-authentication.mdx new file mode 100644 index 000000000..2b33fd7ba --- /dev/null +++ b/docs/integrations/user-authentication.mdx @@ -0,0 +1,9 @@ +--- +title: "User Authentication" +sidebarTitle: "Explore Options" +description: "Browse and search through all available user authentication methods for Infisical." +--- + +import { UserAuthenticationBrowser } from "/snippets/UserAuthenticationBrowser.jsx"; + + \ No newline at end of file diff --git a/docs/sdks/languages/cpp.mdx b/docs/sdks/languages/cpp.mdx index 2e22def9c..27f1aad3e 100644 --- a/docs/sdks/languages/cpp.mdx +++ b/docs/sdks/languages/cpp.mdx @@ -1,6 +1,315 @@ --- title: "Infisical C++ SDK" sidebarTitle: "C++" -url: "https://github.com/Infisical/infisical-cpp-sdk/?tab=readme-ov-file#infisical-c-sdk" icon: "/images/sdks/languages/cpp.svg" ---- \ No newline at end of file +--- + +If you're working with C++, the official Infisical C++ SDK package is the easiest way to fetch and work with secrets for your application. + +## Compatible with C++ 17 and later +The Infisical C++ SDK is compatible with C++ 17 capable compilers. This implies GCC 8 or newer, and clang 3.8 or newer. Earlier versions of C++ are unsupported. + +## Dependencies +- `cURL`: Used internally for crafting HTTP requests. + +## CMake Installation + +```bash +cmake_minimum_required(VERSION 3.14) +project(InfisicalTest) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_PREFIX_PATH ${CMAKE_BINARY_DIR}) + +find_package(OpenSSL REQUIRED) + + +include(FetchContent) + + +FetchContent_Declare( + infisical + GIT_REPOSITORY https://github.com/Infisical/infisical-cpp-sdk.git + GIT_TAG 1.0.0 # Replace with the desired version +) + +FetchContent_MakeAvailable(infisical) +FetchContent_GetProperties(infisical) + + +# Example usage. This will differ based on your project structure. +add_executable(my_app src/main.cpp) +target_link_libraries(my_app PRIVATE infisical OpenSSL::SSL OpenSSL::Crypto) +target_include_directories(my_app PRIVATE ${infisical_SOURCE_DIR}/include) +``` + +## Manual Installation +If you're unable to use the recommended CMake installation approach, you can choose to manually build the library and use it in your project. + +```bash +mkdir build +cd build +cmake .. +make +``` + +## Quick-Start Example + +Below you'll find an example that uses the Infisical SDK to fetch a secret with the key `API_KEY` using [Machine Identity Universal Auth](https://infisical.com/docs/documentation/platform/identities/universal-auth) + +More examples can be found in the [/examples](https://github.com/Infisical/infisical-cpp-sdk/tree/main/examples) folder. + +```cpp +#include +#include + +int main() { + + try { + Infisical::InfisicalClient client( + Infisical::ConfigBuilder() + .withHostUrl("https://app.infisical.com") // Optionally change this to your custom Infisical instance URL. + .withAuthentication( + Infisical::AuthenticationBuilder() + .withUniversalAuth("", "") + .build()) + .build()); + + const auto getSecretOptions = Infisical::Input::GetSecretOptionsBuilder() + .withEnvironment("") // dev, staging, prod, etc + .withProjectId("") + .withSecretKey("API_KEY") + .build(); + + const auto apiKeySecret = client.secrets().getSecret(getSecretOptions); + + printf("Secret retrieved, [key=%s] [value=%s]\n", apiKeySecret.getSecretKey().c_str(), apiKeySecret.getSecretValue().c_str()); + } catch (const Infisical::InfisicalError &e) { + std::cerr << "Error: " << e.what() << std::endl; + return 1; + } + + return 0; +} +``` + +## JSON Serialization +The SDK uses [nlohmann/json](https://github.com/nlohmann/json) internally to serialize/deserialize JSON data. This SDK makes no assumptions about which JSON library you use in your project, and you aren't constrained to `nlohmann/json` in any way. Data returned by the SDK is returned as a class, which exposes Getter methods for getting fields such as the secret value or secret key. + + +## Documentation +The Infisical C++ SDK follows a builder pattern for all types of input. Below is a detailed documentation of our currently support methods. + +Everything related to the Infisical SDK lives inside the `Infisical` namespace. + +### InfisicalClient Class +`InfisicalClient(Config &config)` + +```cpp + Infisical::InfisicalClient client( + Infisical::ConfigBuilder() + .withHostUrl("https://app.infisical.com") + .withAuthentication( + Infisical::AuthenticationBuilder() + .withUniversalAuth(clientId, clientSecret) + .build()) + .build()); +``` + +Config is created through the `ConfigBuilder` class. See below for more details + +### Config Class + +`Config` defines the configuration of the Infisical Client itself, such as authentication. + +```cpp +Infisical::Config config = Infisical::ConfigBuilder() + .withHostUrl("https://app.infisical.com") + .withAuthentication( + Infisical::AuthenticationBuilder() + .withUniversalAuth(clientId, clientSecret) + .build()) + .build(); + +Infisical::InfisicalClient client(config); +``` + +- `withHostUrl(string)` _(optional)_: Specify a custom Infisical host URL, pointing to your Infisical instance. Defaults to `https://app.infisical.com` +- `withAuthentication(Infisical::Authentication)`: Configure the authentication that will be used by the SDK. See [Authentication Class](#authentication-class) for more details. +- `build()`: Returns the `Config` object with the options you configured. + +### Authentication Class +```cpp +Infisical::Authentication auth = Infisical::AuthenticationBuilder() + .withUniversalAuth(clientId, clientSecret) + .build(); + +Infisical::Config config = Infisical::ConfigBuilder() + .withAuthentication(std::move(auth)) // Or use inline declaration + .build(); +``` + +- `withUniversalAuth(string, string)`: Specify the Universal Auth Client ID and Client Secret that will be used for authentication. +- `build()`: Returns the `Authentication` object with the options you specified. + +### TSecret Class +The `TSecret` class is the class that's returned by all secret methods (get/list/delete/update/create). It can come in the form of a `std::vector` or a single instance. + +**Available getter methods:** +- `getId(): std::string`: Returns the ID of the secret. +- `getWorkspace(): std::string`: Returns the project ID of the secret. +- `getEnvironment(): std::string`: Returns the environment slug of the secret. +- `getVersion(): unsigned int`: Gets the version of the secret. By default this will always be the latest version unless specified otherwise with `withVersion()` +- `getType(): std::string`: Returns the type of the secret. Can only be `shared` or `personal`. Shared secrets are available to everyone with access to the secret. Personal secrets are personal overwrites of the secret, mainly intended for local development purposes. +- `getSecretKey(): std::string`: Returns the secret key. +- `getSecretValue(): std::string` Returns the secret value. +- `getRotationId(): std::string`: If the secret is a rotation secret, this will return the rotation ID of the secret. If it's a regular secret, this will return an empty string. +- `getSecretPath(): std::string`: Returns the secret path of the secret. +- `getSkipMultilineEncoding(): bool`: Returns whether or not skip multiline encoding is enabled for the secret or not. +`getIsRotatedSecret(): bool`: Returns wether or not the secret is a rotated secret. If `true`, then `getRotationId()` returns the ID of the rotation. + + + +### Secrets + +#### Create Secret +```cpp +const auto createSecretOptions = Infisical::Input::CreateSecretOptionsBuilder() + .withEnvironment("") + .withProjectId("") + .withSecretKey("SECRET_KEY_TO_CREATE") + .withSecretValue("VALUE_TO_CREATE") + .withSecretComment("Secret comment to attach") // Optional + .withSecretPath("/path/where/to/create/secret") // Optional, defaults to / + .withTagIds({"tag-id-1", "tag-id-2"}) // Optional + .build(); + +const auto secret = client.secrets().createSecret(createSecretOptions); +``` + +**Parameters**: +- `withEnvironment(string)`: Specify the slug of the environment to create the secret in. +- `withProjectId(string)`: Specify the ID of the project to create the secret in. +- `withSecretPath(string)`: Specify the secret path to create the secret in. Defaults to `/` +- `withSecretKey(string)`: The secret key to be created. +- `withSecretValue(string)`: The value of the secret to create. +- `withSecretComment(string)` _(optional)_: Optionally add a comment to the secret. +- `withTagIds(std::vector>)` _(optional)_: A list of ID's of tags to attach to the secret. +- `build()`: Returns the `CreateSecretOptions` class that can be passed into the `createSecret()` method. + +**Returns**: +- Returns the created secret as a `TSecret` class. Read more in the [TSecret Class](#tsecret-class) documentation. + +#### Update Secret + +```cpp + const auto updateSecretOptions = Infisical::Input::UpdateSecretOptionsBuilder() + .withEnvironment("") + .withProjectId("") + .withSecretKey("") + .withNewSecretKey("") // Optional + .withSecretValue("") // Optional + .withSecretComment("Updated comment") // Optional + .withSecretReminderNote("Updated reminder note") // Optional + .withSecretReminderRepeatDays(1) // Optional + .withType("shared") // Optional + .withTagIds({"tag-id-3", "tag-id-4"}) // Optional + .build(); + +const auto updatedSecret = client.secrets().updateSecret(updateSecretOptions); +``` + +**Parameters**: +- `withEnvironment(string)`: Specify the slug of the environment where the secret lives in. +- `withProjectId(string)`: Specify the ID of the project where the secret to update lives in. +- `withSecretPath(string)`: Specify the secret path of the secret to update. Defaults to `/`. +- `withType("shared" | "personal")`: _(optional)_: The type of secret to update. Defaults to `shared`. +- `withSecretKey(string)`: The key of the secret you wish to update. +- `withNewSecretKey(string)` _(optional)_: The new key of the secret you wish to update. +- `withSecretValue(string)` _(optional)_: The new value of the secret. +- `withSecretReminderNote(string)` _(optional)_: Update the secret reminder note attached to the secret. +- `withSecretReminderRepeatDays(unsigned int)` _(optional)_: Update the secret reminder repeat days attached to the secret. +- `withTagIds(std::vector>)` _(optional)_: A list of ID's of tags to attach to the secret. +- `build()`: Returns the `UpdateSecretOptions` class that can be passed into the `updateSecret()` method. + +**Returns**: +- Returns the updated secret as a `TSecret` class. Read more in the [TSecret Class](#tsecret-class) documentation. + +#### Get Secret +```cpp +const auto getSecretOptions = Infisical::Input::GetSecretOptionsBuilder() + .withEnvironment("") + .withProjectId("") + .withSecretKey("") + .withType("shared") + .withVersion(2) + .withExpandSecretReferences(true) + .build(); + +const auto secret = client.secrets().getSecret(getSecretOptions); +``` +**Parameters**: +- `withEnvironment(string)`: Specify the slug of the environment where the secret lives in. +- `withProjectId(string)`: Specify the ID of the project where the secret lives in. +- `withSecretPath(string)`: Specify the secret path of the secret to get. Defaults to `/` +- `withType("shared" | "personal")`: _(optional)_: The type of secret to get. Defaults to `shared`. +- `withSecretKey(string)`: The key of the secret to get. +- `withExpandSecretReferences(bool)` _(optional)_: Whether or not to expand secret references automatically. Defaults to `true`. +- `withVersion(unsigned int)` _(optional)_: Optionally fetch a specific version of the secret. If not defined, the latest version of the secret is returned. +- `build()`: Returns the `GetSecretOptions` class that can be passed into the `getSecret()` method. + +**Returns**: +- Returns the secret as a `TSecret` class. Read more in the [TSecret Class](#tsecret-class) documentation. + +#### Delete Secret + +```cpp +const auto deleteSecretOptions = Infisical::Input::DeleteSecretOptionsBuilder() + .withEnvironment("") + .withProjectId("") + .withSecretKey("") + .withType("shared") + .withSecretPath("") + .build(); + +const auto deletedSecret = client.secrets().deleteSecret(deleteSecretOptions); +``` + +**Parameters**: +- `withEnvironment(string)`: Specify the slug of the environment where the secret to delete lives in. +- `withProjectId(string)`: Specify the ID of the project where the secret to delete lives in. +- `withSecretPath(string)`: Specify the secret path of the secret to delete. Defaults to `/` +- `withType("shared" | "personal")`: _(optional)_: The type of secret to delete. Defaults to `shared`. +- `withSecretKey(string)`: The key of the secret to delete. +- `build()` Returns the `DeleteSecretOptions` class that can be passed into the `deleteSecret()` method. + +**Returns**: +- Returns the deleted secret as a `TSecret` class. Read more in the [TSecret Class](#tsecret-class) documentation. + + +#### List Secrets +```cpp +const auto listSecretsOptions = Infisical::Input::ListSecretOptionsBuilder() + .withProjectId(projectId) + .withEnvironment(environment) + .withSecretPath("/") + .withRecursive(false) + .withAddSecretsToEnvironmentVariables(false) + .build(); + +const auto secrets = client.secrets().listSecrets(listSecretsOptions); + +``` + +**Parameters**: +- `withEnvironment(string)`: Specify the slug of the environment to list secrets from. +- `withProjectId(string)`: Specify the ID of the project to fetch secrets from. +- `withSecretPath(string)`: Specify the secret path to fetch secrets from. Defaults to `/` +- `withExpandSecretReferences(bool)` _(optional)_: Whether or not to expand secret references automatically. Defaults to `true`. +- `withRecursive(bool)` _(optional)_: Wether or not to recursively fetch secrets from sub-folders. If set to true, all secrets from the secret path specified with `withSecretPath()` and downwards will be fetched. +- `withAddSecretsToEnvironmentVariables(bool)` _(optional)_: If set to true, the fetched secrets will be automatically set as environment variables, making them accessible with `std::getenv` or equivalent by secret key. +- `build()`: Returns the `ListSecretsOptions` class that can be passed into the `listSecrets()` method. + +**Returns**: +- Returns the listed secrets as `std::vector`. Read more in the [TSecret Class](#tsecret-class) documentation. diff --git a/docs/sdks/languages/csharp.mdx b/docs/sdks/languages/csharp.mdx deleted file mode 100644 index 524778a4a..000000000 --- a/docs/sdks/languages/csharp.mdx +++ /dev/null @@ -1,594 +0,0 @@ ---- -title: "Infisical .NET SDK" -sidebarTitle: ".NET" -url: "https://github.com/Infisical/infisical-dotnet-sdk?tab=readme-ov-file#infisical-net-sdk" -icon: "/images/sdks/languages/dotnet.svg" ---- -{/* -If you're working with C#, the official [Infisical C# SDK](https://github.com/Infisical/sdk/tree/main/languages/csharp) package is the easiest way to fetch and work with secrets for your application. - -- [Nuget Package](https://www.nuget.org/packages/Infisical.Sdk) -- [Github Repository](https://github.com/Infisical/sdk/tree/main/languages/csharp) - - - **Deprecation Notice** - - All versions prior to **2.3.9** should be considered deprecated and are no longer supported by Infisical. Please update to version **2.3.9** or newer. All changes are fully backwards compatible with older versions. - - -## Basic Usage - -```cs -using Infisical.Sdk; - -namespace Example -{ - class Program - { - static void Main(string[] args) - { - - ClientSettings settings = new ClientSettings - { - Auth = new AuthenticationOptions - { - UniversalAuth = new UniversalAuthMethod - { - ClientId = "your-client-id", - ClientSecret = "your-client-secret" - } - } - }; - - - var infisicalClient = new InfisicalClient(settings); - - var getSecretOptions = new GetSecretOptions - { - SecretName = "TEST", - ProjectId = "PROJECT_ID", - Environment = "dev", - }; - var secret = infisicalClient.GetSecret(getSecretOptions); - - - Console.WriteLine($"The value of secret '{secret.SecretKey}', is: {secret.SecretValue}"); - } - } -} -``` - -This example demonstrates how to use the Infisical C# SDK in a C# application. The application retrieves a secret named `TEST` from the `dev` environment of the `PROJECT_ID` project. - - - We do not recommend hardcoding your [Machine Identity Tokens](/platform/identities/overview). Setting it as an environment variable would be best. - - -# Installation - -```console -$ dotnet add package Infisical.Sdk -``` -# Configuration - -Import the SDK and create a client instance with your [Machine Identity](/platform/identities/universal-auth). - -```cs -using Infisical.Sdk; - -namespace Example -{ - class Program - { - static void Main(string[] args) - { - ClientSettings settings = new ClientSettings - { - Auth = new AuthenticationOptions - { - UniversalAuth = new UniversalAuthMethod - { - ClientId = "your-client-id", - ClientSecret = "your-client-secret" - } - } - }; - - - var infisicalClient = new InfisicalClient(settings); // <-- Your SDK client is now ready to use - } - } -} -``` - -### ClientSettings methods - - - - - Your machine identity client ID. - - - Your machine identity client secret. - - - - An access token obtained from the machine identity login endpoint. - - - - Time-to-live (in seconds) for refreshing cached secrets. - If manually set to 0, caching will be disabled, this is not recommended. - - - - Your self-hosted absolute site URL including the protocol (e.g. `https://app.infisical.com`) - - - - Optionally provide a path to a custom SSL certificate file. This can be substituted by setting the `INFISICAL_SSL_CERTIFICATE` environment variable to the contents of the certificate. - - - - The authentication object to use for the client. This is required unless you're using environment variables. - - - - - -### Authentication - -The SDK supports a variety of authentication methods. The most common authentication method is Universal Auth, which uses a client ID and client secret to authenticate. - -#### Universal Auth - -**Using environment variables** -- `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID` - Your machine identity client ID. -- `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET` - Your machine identity client secret. - -**Using the SDK directly** -```csharp - ClientSettings settings = new ClientSettings - { - Auth = new AuthenticationOptions - { - UniversalAuth = new UniversalAuthMethod - { - ClientId = "your-client-id", - ClientSecret = "your-client-secret" - } - } - }; - - var infisicalClient = new InfisicalClient(settings); -``` - -#### GCP ID Token Auth - - Please note that this authentication method will only work if you're running your application on Google Cloud Platform. - Please [read more](/documentation/platform/identities/gcp-auth) about this authentication method. - - -**Using environment variables** -- `INFISICAL_GCP_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. - -**Using the SDK directly** -```csharp - ClientSettings settings = new ClientSettings - { - Auth = new AuthenticationOptions - { - GcpIdToken = new GcpIdTokenAuthMethod - { - IdentityId = "your-machine-identity-id", - } - } - }; - - - var infisicalClient = new InfisicalClient(settings); -``` - -#### GCP IAM Auth - -**Using environment variables** -- `INFISICAL_GCP_IAM_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. -- `INFISICAL_GCP_IAM_SERVICE_ACCOUNT_KEY_FILE_PATH` - The path to your GCP service account key file. - -**Using the SDK directly** -```csharp - ClientSettings settings = new ClientSettings - { - Auth = new AuthenticationOptions - { - GcpIam = new GcpIamAuthMethod - { - IdentityId = "your-machine-identity-id", - ServiceAccountKeyFilePath = "./path/to/your/service-account-key.json" - } - } - }; - - - var infisicalClient = new InfisicalClient(settings); -``` - -#### AWS IAM Auth - - Please note that this authentication method will only work if you're running your application on AWS. - Please [read more](/documentation/platform/identities/aws-auth) about this authentication method. - - -**Using environment variables** -- `INFISICAL_AWS_IAM_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. - -**Using the SDK directly** -```csharp - ClientSettings settings = new ClientSettings - { - Auth = new AuthenticationOptions - { - AwsIam = new AwsIamAuthMethod - { - IdentityId = "your-machine-identity-id", - } - } - }; - - - var infisicalClient = new InfisicalClient(settings); -``` - - -#### Azure Auth - - Please note that this authentication method will only work if you're running your application on Azure. - Please [read more](/documentation/platform/identities/azure-auth) about this authentication method. - - -**Using environment variables** -- `INFISICAL_AZURE_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. - -**Using the SDK directly** -```csharp - ClientSettings settings = new ClientSettings - { - Auth = new AuthenticationOptions - { - Azure = new AzureAuthMethod - { - IdentityId = "YOUR_IDENTITY_ID", - } - } - }; - - var infisicalClient = new InfisicalClient(settings); -``` - -#### Kubernetes Auth - - Please note that this authentication method will only work if you're running your application on Kubernetes. - Please [read more](/documentation/platform/identities/kubernetes-auth) about this authentication method. - - -**Using environment variables** -- `INFISICAL_KUBERNETES_IDENTITY_ID` - Your Infisical Machine Identity ID. -- `INFISICAL_KUBERNETES_SERVICE_ACCOUNT_TOKEN_PATH_ENV_NAME` - The environment variable name that contains the path to the service account token. This is optional and will default to `/var/run/secrets/kubernetes.io/serviceaccount/token`. - -**Using the SDK directly** -```csharp - ClientSettings settings = new ClientSettings - { - Auth = new AuthenticationOptions - { - Kubernetes = new KubernetesAuthMethod - { - ServiceAccountTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token", // Optional - IdentityId = "YOUR_IDENTITY_ID", - } - } - }; - - var infisicalClient = new InfisicalClient(settings); -``` - - - -### Caching - -To reduce the number of API requests, the SDK temporarily stores secrets it retrieves. By default, a secret remains cached for 5 minutes after it's first fetched. Each time it's fetched again, this 5-minute timer resets. You can adjust this caching duration by setting the "cacheTTL" option when creating the client. - -## Working with Secrets - -### client.ListSecrets(options) - -```cs -var options = new ListSecretsOptions -{ - ProjectId = "PROJECT_ID", - Environment = "dev", - Path = "/foo/bar", - AttachToProcessEnv = false, -}; - -var secrets = infisical.ListSecrets(options); -``` - -Retrieve all secrets within the Infisical project and environment that client is connected to - -#### Parameters - - - - - The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. - - - - The project ID where the secret lives in. - - - - The path from where secrets should be fetched from. - - - - Whether or not to set the fetched secrets to the process environment. If true, you can access the secrets like so `System.getenv("SECRET_NAME")`. - - - - Whether or not to include imported secrets from the current path. Read about [secret import](/documentation/platform/secret-reference) - - - - Whether or not to fetch secrets recursively from the specified path. Please note that there's a 20-depth limit for recursive fetching. - - - - Whether or not to expand secret references in the fetched secrets. Read about [secret reference](/documentation/platform/secret-reference) - - - - - -### client.GetSecret(options) - -```cs -var options = new GetSecretOptions - { - SecretName = "AAAA", - ProjectId = "659c781eb2d4fe3e307b77bd", - Environment = "dev", - }; -var secret = infisical.GetSecret(options); -``` - -Retrieve a secret from Infisical. - -By default, `GetSecret()` fetches and returns a shared secret. - -#### Parameters - - - - - The key of the secret to retrieve. - - - The project ID where the secret lives in. - - - The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. - - - The path from where secret should be fetched from. - - - The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". - - - Whether or not to include imported secrets from the current path. Read about [secret import](/documentation/platform/secret-reference) - - - Whether or not to expand secret references in the fetched secrets. Read about [secret reference](/documentation/platform/secret-reference) - - - - - -### client.CreateSecret(options) - -```cs -var options = new CreateSecretOptions { - Environment = "dev", - ProjectId = "PROJECT_ID", - - SecretName = "NEW_SECRET", - SecretValue = "NEW_SECRET_VALUE", - SecretComment = "This is a new secret", -}; - -var newSecret = infisical.CreateSecret(options); -``` - -Create a new secret in Infisical. - -#### Parameters - - - - - The key of the secret to create. - - - The value of the secret. - - - The project ID where the secret lives in. - - - The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. - - - The path from where secret should be created. - - - The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". - - - - -### client.UpdateSecret(options) - -```cs -var options = new UpdateSecretOptions { - Environment = "dev", - ProjectId = "PROJECT_ID", - - SecretName = "SECRET_TO_UPDATE", - SecretValue = "NEW VALUE" -}; - -var updatedSecret = infisical.UpdateSecret(options); -``` - -Update an existing secret in Infisical. - -#### Parameters - - - - - The key of the secret to update. - - - The new value of the secret. - - - The project ID where the secret lives in. - - - The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. - - - The path from where secret should be updated. - - - The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". - - - - -### client.DeleteSecret(options) - -```cs -var options = new DeleteSecretOptions -{ - Environment = "dev", - ProjectId = "PROJECT_ID", - SecretName = "NEW_SECRET", -}; - -var deletedSecret = infisical.DeleteSecret(options); -``` - -Delete a secret in Infisical. - -#### Parameters - - - - - The key of the secret to update. - - - The project ID where the secret lives in. - - - The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. - - - The path from where secret should be deleted. - - - The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". - - - - -## Cryptography - -### Create a symmetric key - -Create a base64-encoded, 256-bit symmetric key to be used for encryption/decryption. - -```cs -var key = infisical.CreateSymmetricKey(); -``` - -#### Returns (string) -`key` (string): A base64-encoded, 256-bit symmetric key, that can be used for encryption/decryption purposes. - -### Encrypt symmetric -```cs -var options = new EncryptSymmetricOptions -{ - Plaintext = "Infisical is awesome!", - Key = key, -}; - -var encryptedData = infisical.EncryptSymmetric(options); -``` - -#### Parameters - - - - - The plaintext you want to encrypt. - - - The symmetric key to use for encryption. - - - - -#### Returns (object) -`Tag` (string): A base64-encoded, 128-bit authentication tag. -`Iv` (string): A base64-encoded, 96-bit initialization vector. -`CipherText` (string): A base64-encoded, encrypted ciphertext. - -### Decrypt symmetric -```cs -var decryptOptions = new DecryptSymmetricOptions -{ - Key = key, - Ciphertext = encryptedData.Ciphertext, - Iv = encryptedData.Iv, - Tag = encryptedData.Tag, -}; - -var decryptedPlaintext = infisical.DecryptSymmetric(decryptOptions); -``` - -#### Parameters - - - - The ciphertext you want to decrypt. - - - The symmetric key to use for encryption. - - - The initialization vector to use for decryption. - - - The authentication tag to use for decryption. - - - - -#### Returns (string) -`Plaintext` (string): The decrypted plaintext. - */} diff --git a/docs/sdks/languages/dotnet.mdx b/docs/sdks/languages/dotnet.mdx new file mode 100644 index 000000000..7b3d12885 --- /dev/null +++ b/docs/sdks/languages/dotnet.mdx @@ -0,0 +1,358 @@ +--- +title: "Infisical .NET SDK" +sidebarTitle: ".NET" +icon: "/images/sdks/languages/dotnet.svg" +--- + +If you're working with .NET, the official Infisical .NET SDK package is the easiest way to fetch and work with secrets for your application. + +## Installation + +```bash +dotnet add package Infisical.Sdk +``` + +## Getting Started (.NET) + +```csharp +namespace Example; + +using Infisical.Sdk; +using Infisical.Sdk.Model; + +public class Program { + public static void Main(string[] args) { + + var settings = new InfisicalSdkSettingsBuilder() + .WithHostUri("http://localhost:8080") // Optional. Will default to https://app.infisical.com + .Build(); + + var infisicalClient = new InfisicalClient(settings); + + var _ = infisicalClient.Auth().UniversalAuth().LoginAsync("", "").Result; + + var options = new ListSecretsOptions + { + SetSecretsAsEnvironmentVariables = true, + EnvironmentSlug = "", + SecretPath = "/", + ProjectId = "", + }; + + var secrets = infisicalClient.Secrets().ListAsync(options).Result; + + if (secrets == null) + { + throw new Exception("Failed to fetch secrets, returned null response"); + } + + foreach (var secret in secrets) + { + Console.WriteLine($"{secret.SecretKey}: {secret.SecretValue}"); + } + } +} +``` + +## Getting Started (Visual Basic) +```vb +Imports Infisical.Sdk +Imports Infisical.Sdk.Model + +Module Program + Sub Main(args As String()) + Dim settings = New InfisicalSdkSettingsBuilder() _ + .WithHostUri("https://app.infisical.com") _ + .Build() + + Dim infisicalClient As New InfisicalClient(settings) + + Dim authResult = infisicalClient.Auth().UniversalAuth() _ + .LoginAsync("", "machine-identity-universal-auth-client-secret").Result + + Dim options As New ListSecretsOptions With { + .SetSecretsAsEnvironmentVariables = True, + .EnvironmentSlug = "", + .SecretPath = "/", + .ProjectId = "" + } + + Dim secrets = infisicalClient.Secrets().ListAsync(options).Result + + For Each secret In secrets + Console.WriteLine(secret.SecretKey) + if Environment.GetEnvironmentVariable(secret.SecretKey) IsNot Nothing Then + Console.WriteLine("{0} found on environment variables", secret.SecretKey) + End If + Next + + End Sub +End Module +``` + +## Core Methods + +The SDK methods are organized into the following high-level categories: + +1. `Auth()`: Handles authentication methods. +2. `Secrets()`: Manages CRUD operations for secrets. +3. `Pki()`: Programmatically interact with the Infisical PKI. + * `Subscribers()`: Manage PKI Subscribers. + +### `Auth()` + +The `Auth()` component provides methods for authentication: + +### Universal Auth + +#### Authenticating +```cs +var _ = await sdk.Auth().UniversalAuth().LoginAsync( + "CLIENT_ID", + "CLIENT_SECRET" +); +``` + + +**Parameters:** +- `clientId` (string): The client ID of your Machine Identity. +- `clientSecret` (string): The client secret of your Machine Identity. + +### `Secrets()` + +The `Secrets()` sub-class handles operations related to the Infisical secrets management product. + +#### List Secrets + +```cs +Task ListAsync(ListSecretsOptions options); + +throws InfisicalException +``` + +```csharp +var options = new ListSecretsOptions + { + SetSecretsAsEnvironmentVariables = true, + EnvironmentSlug = "dev", + SecretPath = "/test", + Recursive = true, + ExpandSecretReferences = true, + ProjectId = projectId, + ViewSecretValue = true, + }; + +Secret[] secrets = await sdk.Secrets().ListAsync(options); +``` + +**ListSecretsOptions:** +- `ProjectId` (string): The ID of your project. +- `EnvironmentSlug` (string): The environment in which to list secrets (e.g., "dev"). +- `SecretPath` (string): The path to the secrets. +- `ExpandSecretReferences` (boolean): Whether to expand secret references. +- `Recursive` (boolean): Whether to list secrets recursively. +- `SetSecretsAsEnvironmentVariables` (boolean): Set the retrieved secrets as environment variables. + +**Returns:** +- `Task`: The response containing the list of secrets. + +#### Create Secret + +```cs +public Task CreateAsync(CreateSecretOptions options); + +throws InfisicalException +``` + +```cs + +var options = new CreateSecretOptions +{ + SecretName = "SECRET_NAME", + SecretValue = "SECRET_VALUE", + EnvironmentSlug = "", + SecretPath = "/", + ProjectId = "", + Metadata = new SecretMetadata[] { + new SecretMetadata { + Key = "metadata-key", + Value = "metadata-value" + } + } +}; + +Task newSecret = await sdk.Secrets().CreateAsync(options); +``` + +**Parameters:** +- `SecretName` (string): The name of the secret to create +- `SecretValue` (string): The value of the secret. +- `ProjectId` (string): The ID of your project. +- `EnvironmentSlug` (string): The environment in which to create the secret. +- `SecretPath` (string, optional): The path to the secret. +- `Metadata` (object, optional): Attach metadata to the secret. +- `SecretComment` (string, optional): Attach a secret comment to the secret. +- `SecretReminderNote` (string, optional): Attach a secret reminder note to the secret. +- `SecretReminderRepeatDays` (int, optional): Set the reminder repeat days on the secret. +- `SkipMultilineEncoding` (bool, optional): Whether or not to skip multiline encoding for the secret's value. Defaults to `false`. + +**Returns:** +- `Task`: The created secret. + +#### Update Secret + +```cs +public Task UpdateAsync(UpdateSecretOptions options); + +throws InfisicalException +``` + + +```cs + +var updateSecretOptions = new UpdateSecretOptions +{ + SecretName = "EXISTING_SECRET_NAME", + EnvironmentSlug = "", + SecretPath = "/", + NewSecretName = "NEW_SECRET_NAME", + NewSecretValue = "new-secret-value", + ProjectId = "", +}; + + +Task updatedSecret = await sdk.Secrets().UpdateAsync(updateSecretOptions); +``` + +**Parameters:** +- `SecretName` (string): The name of the secret to update.` +- `ProjectId` (string): The ID of your project. +- `EnvironmentSlug` (string): The environment in which to update the secret. +- `SecretPath` (string): The path to the secret. +- `NewSecretValue` (string, optional): The new value of the secret. +- `NewSecretName` (string, optional): A new name for the secret. +- `NewMetadata` (object, optional): New metadata to attach to the secret. + +**Returns:** +- `Task`: The updated secret. + +#### Get Secret by Name + +```cs +public Task GetAsync(GetSecretOptions options); + +throws InfisicalException +``` + +```cs + +var getSecretOptions = new GetSecretOptions +{ + SecretName = "SECRET_NAME", + EnvironmentSlug = "", + SecretPath = "/", + ProjectId = "", +}; + +Secret secret = await sdk.Secrets().GetAsync(getSecretOptions); +``` + +**Parameters:** +- `SecretName` (string): The name of the secret to get` +- `ProjectId` (string): The ID of your project. +- `EnvironmentSlug` (string): The environment in which to retrieve the secret. +- `SecretPath` (string): The path to the secret. +- `ExpandSecretReferences` (boolean, optional): Whether to expand secret references. +- `Type` (SecretType, optional): The type of secret to fetch. Defaults to `Shared`. + + +**Returns:** +- `Task`: The fetched secret. + +#### Delete Secret by Name + +```cs +public Secret DeleteAsync(DeleteSecretOptions options); + +throws InfisicalException +``` + +```cs + +var options = new DeleteSecretOptions +{ + SecretName = "SECRET_TO_DELETE", + EnvironmentSlug = "", + SecretPath = "/", + ProjectId = "", +}; + + +Secret deletedSecret = await sdk.Secrets().DeleteAsync(options); +``` + +**Parameters:** +- `SecretName` (string): The name of the secret to delete. +- `ProjectId` (string): The ID of your project. +- `EnvironmentSlug` (string): The environment in which to delete the secret. +- `SecretPath` (string, optional): The path to the secret. + +**Returns:** +- `Task`: The deleted secret. + + +### `Pki().Subscribers()` + +The `Pki().Subscribers()` sub-class is used to programmatically interact with the Infisical PKI product line. Currently only issuing new certificates and retrieving the latest certificate bundle from a subscriber is supported. More widespread support for the PKI product is coming to the .NET SDK in the near future. + + +#### Issue a new certificate + +```cs +public async Task IssueCertificateAsync(IssueCertificateOptions options); + +throws InfisicalException +``` + +```cs + +var options = new IssueCertificateOptions +{ + SubscriberName = "", + ProjectId = "", +}; + +SubscriberIssuedCertificate newCertificate = await sdk.Pki().Subscribers().IssueCertificateAsync(options); +``` + +**Parameters:** +- `SubscriberName` (string): The name of the subscriber to create a certificate for. +- `ProjectId` (string): The ID of PKI project. + +**Returns:** +- `Task`: The newly issued certificate along with it's credentials for the specified subscriber. + +#### Retrieve latest certificate bundle + +```cs +public async Task RetrieveLatestCertificateBundleAsync(RetrieveLatestCertificateBundleOptions options) + +throws InfisicalException +``` + +```cs +var options = new RetrieveLatestCertificateBundleOptions +{ + SubscriberName = "", + ProjectId = "", +}; + +CertificateBundle latestCertificate = await sdk.Pki().Subscribers().RetrieveLatestCertificateBundleAsync(options); +``` + +**Parameters:** +- `SubscriberName` (string): The name of the subscriber to retrieve the latest certificate bundle for +- `ProjectId` (string): The ID of PKI project. + +**Returns:** +- `Task`: The latest certificate bundle for the specified subscriber. \ No newline at end of file diff --git a/docs/sdks/languages/go.mdx b/docs/sdks/languages/go.mdx index 312f7e307..01250c452 100644 --- a/docs/sdks/languages/go.mdx +++ b/docs/sdks/languages/go.mdx @@ -4,7 +4,7 @@ sidebarTitle: "Go" icon: "/images/sdks/languages/go.svg" --- -If you're working with Go Lang, the official [Infisical Go SDK](https://github.com/infisical/go-sdk) package is the easiest way to fetch and work with secrets for your application. +If you're working with Go, the official Infisical Go SDK package is the easiest way to fetch and work with secrets for your application. - [Package](https://pkg.go.dev/github.com/infisical/go-sdk) - [Github Repository](https://github.com/infisical/go-sdk) diff --git a/docs/sdks/languages/java.mdx b/docs/sdks/languages/java.mdx index 359afb987..7ead4d3dd 100644 --- a/docs/sdks/languages/java.mdx +++ b/docs/sdks/languages/java.mdx @@ -1,575 +1,487 @@ --- title: "Infisical Java SDK" sidebarTitle: "Java" -url: "https://github.com/Infisical/java-sdk?tab=readme-ov-file#infisical-java-sdk" icon: "/images/sdks/languages/java.svg" --- -{ -/* -If you're working with Java, the official [Infisical Java SDK](https://github.com/Infisical/sdk/tree/main/languages/java) package is the easiest way to fetch and work with secrets for your application. +If you're working with Java, the official Infisical Java SDK package is the easiest way to fetch and work with secrets for your application. -- [Maven Package](https://github.com/Infisical/sdk/packages/2019741) -- [Github Repository](https://github.com/Infisical/sdk/tree/main/languages/java) +## Installation -## Basic Usage +Replace `{version}` with the version of the SDK you wish to use. This documentation covers version >=3.0.0. + +### Maven + +```xml + + com.infisical + sdk + {version} + +``` + +### Gradle +```gradle + implementation group: 'com.infisical', name: 'sdk', version: '{version}' +``` + + +### Others +For other build tools, please check our [package snippets](https://central.sonatype.com/artifact/com.infisical/sdk), and select the build tool you're using for your project. + + + +## Getting Started ```java -package com.example.app; +package com.example.example; -import com.infisical.sdk.InfisicalClient; -import com.infisical.sdk.schema.*; +import com.infisical.sdk.InfisicalSdk; +import com.infisical.sdk.SdkConfig; public class Example { - public static void main(String[] args) { - - // Create the authentication settings for the client - ClientSettings settings = new ClientSettings(); - AuthenticationOptions authOptions = new AuthenticationOptions(); - UniversalAuthMethod authMethod = new UniversalAuthMethod(); - authMethod.setClientID("YOUR_IDENTITY_ID"); - authMethod.setClientSecret("YOUR_CLIENT_SECRET"); + public static void main(String[] args) { + var sdk = new InfisicalSdk( + new SdkConfig.Builder() + // Optional, will default to https://app.infisical.com + .withSiteUrl("https://your-infisical-instance.com") + .build() + ); - authOptions.setUniversalAuth(authMethod); - settings.setAuth(authOptions); + sdk.Auth().UniversalAuthLogin( + "CLIENT_ID", + "CLIENT_SECRET" + ); - // Create a new Infisical Client - InfisicalClient client = new InfisicalClient(settings); + var secret = sdk.Secrets().GetSecret( + "", + "", + "", + "", + null, // Expand Secret References (boolean, optional) + null, // Include Imports (boolean, optional) + null // Secret Type (shared/personal, defaults to shared, optional) + ); - // Create the options for fetching the secret - GetSecretOptions options = new GetSecretOptions(); - options.setSecretName("TEST"); - options.setEnvironment("dev"); - options.setProjectID("PROJECT_ID"); - // Fetch the sercret with the provided options - GetSecretResponseSecret secret = client.getSecret(options); - - // Print the value - System.out.println(secret.getSecretValue()); - - // Important to avoid memory leaks! - // If you intend to use the client throughout your entire application, you can omit this line. - client.close(); - } + System.out.println(secret); + } } ``` -This example demonstrates how to use the Infisical Java SDK in a Java application. The application retrieves a secret named `TEST` from the `dev` environment of the `PROJECT_ID` project. +## Core Methods - - We do not recommend hardcoding your [Machine Identity Tokens](/platform/identities/overview). Setting it as an environment variable would be best. - +The SDK methods are organized into the following high-level categories: -# Installation +1. `Auth()`: Handles authentication methods. +2. `Secrets()`: Manages CRUD operations for secrets. -The Infisical Java SDK is hosted on the GitHub Packages Apache Maven registry. Because of this you need to configure your environment properly so it's able to pull dependencies from the GitHub registry. Please check [this guide from GitHub](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-apache-maven-registry) on how to achieve this. +### `Auth` -Our package is [located here](https://github.com/Infisical/sdk/packages/2019741). Please follow the installation guide on the page. +The `Auth` component provides methods for authentication: -# Configuration +### Universal Auth -Import the SDK and create a client instance with your [Machine Identity](/platform/identities/universal-auth). +#### Authenticating ```java -import com.infisical.sdk.InfisicalClient; -import com.infisical.sdk.schema.*; - -public class App { - public static void main(String[] args) { - // Create the authentication settings for the client - ClientSettings settings = new ClientSettings(); - AuthenticationOptions authOptions = new AuthenticationOptions(); - UniversalAuthMethod authMethod = new UniversalAuthMethod(); - - authMethod.setClientID("YOUR_IDENTITY_ID"); - authMethod.setClientSecret("YOUR_CLIENT_SECRET"); - - authOptions.setUniversalAuth(authMethod); - settings.setAuth(authOptions); - - // Create a new Infisical Client - InfisicalClient client = new InfisicalClient(settings); // Your client! - } -} +public void UniversalAuthLogin( + String clientId, + String clientSecret +) +throws InfisicalException ``` -### ClientSettings methods - - - - - Your machine identity client ID. - - **This field is deprecated and will be removed in future versions.** Please use the `setAuth()` method on the client settings instead. - - - Your machine identity client secret. - - **This field is deprecated and will be removed in future versions.** Please use the `setAuth()` method on the client settings instead. - - - - An access token obtained from the machine identity login endpoint. - - **This field is deprecated and will be removed in future versions.** Please use the `setAuth()` method on the client settings instead. - - - - Time-to-live (in seconds) for refreshing cached secrets. - If manually set to 0, caching will be disabled, this is not recommended. - - - - Your self-hosted absolute site URL including the protocol (e.g. `https://app.infisical.com`) - - - - Optionally provide a path to a custom SSL certificate file. This can be substituted by setting the `INFISICAL_SSL_CERTIFICATE` environment variable to the contents of the certificate. - - - - The authentication object to use for the client. This is required unless you're using environment variables. - - - - - -### Authentication - -The SDK supports a variety of authentication methods. The most common authentication method is Universal Auth, which uses a client ID and client secret to authenticate. - -#### Universal Auth - -**Using environment variables** -- `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID` - Your machine identity client ID. -- `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET` - Your machine identity client secret. - -**Using the SDK directly** ```java - ClientSettings settings = new ClientSettings(); - AuthenticationOptions authOptions = new AuthenticationOptions(); - UniversalAuthMethod authMethod = new UniversalAuthMethod(); - - authMethod.setClientID("YOUR_IDENTITY_ID"); - authMethod.setClientSecret("YOUR_CLIENT_SECRET"); - - authOptions.setUniversalAuth(authMethod); - settings.setAuth(authOptions); - - InfisicalClient client = new InfisicalClient(settings); -``` - -#### GCP ID Token Auth - - Please note that this authentication method will only work if you're running your application on Google Cloud Platform. - Please [read more](/documentation/platform/identities/gcp-auth) about this authentication method. - - -**Using environment variables** -- `INFISICAL_GCP_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. - -**Using the SDK directly** -```java - ClientSettings settings = new ClientSettings(); - AuthenticationOptions authOptions = new AuthenticationOptions(); - GCPIDTokenAuthMethod authMethod = new GCPIDTokenAuthMethod(); - - authMethod.setIdentityID("YOUR_MACHINE_IDENTITY_ID"); - - authOptions.setGcpIDToken(authMethod); - settings.setAuth(authOptions); - - InfisicalClient client = new InfisicalClient(settings); -``` - -#### GCP IAM Auth - -**Using environment variables** -- `INFISICAL_GCP_IAM_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. -- `INFISICAL_GCP_IAM_SERVICE_ACCOUNT_KEY_FILE_PATH` - The path to your GCP service account key file. - -**Using the SDK directly** -```java - ClientSettings settings = new ClientSettings(); - AuthenticationOptions authOptions = new AuthenticationOptions(); - GCPIamAuthMethod authMethod = new GCPIamAuthMethod(); - - authMethod.setIdentityID("YOUR_MACHINE_IDENTITY_ID"); - authMethod.setServiceAccountKeyFilePath("./path/to/your/service-account-key.json"); - - authOptions.setGcpIam(authMethod); - settings.setAuth(authOptions); - - InfisicalClient client = new InfisicalClient(settings); -``` - -#### AWS IAM Auth - - Please note that this authentication method will only work if you're running your application on AWS. - Please [read more](/documentation/platform/identities/aws-auth) about this authentication method. - - -**Using environment variables** -- `INFISICAL_AWS_IAM_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. - -**Using the SDK directly** -```java - ClientSettings settings = new ClientSettings(); - AuthenticationOptions authOptions = new AuthenticationOptions(); - AWSIamAuthMethod authMethod = new AWSIamAuthMethod(); - - authMethod.setIdentityID("YOUR_MACHINE_IDENTITY_ID"); - - authOptions.setAwsIam(authMethod); - settings.setAuth(authOptions); - - InfisicalClient client = new InfisicalClient(settings); -``` - -#### Azure Auth - - Please note that this authentication method will only work if you're running your application on Azure. - Please [read more](/documentation/platform/identities/azure-auth) about this authentication method. - - -**Using environment variables** -- `INFISICAL_AZURE_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. - -**Using the SDK directly** -```java - ClientSettings settings = new ClientSettings(); - AuthenticationOptions authOptions = new AuthenticationOptions(); - AzureAuthMethod authMethod = new AzureAuthMethod(); - - authMethod.setIdentityID("YOUR_IDENTITY_ID"); - - authOptions.setAzure(authMethod); - settings.setAuth(authOptions); - - InfisicalClient client = new InfisicalClient(settings); -``` - -#### Kubernetes Auth - - Please note that this authentication method will only work if you're running your application on Kubernetes. - Please [read more](/documentation/platform/identities/kubernetes-auth) about this authentication method. - - -**Using environment variables** -- `INFISICAL_KUBERNETES_IDENTITY_ID` - Your Infisical Machine Identity ID. -- `INFISICAL_KUBERNETES_SERVICE_ACCOUNT_TOKEN_PATH_ENV_NAME` - The environment variable name that contains the path to the service account token. This is optional and will default to `/var/run/secrets/kubernetes.io/serviceaccount/token`. - -**Using the SDK directly** -```java - ClientSettings settings = new ClientSettings(); - AuthenticationOptions authOptions = new AuthenticationOptions(); - KubernetesAuthMethod authMethod = new KubernetesAuthMethod(); - - authMethod.setIdentityID("YOUR_IDENTITY_ID"); - authMethod.setServiceAccountTokenPath("/var/run/secrets/kubernetes.io/serviceaccount/token"); // Optional - - authOptions.setKubernetes(authMethod); - settings.setAuth(authOptions); - - InfisicalClient client = new InfisicalClient(settings); +sdk.Auth().UniversalAuthLogin( + "CLIENT_ID", + "CLIENT_SECRET" +); ``` -### Caching +**Parameters:** +- `clientId` (string): The client ID of your Machine Identity. +- `clientSecret` (string): The client secret of your Machine Identity. -To reduce the number of API requests, the SDK temporarily stores secrets it retrieves. By default, a secret remains cached for 5 minutes after it's first fetched. Each time it's fetched again, this 5-minute timer resets. You can adjust this caching duration by setting the "cacheTTL" option when creating the client. - -## Working with Secrets - -### client.listSecrets(options) +### LDAP Auth ```java -ListSecretsOptions options = new ListSecretsOptions(); -options.setEnvironment("dev"); -options.setProjectID("PROJECT_ID"); -options.setPath("/foo/bar"); -options.setIncludeImports(false); -options.setRecursive(false); -options.setExpandSecretReferences(true); - -SecretElement[] secrets = client.listSecrets(options); +public void LdapAuthLogin( + LdapAuthLoginInput input +) +throws InfisicalException ``` -Retrieve all secrets within the Infisical project and environment that client is connected to - -#### Methods - - - - - The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. - - - - The project ID where the secret lives in. - - - - The path from where secrets should be fetched from. - - - - Whether or not to set the fetched secrets to the process environment. If true, you can access the secrets like so `System.getenv("SECRET_NAME")`. - - - - Whether or not to include imported secrets from the current path. Read about [secret import](/documentation/platform/secret-reference) - - - - Whether or not to fetch secrets recursively from the specified path. Please note that there's a 20-depth limit for recursive fetching. - - - - Whether or not to expand secret references in the fetched secrets. Read about [secret reference](/documentation/platform/secret-reference) - - - - - -### client.getSecret(options) - ```java -GetSecretOptions options = new GetSecretOptions(); -options.setSecretName("TEST"); -options.setEnvironment("dev"); -options.setProjectID("PROJECT_ID"); +var input = LdapAuthLoginInput + .builder() + .identityId("") + .username("") + .password("") + .build(); -GetSecretResponseSecret secret = client.getSecret(options); - -String secretValue = secret.getSecretValue(); +sdk.Auth().LdapAuthLogin(input); ``` -Retrieve a secret from Infisical. +**Parameters:** +- `input` (LdapAuthLoginInput): The input for authenticating with LDAP. + - `identityId` (String): The ID of the machine identity to authenticate with. + - `username` (String): The LDAP username. + - `password` (String): The LDAP password. -By default, `getSecret()` fetches and returns a shared secret. +### Access Token Auth -#### Methods - - - - - The key of the secret to retrieve. - - - The project ID where the secret lives in. - - - The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. - - - The path from where secret should be fetched from. - - - The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". - - - Whether or not to include imported secrets from the current path. Read about [secret import](/documentation/platform/secret-reference) - - - Whether or not to expand secret references in the fetched secrets. Read about [secret reference](/documentation/platform/secret-reference) - - - - -### client.createSecret(options) +#### Authenticating ```java -CreateSecretOptions createOptions = new CreateSecretOptions(); -createOptions.setSecretName("NEW_SECRET"); -createOptions.setEnvironment("dev"); -createOptions.setProjectID("PROJECT_ID"); -createOptions.setSecretValue("SOME SECRET VALUE"); -createOptions.setPath("/"); // Default -createOptions.setType("shared"); // Default - -CreateSecretResponseSecret newSecret = client.createSecret(createOptions); +public void SetAccessToken( + String accessToken +) +throws InfisicalException ``` -Create a new secret in Infisical. - -#### Methods - - - - - The key of the secret to create. - - - The value of the secret. - - - The project ID where the secret lives in. - - - The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. - - - The path from where secret should be created. - - - The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". - - - - -### client.updateSecret(options) - ```java -UpdateSecretOptions options = new UpdateSecretOptions(); - -options.setSecretName("SECRET_TO_UPDATE"); -options.setSecretValue("NEW SECRET VALUE"); -options.setEnvironment("dev"); -options.setProjectID("PROJECT_ID"); -options.setPath("/"); // Default -options.setType("shared"); // Default - -UpdateSecretResponseSecret updatedSecret = client.updateSecret(options); +sdk.Auth().SetAccessToken("ACCESS_TOKEN"); ``` -Update an existing secret in Infisical. +**Parameters:** +- `accessToken` (string): The access token you want to use for authentication. -#### Methods +### `Secrets` - - - - The key of the secret to update. - - - The new value of the secret. - - - The project ID where the secret lives in. - - - The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. - - - The path from where secret should be updated. - - - The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". - - - +This sub-class handles operations related to secrets: -### client.deleteSecret(options) +#### List Secrets ```java -DeleteSecretOptions options = new DeleteSecretOptions(); +public List ListSecrets( + String projectId, + String environmentSlug, + String secretPath, + Boolean expandSecretReferences, + Boolean recursive, + Boolean includeImports, + Boolean setSecretsOnSystemProperties +) -options.setSecretName("SECRET_TO_DELETE"); -options.setEnvironment("dev"); -options.setProjectID("PROJECT_ID"); -options.setPath("/"); // Default -options.setType("shared"); // Default - -DeleteSecretResponseSecret deletedSecret = client.deleteSecret(options); +throws InfisicalException ``` -Delete a secret in Infisical. - -#### Methods - - - - - The key of the secret to update. - - - The project ID where the secret lives in. - - - The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. - - - The path from where secret should be deleted. - - - The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". - - - - -## Cryptography - -### Create a symmetric key - -Create a base64-encoded, 256-bit symmetric key to be used for encryption/decryption. - ```java -String key = client.createSymmetricKey(); +List secrets = sdk.Secrets().ListSecrets( + "", + "", // dev, prod, staging, etc. + "/secret/path", // `/` is the root folder + false, // Should expand secret references + false, // Should get secrets recursively from sub folders + false, // Should include imports + false // Should set the fetched secrets as key/value pairs on the system properties. Makes the secrets accessible as System.getProperty("") +); ``` -#### Returns (string) -`key` (string): A base64-encoded, 256-bit symmetric key, that can be used for encryption/decryption purposes. +**Parameters:** +- `projectId` (string): The ID of your project. +- `environmentSlug` (string): The environment in which to list secrets (e.g., "dev"). +- `secretPath` (string): The path to the secrets. +- `expandSecretReferences` (boolean): Whether to expand secret references. +- `recursive` (boolean): Whether to list secrets recursively. +- `includeImports` (boolean): Whether to include imported secrets. +- `setSecretsOnSystemProperties` (boolean): Set the retrieved secrets as key/value pairs on the system properties, making them accessible through `System.getProperty("")` + +**Returns:** +- `List`: The response containing the list of secrets. + +#### Create Secret + -### Encrypt symmetric ```java -EncryptSymmetricOptions options = new EncryptSymmetricOptions(); -options.setKey(key); -options.setPlaintext("Infisical is awesome!"); - -EncryptSymmetricResponse encryptedData = client.encryptSymmetric(options); +public Secret CreateSecret( + String secretName, + String secretValue, + String projectId, + String environmentSlug, + String secretPath +) +throws InfisicalException ``` -#### Methods - - - - - The plaintext you want to encrypt. - - - The symmetric key to use for encryption. - - - - -#### Returns (object) -`tag (getTag())` (string): A base64-encoded, 128-bit authentication tag. -`iv (getIv())` (string): A base64-encoded, 96-bit initialization vector. -`ciphertext (getCipherText())` (string): A base64-encoded, encrypted ciphertext. - - -### Decrypt symmetric ```java -DecryptSymmetricOptions decryptOptions = new DecryptSymmetricOptions(); -decryptOptions.setKey(key); -decryptOptions.setCiphertext(encryptedData.getCiphertext()); -decryptOptions.setIv(encryptedData.getIv()); -decryptOptions.setTag(encryptedData.getTag()); - -String decryptedString = client.decryptSymmetric(decryptOptions); +Secret newSecret = sdk.Secrets().CreateSecret( + "NEW_SECRET_NAME", + "secret-value", + "", + "", // dev, prod, staging, etc. + "/secret/path", // `/` is the root folder +); ``` -#### Methods - - - - The ciphertext you want to decrypt. - - - The symmetric key to use for encryption. - - - The initialization vector to use for decryption. - - - The authentication tag to use for decryption. - - - +**Parameters:** +- `secretName` (string): The name of the secret to create +- `secretValue` (string): The value of the secret. +- `projectId` (string): The ID of your project. +- `environmentSlug` (string): The environment in which to create the secret. +- `secretPath` (string, optional): The path to the secret. -#### Returns (string) -`Plaintext` (string): The decrypted plaintext. -*/} \ No newline at end of file +**Returns:** +- `Secret`: The created secret. + +#### Update Secret + +```java +public Secret UpdateSecret( + String secretName, + String projectId, + String environmentSlug, + String secretPath, + String newSecretValue, + String newSecretName + ) + +throws InfisicalException +``` + + +```java +Secret updatedSecret = sdk.Secrets().UpdateSecret( + "SECRET_NAME", + "", + "", // dev, prod, staging, etc. + "/secret/path", // `/` is the root folder + "NEW_SECRET_VALUE", // nullable + "NEW_SECRET_NAME" // nullable +); +``` + +**Parameters:** +- `secretName` (string): The name of the secret to update. +- `projectId` (string): The ID of your project. +- `environmentSlug` (string): The environment in which to update the secret. +- `secretPath` (string): The path to the secret. +- `newSecretValue` (string, nullable): The new value of the secret. +- `newSecretName` (string, nullable): A new name for the secret. + +**Returns:** +- `Secret`: The updated secret. + +#### Get Secret by Name + +```java +public Secret GetSecret( + String secretName, + String projectId, + String environmentSlug, + String secretPath, + Boolean expandSecretReferences, + Boolean includeImports, + String secretType + ) +throws InfisicalException +``` + +```java +Secret secret = sdk.Secrets().GetSecret( + "SECRET_NAME", + "", + "", // dev, prod, staging, etc. + "/secret/path", // `/` is the root folder + false, // Should expand secret references + false, // Should get secrets recursively from sub folders + false, // Should include imports + "shared" // Optional Secret Type (defaults to "shared") +); +``` + +**Parameters:** +- `secretName` (string): The name of the secret to get` +- `projectId` (string): The ID of your project. +- `environmentSlug` (string): The environment in which to retrieve the secret. +- `secretPath` (string): The path to the secret. +- `expandSecretReferences` (boolean, optional): Whether to expand secret references. +- `includeImports` (boolean, optional): Whether to include imported secrets. +- `secretType` (personal | shared, optional): The type of secret to fetch. + + +**Returns:** +- `Secret`: The fetched secret. + +#### Delete Secret by Name + +```java +public Secret DeleteSecret( + String secretName, + String projectId, + String environmentSlug, + String secretPath + ) +throws InfisicalException +``` + +```java +Secret deletedSecret = sdk.Secrets().DeleteSecret( + "SECRET_NAME", + "", + "", // dev, prod, staging, etc. + "/secret/path", // `/` is the root folder +); +``` + +**Parameters:** +- `secretName` (string): The name of the secret to delete. +- `projectId` (string): The ID of your project. +- `environmentSlug` (string): The environment in which to delete the secret. +- `secretPath` (string, optional): The path to the secret. + +**Returns:** +- `Secret`: The deleted secret. + + +### `Folders` + +#### Get Folder By Name + +```java +public Folder Get( + String folderId +); +throws InfisicalException +``` + +```java +Folder folder = sdk.Folders().Get(""); +``` + +**Parameters:** +- `folderId` (String): The ID of the folder to retrieve. + +**Returns:** +- `Folder`: The retrieved folder. + +#### List Folders + +```java +public List List( + ListFoldersInput input +) +throws InfisicalException +``` + +```java +ListFoldersInput input = ListFoldersInput + .builder() + .projectId("") + .environmentSlug("") + .folderPath("/") + .recursive(false) + .build(); + +List folders = sdk.Folders().List(input); +``` + + +**Parameters:** +- `input` (ListFoldersInput): The input for listing folders. + - `projectId` (String): The ID of the project to list folders from. + - `environmentSlug` (String): The slug of the environment to list folders from. + - `folderPath` (String): The path to list folders from. Defaults to `/`. + - `recursive` (Boolean): Whether or not to list sub-folders recursively from the specified folder path and downwards. Defaults to `false`. + +**Returns:** +- `List`: The retrieved folders. + +#### Create Folder + +```java +public Folder Create( + CreateFolderInput input +) +throws InfisicalException +``` + +```java +var input = CreateFolderInput + .builder() + .projectId("") + .environmentSlug("") + .folderName("") + .folderPath("/") + .description("Optional folder description") + .build(); + +Folder createdFolder = sdk.Folders().Create(input); +``` + +**Parameters:** +- `input` (CreateFolderInput): The input for creating a folder. + - `projectId` (String): The ID of the project to create the folder in. + - `environmentSlug` (String): The slug of the environment to create the folder in. + - `folderPath` (String): The path to create the folder in. Defaults to `/`. + - `folderName` (String): The name of the folder to create. + - `description` (String): The description of the folder to create. This is optional. + +**Returns:** +- `Folder`: The created folder. + +#### Update Folder + +```java +public Folder Update( + UpdateFolderInput input +) +throws InfisicalException +``` + +```java +var input = UpdateFolderInput + .builder() + .projectId("") + .environmentSlug("") + .folderId("") + .newFolderName("") + .folderPath("/") + .build(); + +Folder updatedFolder = sdk.Folders().Update(input); +``` + +**Parameters:** +- `input` (UpdateFolderInput): The input for updating a folder. + - `projectId` (String): The ID of the project where the folder exists. + - `environmentSlug` (String): The slug of the environment where the folder exists. + - `folderPath` (String): The path of the folder to update. + - `folderId` (String): The ID of the folder to update. + - `newFolderName` (String): The new folder name. + +**Returns:** +- `Folder`: The updated folder. + +#### Delete Folder + +```java +public Folder Delete( + DeleteFolderInput input +) +throws InfisicalException +``` + +```java +var input = DeleteFolderInput + .builder() + .folderId("") + .environmentSlug("") + .projectId("") + .build(); + +Folder deletedFolder = sdk.Folders().Delete(input); +``` + + +**Parameters:** +- `input` (DeleteFolderInput): The input for deleting a folder. + - `projectId` (String): The ID of the project where the folder exists. + - `environmentSlug` (String): The slug of the environment where the folder exists. + - `folderId` (String): The ID of the folder to delete. + +**Returns:** +- `Folder`: The deleted folder. \ No newline at end of file diff --git a/docs/sdks/languages/node.mdx b/docs/sdks/languages/node.mdx index 69bb36e97..e28e9c9f2 100644 --- a/docs/sdks/languages/node.mdx +++ b/docs/sdks/languages/node.mdx @@ -1,558 +1,559 @@ --- title: "Infisical Node.js SDK" sidebarTitle: "Node.js" -url: "https://github.com/Infisical/node-sdk-v2?tab=readme-ov-file#infisical-nodejs-sdk" icon: "/images/sdks/languages/node.svg" --- -{/* -If you're working with Node.js, the official [Infisical Node SDK](https://github.com/Infisical/sdk/tree/main/languages/node) package is the easiest way to fetch and work with secrets for your application. +If you're working with Node.js, the official Infisical Node.js SDK package is the easiest way to fetch and work with secrets for your application. -- [NPM Package](https://www.npmjs.com/package/@infisical/sdk) -- [Github Repository](https://github.com/Infisical/sdk/tree/main/languages/node) +## Deprecation Notice -## Basic Usage - -```js -import express from "express"; - -import { InfisicalClient } from "@infisical/sdk"; - -const app = express(); - -const PORT = 3000; - -const client = new InfisicalClient({ - siteUrl: "https://app.infisical.com", // Optional, defaults to https://app.infisical.com - auth: { - universalAuth: { - clientId: "YOUR_CLIENT_ID", - clientSecret: "YOUR_CLIENT_SECRET" - } - } -}); - -app.get("/", async (req, res) => { - // Access the secret - - const name = await client.getSecret({ - environment: "dev", - projectId: "PROJECT_ID", - path: "/", - type: "shared", - secretName: "NAME" - }); - - res.send(`Hello! My name is: ${name.secretValue}`); -}); - -app.listen(PORT, async () => { - // initialize client - - console.log(`App listening on port ${PORT}`); -}); -``` - -This example demonstrates how to use the Infisical Node SDK with an Express application. The application retrieves a secret named "NAME" and responds to requests with a greeting that includes the secret value. - - - We do not recommend hardcoding your [Machine Identity Tokens](/documentation/platform/identities/overview). Setting it as an environment variable - would be best. - +Please be aware that all versions prior to `4.0.0` are officially fully unsupported. +Please upgrade to version 4.0.0 or newer in order to receive the latest updates. ## Installation -Run `npm` to add `@infisical/sdk` to your project. - -```console -$ npm install @infisical/sdk +```bash +npm install @infisical/sdk ``` -## Configuration +## Getting Started -Import the SDK and create a client instance with your [Machine Identity](/documentation/platform/identities/overview). +```typescript +import { InfisicalSDK } from '@infisical/sdk' - - - ```js - import { InfisicalClient, LogLevel } from "@infisical/sdk"; +const client = new InfisicalSDK({ + siteUrl: "your-infisical-instance.com" // Optional, defaults to https://app.infisical.com +}); - const client = new InfisicalClient({ - auth: { - universalAuth: { - clientId: "YOUR_CLIENT_ID", - clientSecret: "YOUR_CLIENT_SECRET" - } - }, - logLevel: LogLevel.Error - }); - ``` +// Authenticate with Infisical +await client.auth().universalAuth.login({ + clientId: "", + clientSecret: "" +}); - - - ```js - const { InfisicalClient } = require("@infisical/sdk"); +const allSecrets = await client.secrets().listSecrets({ + environment: "dev", // stg, dev, prod, or custom environment slugs + projectId: "" +}); - const client = new InfisicalClient({ - auth: { - universalAuth: { - clientId: "YOUR_CLIENT_ID", - clientSecret: "YOUR_CLIENT_SECRET" - } - }, - }); - ``` +console.log("Fetched secrets", allSecrets) +``` - - +## Core Methods -### Parameters +The SDK methods are organized into the following high-level categories: - - - - Your machine identity client ID. - - **This field is deprecated and will be removed in future versions.** Please use the `auth.universalAuth.clientId` field instead. - - - Your machine identity client secret. - - **This field is deprecated and will be removed in future versions.** Please use the `auth.universalAuth.clientSecret` field instead. - +1. `auth`: Handles authentication methods. +2. `secrets`: Manages CRUD operations for secrets. +3. `dynamicSecrets`: Manages dynamic secrets and leases. +4. `projects`: Creates and manages projects. +5. `environments`: Creates and manages environments. +6. `folders`: Creates and manages folders. - - An access token obtained from the machine identity login endpoint. - - **This field is deprecated and will be removed in future versions.** Please use the `auth.accessToken` field instead. - +### `auth` - - Time-to-live (in seconds) for refreshing cached secrets. - If manually set to 0, caching will be disabled, this is not recommended. - - - - Your self-hosted absolute site URL including the protocol (e.g. `https://app.infisical.com`) - - - The level of logs you wish to log The logs are derived from Rust, as we have written our base SDK in Rust. - - - - Optionally provide a path to a custom SSL certificate file. This can be substituted by setting the `INFISICAL_SSL_CERTIFICATE` environment variable to the contents of the certificate. - - - - The authentication object to use for the client. This is required unless you're using environment variables. - - - - - - -### Authentication - -The SDK supports a variety of authentication methods. The most common authentication method is Universal Auth, which uses a client ID and client secret to authenticate. +The `Auth` component provides methods for authentication: #### Universal Auth -**Using environment variables** -- `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID` - Your machine identity client ID. -- `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET` - Your machine identity client secret. - -**Using the SDK directly** -```js -const client = new InfisicalClient({ - auth: { - universalAuth: { - clientId: "YOUR_CLIENT_ID", - clientSecret: "YOUR_CLIENT_SECRET" - } - } +#### Authenticating +```typescript +await client.auth().universalAuth.login({ + clientId: "", + clientSecret: "" }); ``` -#### GCP ID Token Auth - - Please note that this authentication method will only work if you're running your application on Google Cloud Platform. - Please [read more](/documentation/platform/identities/gcp-auth) about this authentication method. - +**Parameters:** +- `options` (object): + - `clientId` (string): The client ID of your Machine Identity. + - `clientSecret` (string): The client secret of your Machine Identity. -**Using environment variables** -- `INFISICAL_GCP_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. - -**Using the SDK directly** -```js -const client = new InfisicalClient({ - auth: { - gcpIdToken: { - identityId: "YOUR_IDENTITY_ID" - } - } -}); +#### Renewing +You can renew the authentication token that is currently set by using the `renew()` method. +```typescript +await client.auth().universalAuth.renew(); ``` -#### GCP IAM Auth +#### Manually set access token +By default, when you run a successful `.login()` method call, the access token returned will be auto set for the client instance. However, if you wish to set the access token manually, you may use this method. -**Using environment variables** -- `INFISICAL_GCP_IAM_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. -- `INFISICAL_GCP_IAM_SERVICE_ACCOUNT_KEY_FILE_PATH` - The path to your GCP service account key file. - -**Using the SDK directly** -```js -const client = new InfisicalClient({ - auth: { - gcpIam: { - identityId: "YOUR_IDENTITY_ID", - serviceAccountKeyFilePath: "./path/to/your/service-account-key.json" - } - } -}); +```typescript +client.auth().accessToken("") ``` -#### AWS IAM Auth - - Please note that this authentication method will only work if you're running your application on AWS. - Please [read more](/documentation/platform/identities/aws-auth) about this authentication method. - - -**Using environment variables** -- `INFISICAL_AWS_IAM_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. - -**Using the SDK directly** -```js -const client = new InfisicalClient({ - auth: { - awsIam: { - identityId: "YOUR_IDENTITY_ID" - } - } -}); -``` - -#### Azure Auth - - Please note that this authentication method will only work if you're running your application on Azure. - Please [read more](/documentation/platform/identities/azure-auth) about this authentication method. - - -**Using environment variables** -- `INFISICAL_AZURE_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. - -**Using the SDK directly** -```js -const client = new InfisicalClient({ - auth: { - azure: { - identityId: "YOUR_IDENTITY_ID" - } - } -}); -``` +**Parameters:** +- `accessToken` (string): The access token to be used for authentication. _This should not include "Bearer"._ -#### Kubernetes Auth - - Please note that this authentication method will only work if you're running your application on Kubernetes. - Please [read more](/documentation/platform/identities/kubernetes-auth) about this authentication method. - +#### AWS IAM -**Using environment variables** -- `INFISICAL_KUBERNETES_IDENTITY_ID` - Your Infisical Machine Identity ID. -- `INFISICAL_KUBERNETES_SERVICE_ACCOUNT_TOKEN_PATH_ENV_NAME` - The environment variable name that contains the path to the service account token. This is optional and will default to `/var/run/secrets/kubernetes.io/serviceaccount/token`. + + AWS IAM auth only works when the SDK is being used from within an AWS service, such as Lambda, EC2, etc. + -**Using the SDK directly** -```js -const client = new InfisicalClient({ - auth: { - kubernetes: { - identityId: "YOUR_IDENTITY_ID", - serviceAccountTokenPathEnvName: "/var/run/secrets/kubernetes.io/serviceaccount/token" // Optional - } - } -}); -``` - -### Caching - -To reduce the number of API requests, the SDK temporarily stores secrets it retrieves. By default, a secret remains cached for 5 minutes after it's first fetched. Each time it's fetched again, this 5-minute timer resets. You can adjust this caching duration by setting the "cacheTtl" option when creating the client. - -## Working with Secrets - -### client.listSecrets(options) - -```js -const secrets = await client.listSecrets({ - environment: "dev", - projectId: "PROJECT_ID", - path: "/foo/bar/", - includeImports: false -}); -``` - -Retrieve all secrets within the Infisical project and environment that client is connected to - -#### Parameters - - - - - The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. - - - The project ID where the secret lives in. - - - - The path from where secrets should be fetched from. - - - - Whether or not to set the fetched secrets to the process environment. If true, you can access the secrets like so `process.env["SECRET_NAME"]`. - - - - Whether or not to fetch secrets recursively from the specified path. Please note that there's a 20-depth limit for recursive fetching. - - - - Whether or not to expand secret references in the fetched secrets. Read about [secret reference](/documentation/platform/secret-reference) - - - - Whether or not to include imported secrets from the current path. Read about [secret import](/documentation/platform/secret-reference) - - - - - -### client.getSecret(options) - -```js -const secret = await client.getSecret({ - environment: "dev", - projectId: "PROJECT_ID", - secretName: "API_KEY", - path: "/", - type: "shared" -}); -``` - -Retrieve a secret from Infisical. - -By default, `getSecret()` fetches and returns a shared secret. - -#### Parameters - - - - - The key of the secret to retrieve. - - - The project ID where the secret lives in. - - - The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. - - - The path from where secret should be fetched from. - - - The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". - - - Whether or not to include imported secrets from the current path. Read about [secret import](/documentation/platform/secret-reference) - - - Whether or not to expand secret references in the fetched secrets. Read about [secret reference](/documentation/platform/secret-reference) - - - - -### client.createSecret(options) - -```js -const newApiKey = await client.createSecret({ - projectId: "PROJECT_ID", - environment: "dev", - secretName: "API_KEY", - secretValue: "SECRET VALUE", - path: "/", - type: "shared" -}); -``` - -Create a new secret in Infisical. - - - - - The key of the secret to create. - - - The value of the secret. - - - The project ID where the secret lives in. - - - The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. - - - The path from where secret should be created. - - - The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". - - - - -### client.updateSecret(options) - -```js -const updatedApiKey = await client.updateSecret({ - secretName: "API_KEY", - secretValue: "NEW SECRET VALUE", - projectId: "PROJECT_ID", - environment: "dev", - path: "/", - type: "shared" -}); -``` - -Update an existing secret in Infisical. - -#### Parameters - - - - - The key of the secret to update. - - - The new value of the secret. - - - The project ID where the secret lives in. - - - The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. - - - The path from where secret should be updated. - - - The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". - - - - -### client.deleteSecret(options) - -```js -const deletedSecret = await client.deleteSecret({ - secretName: "API_KEY", - - environment: "dev", - projectId: "PROJECT_ID", - path: "/", - - type: "shared" -}); -``` - -Delete a secret in Infisical. - - - - - The key of the secret to update. - - - The project ID where the secret lives in. - - - The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. - - - The path from where secret should be deleted. - - - The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". - - - - -## Cryptography - -### Create a symmetric key - -Create a base64-encoded, 256-bit symmetric key to be used for encryption/decryption. - -```js -const key = client.createSymmetricKey(); -``` - -#### Returns (string) -`key` (string): A base64-encoded, 256-bit symmetric key, that can be used for encryption/decryption purposes. - -### Encrypt symmetric -```js -const { iv, tag, ciphertext } = await client.encryptSymmetric({ - key: key, - plaintext: "Infisical is awesome!", +#### Authenticating +```typescript +await client.auth().awsIamAuth.login({ + identityId: "" }) ``` -#### Parameters +**Parameters:** +- `options` (object): + - `identityId` (string): The ID of your identity - - - - The plaintext you want to encrypt. - - - The symmetric key to use for encryption. - - - +#### Renewing +You can renew the authentication token that is currently set by using the `renew()` method. +```typescript +await client.auth().awsIamAuth.renew(); +``` -#### Returns (object) -`tag` (string): A base64-encoded, 128-bit authentication tag. -`iv` (string): A base64-encoded, 96-bit initialization vector. -`ciphertext` (string): A base64-encoded, encrypted ciphertext. -### Decrypt symmetric -```js -const decryptedString = await client.decryptSymmetric({ - key: key, - iv: iv, - tag: tag, - ciphertext: ciphertext, +### `secrets` + +This sub-class handles operations related to secrets: + +#### List Secrets + +```typescript +const allSecrets = await client.secrets().listSecrets({ + environment: "dev", + projectId: "", + expandSecretReferences: true, + viewSecretValue: true, + includeImports: false, + recursive: false, + secretPath: "/foo/bar", }); ``` -#### Parameters - - - - The ciphertext you want to decrypt. - - - The symmetric key to use for encryption. - - - The initialization vector to use for decryption. - - - The authentication tag to use for decryption. - - - +**Parameters:** +- `projectId` (string): The ID of your project. +- `environment` (string): The environment in which to list secrets (e.g., "dev"). +- `secretPath` (str): The path to the secrets. +- `expandSecretReferences` (bool, optional): Whether to expand secret references. +- `viewSecretValue` (bool, optional): Whether or not to reveal the secret value of the secrets. If set to `false`, the `secretValue` is masked with ``. Defaults to `true`. +- `recursive` (bool, optional): Whether to list secrets recursively. +- `includeImports` (bool, optional): Whether to include imported secrets. +- `tagFilters` (string[], optional): Tags to filter secrets. -#### Returns (string) -`plaintext` (string): The decrypted plaintext. +**Returns:** +- `ListSecretsResponse`: The response containing the list of secrets. -*/} \ No newline at end of file +#### List secrets with imports + +The `listSecretsWithImports` method makes it easier to get all your secrets at once. The imported secrets will automatically be added to the secrets returned. The secrets in the selected environment will take precedence over the imported secrets. This means if you have secrets with conflicting names, the secret from the environment the import was imported into, will take precedence. + +```typescript +const allSecrets = await client.secrets().listSecretsWithImports({ + environment: "dev", + projectId: "", + expandSecretReferences: true, + viewSecretValue: true, + recursive: false, + secretPath: "/foo/bar" +}); +``` + +**Parameters:** +- `projectId` (string): The ID of your project. +- `environment` (string): The environment in which to list secrets (e.g., "dev"). +- `secretPath` (str): The path to the secrets. +- `expandSecretReferences` (bool, optional): Whether to expand secret references. +- `viewSecretValue` (bool, optional): Whether or not to reveal the secret value of the secrets. If set to `false`, the `secretValue` is masked with ``. Defaults to `true`. +- `recursive` (bool, optional): Whether to list secrets recursively. +- `tagFilters` (string[], optional): Tags to filter secrets. + +**Returns:** +- `Secret[]`: Returns the list of secrets objects, with imports. + + + +#### Create Secret + +```typescript + const newSecret = await client.secrets().createSecret("SECRET_NAME", { + environment: "dev", + projectId: "", + secretValue: "SECRET_VALUE", + secretComment: "This is a new secret", // Optional + secretPath: "/foo/bar", // Optional + secretReminderNote: "This is a reminder note", // Optional + secretReminderRepeatDays: 7, // Optional + skipMultilineEncoding: false, // Optional + tagIds: ["tagId1", "tagId2"], // Optional + type: "personal" // Optional + }); +``` + +**Parameters:** +- `secretName` (string): The name of the secret to create +- `options` (object): + - `projectId` (string): The ID of your project. + - `environment` (str): The environment in which to create the secret. + - `secretValue` (str): The value of the secret. + - `secretPath` (string, optional): The path to the secret. + - `secretComment` (str, optional): A comment associated with the secret. + - `skipMultilineEncoding` (bool, optional): Whether to skip encoding for multiline secrets. + - `secretReminderNote` (string, optional): A note for the secret reminder. + - `secretReminderRepeatDays` (number, optional): Number of days after which to repeat secret reminders. + - `tagIds` (string[], optional): Array of tags to assign to the new secret. + - `type` (personal | shared, optional): Which type of secret to create. + +**Returns:** +- `CreateSecretResponse`: The response after creating the secret. + +#### Update Secret + +```typescript +const updatedSecret = await client.secrets().updateSecret("SECRET_TO_UPDATE", { + environment: "dev", + projectId: "", + secretValue: "UPDATED_SECRET_VALUE", + newSecretName: "NEW_SECRET_NAME2", // Optional + secretComment: "This is an updated secret", // Optional + secretPath: "/foo/bar", // Optional + secretReminderNote: "This is an updated reminder note", // Optional + secretReminderRepeatDays: 14, // Optional + skipMultilineEncoding: false, // Optional + tagIds: ["tagId1", "tagId2"], // Optional + type: "personal", // Optional + metadata: { // Optional + extra: "metadata" + } +}); +``` + +**Parameters:** +- `secretName` (string): The name of the secret to update.` +- `options` (object): + - `environment` (str): The environment in which to update the secret. + - `projectId` (str): The ID of your project. + - `secretValue` (str, optional): The new value of the secret. + - `newSecretName` (str, optional): A new name for the secret. + - `secretComment` (str, optional): An updated comment associated with the secret. + - `secretPath` (str): The path to the secret. + - `secretReminderNote` (str, optional): An updated note for the secret reminder. + - `secretReminderRepeatDays` (number, optional): Updated number of days after which to repeat secret reminders. + - `skipMultilineEncoding` (bool, optional): Whether to skip encoding for multiline secrets. + - `tagIds` (string[], optional): Array of tags to assign to the secret. + - `type` (personal | shared, optional): Which type of secret to create. + - `metadata` (object, optional): Assign additional details to the secret, accessible through the API. + +**Returns:** +- `UpdateSecretResponse`: The response after updating the secret. + +#### Get Secret by Name + +```typescript + const singleSecret = await client.secrets().getSecret({ + environment: "dev", + projectId: "", + secretName: "DATABASE_URL", + expandSecretReferences: true, // Optional + viewSecretValue: true, // Optional + includeImports: true, // Optional + secretPath: "/foo/bar", // Optional + type: "shared", // Optional + version: 1 // Optional + }); +``` + +**Parameters:** +- `environment` (str): The environment in which to retrieve the secret. +- `projectId` (str): The ID of your project. +- `secretName` (str): The name of the secret. +- `secretPath` (str, optional): The path to the secret. +- `expandSecretReferences` (bool, optional): Whether to expand secret references. +- `viewSecretValue` (bool, optional): Whether or not to reveal the secret value of the secret. If set to `false`, the `secretValue` is masked with ``. Defaults to `true`. +- `includeImports` (bool): Whether to include imported secrets. +- `version` (str, optional): The version of the secret to retrieve. Fetches the latest by default. +- `type` (personal | shared, optional): The type of secret to fetch. + + +**Returns:** +- `Secret`: Returns the secret object. + +#### Delete Secret by Name + +```typescript +const deletedSecret = await client.secrets().deleteSecret("SECRET_TO_DELETE", { + environment: "dev", + projectId: "", + secretPath: "/foo/bar", // Optional + type: "personal" // Optional +}); +``` + +**Parameters:** +- `secretName` (string): The name of the secret to delete. +- `options` (object): + - `projectId` (str): The ID of your project. + - `environment` (str): The environment in which to delete the secret. + - `secret_path` (str, optional): The path to the secret. + - `type` (personal | shared, optional): The type of secret to delete. + +**Returns:** +- `DeleteSecretResponse`: The response after deleting the secret. + + + + +### `dynamicSecrets` + + +#### Create a new dynamic secret + +Creating a new dynamic secret can be done by using the `.dynamicSecrets().create({})` function. More details below. + + +The input for creating new dynamic secret varies greatly between secret types. +For a more in-depth description of each input type for each dynamic secret type, please refer to [our API documentation](https://infisical.com/docs/api-reference/endpoints/dynamic-secrets/create) + + +##### Example for creating a new Redis dynamic secret + +```typescript +import { InfisicalSDK, DynamicSecretProviders } from "@infisical/sdk"; + const client = new InfisicalSDK(); + + await client.auth().universalAuth.login({ + // For localhost + clientId: "CLIENT_ID", + clientSecret: "CLIENT_SECRET" + }); +const dynamicSecret = await client.dynamicSecrets().create({ + provider: { + type: DynamicSecretProviders.Redis, + inputs: { + host: "", + port: 6479, + username: "", + password: "", // Only required if your Redis instance uses authentication (recommended) + creationStatement: "ACL SETUSER {{username}} on >{{password}} ~* &* +@all", + revocationStatement: "ACL DELUSER {{username}}" + } + }, + defaultTTL: "1h", + maxTTL: "24h", + name: "dynamic-secret-name", + projectSlug: "project-slug", + environmentSlug: "dev" +}); +console.log(dynamicSecret); +``` + +**Returns:** +- `DynamicSecret`: The created dynamic secret. + + +#### Delete a dynamic secret + +Note: Deleting a dynamic secret will also delete it's associated leases. + +```typescript +const deletedDynamicSecret = await client.dynamicSecrets().delete("dynamic-secret-name", { + environmentSlug: "dev", + projectSlug: "project-slug" +}); +``` + +**Parameters:** +- `secretName` (string): The ID of the dynamic secret to delete +- `options` (object): + - `projectSlug` (str): The ID of your project. + - `environment` (str): The environment in which to delete the secret. + +**Returns:** +- `DynamicSecret`: The deleted dynamic secret. + +### `dynamicSecrets.leases` +In this section you'll learn how to work with dynamic secret leases + + +#### Create a new lease + +```typescript +const lease = await client.dynamicSecrets().leases.create({ + dynamicSecretName: "dynamic-secret-name", + environmentSlug: "dev", + projectSlug: "your-project-slug", + path: "/foo/bar", + ttl: "5m" // Optional +}); + +console.log(lease); +``` + +**Your dynamic secret credentials will be contained user `lease.data` in this example.** + +**Parameters:** +- `dynamicSecretName` (string): The name of the dynamic secret you wish to create a lease for. +- `projectSlug` (string): The slug of the project where the secret is located. +- `environmentSlug` (string): The environment where the dynamic secret is located. +- `path` (string, optional): The path of where the dynamic secret is located. +- `ttl` (string, optional): A [vercel/ms](https://github.com/vercel/ms) encoded string representation of how long the lease credentials should be valid for. This will default to the dynamic secret's default TTL if not specified. + +**Returns:** +- `CreateLeaseResponse`: The dynamic secret lease result. + + +#### Delete a lease +```typescript +const deletedLease = await client.dynamicSecrets().leases.delete(newLease.lease.id, { + environmentSlug: "dev", + projectSlug: "test-zb-3a", + path: "/foo/bar", + isForced: false // Whether or not to forcefully delete the lease. This can't guarantee that the lease will be deleted from the external provider. +}); +``` + +**Parameters:** +- `leaseId` (string): The ID of the lease you want to delete. +- options: + - `projectSlug` (string): The slug of the project where the secret is located. + - `environmentSlug` (string): The environment where the dynamic secret is located. + - `path` (string, optional): The path of where the dynamic secret is located. + - `isForced` (bool, optional): Whether or not to forcefully delete the lease. This can't guarantee that the lease will be deleted from the external provider, and is potentially unsafe for sensitive dynamic secrets. + +**Returns:** +- `DeleteLeaseResponse`: The deleted lease result. + +#### Renew a lease + +Please note that renewals must happen **before** the lease has fully expired. After renewing the lease, you won't be given new credentials. Instead the existing credentials will continue to live for the specified TTL + +```typescript +const renewedLease = await client.dynamicSecrets().leases.renew(newLease.lease.id, { + environmentSlug: "dev", + projectSlug: "project-slug", + path: "/foo/bar", // Optional + ttl: "10m" // Optional + }); +``` + +**Parameters:** +- `leaseId` (string): The ID of the lease you want to delete. +- `options` (object): + - `projectSlug` (string): The slug of the project where the secret is located. + - `environmentSlug` (string): The environment where the dynamic secret is located. + - `path` (string, optional): The path of where the dynamic secret is located. + - `ttl` (string, optional): A [vercel/ms](https://github.com/vercel/ms) encoded string representation of how long the lease credentials should be valid for. This will default to the dynamic secret's default TTL if not specified. + +**Returns:** +- `RenewLeaseResponse`: The renewed lease response _(doesn't contain new credentials)_. + +### `projects` + +#### Create a new project + +```typescript +const project = await client.projects().create({ + projectName: "", + type: "secret-manager", // cert-manager, secret-manager, kms, ssh + projectDescription: "", // Optional + slug: "", // Optional + template: "", // Optional + kmsKeyId: "kms-key-id" // Optional +}); +``` + +**Parameters:** +- `projectName` (string): The name of the project to create. +- `type` (string): The type of project to create. Valid options are `secret-manager`, `cert-manager`, `kms`, `ssh` +- `projectDescription` (string): An optional description of the project to create. +- `slug` (string): An optional slug for the project to create. If not provided, one will be generated automatically. +- `template` (string): Optionally provide a project template name to use for creating this project. +- `kmsKeyId` (string): The ID of the KMS key to use for the project. Will use the Infisical KMS by default. + +**Returns:** +- `Project`: The project that was created. + + +#### Invite members to a project + +When inviting members to projects, you must either specify the `emails` or `usernames`. If neither are specified, the SDK will throw an error. + +```typescript +const memberships = await client.projects().inviteMembers({ + projectId: project.id, + emails: ["test1@example.com", "test2@example.com"], // Optional + usernames: ["example-user3", "example-user4"] // Optional + roleSlugs: ["member"] // Optional +}); +``` + +**Parameters:** +- `projectId`: (string): The ID of the project to invite members to +- `emails`: (string[]): An array of emails of the users to invite to the project. +- `usernames`: (string[]) An array of usernames of the users to invite to the project. +- `roleSlugs`: (string[]): An array of role slugs to assign to the members. If not specified, this will default to `member`. + +**Returns:** +- `Membership[]`: An array of the created project memberships. + +### `environments` + +#### Create a new environment + +```typescript +const environment = await client.environments().create({ + name: "", + projectId: "", + slug: "", + position: 1 // Optional +}); +``` + +**Parameters:** +- `name` (string): The name of the environment to be created. +- `projectId` (string): The ID of the project to create the environment within. +- `slug`: (string): The slug of the environment to be created. +- `position` (number): An optional position of the environment to be created. The position is used in the Infisical UI to display environments in order. Environments with the lowest position come first. + +**Returns:** +- `Environment`: The environment that was created. + +#### Create a new folder + +```typescript +const folder = await client.folders().create({ + name: "", + path: "", + projectId: "", + environment: "", + description: "" // Optional +}); +``` + +**Parameters:** +- `name` (string): The name of the folder to create. +- `path` (string): The path where of where to create the folder. Defaults to `/`, which is the root folder. +- `projectId` (string): The ID of the project to create the folder within. +- `environment` (string): The slug of the environment to create the folder within. +- `description` (string): An optional folder description. + +**Returns:** +- `Folder`: The folder that was created. + +#### List folders + +```typescript +const folders = await client.folders().listFolders({ + environment: "dev", + projectId: "", + path: "/foo/bar", // Optional + recursive: false // Optional +}); +``` + +**Parameters:** +- `environment` (string): The slug of the environment to list folders within. +- `projectId` (string): The ID of the project to list folders within. +- `path` (string): The path to list folders within. Defaults to `/`, which is the root folder. +- `recursive` (boolean): An optional flag to list folders recursively. Defaults to `false`. + +**Returns:** +- `Folder[]`: An array of folders. \ No newline at end of file diff --git a/docs/sdks/languages/python.mdx b/docs/sdks/languages/python.mdx index f888c0819..066ecc060 100644 --- a/docs/sdks/languages/python.mdx +++ b/docs/sdks/languages/python.mdx @@ -1,533 +1,418 @@ --- title: "Infisical Python SDK" sidebarTitle: "Python" -url: "https://github.com/Infisical/python-sdk-official?tab=readme-ov-file#infisical-python-sdk" icon: "/images/sdks/languages/python.svg" --- -{/* If you're working with Python, the official [infisical-python](https://github.com/Infisical/sdk/edit/main/crates/infisical-py) package is the easiest way to fetch and work with secrets for your application. +If you're working with Python, the official Infisical Python SDK package is the easiest way to fetch and work with secrets for your application. -- [PyPi Package](https://pypi.org/project/infisical-python/) -- [Github Repository](https://github.com/Infisical/sdk/edit/main/crates/infisical-py) +### Migrating to version 1.0.3 or above -## Basic Usage +We have recently rolled out our first stable version of the SDK, version `1.0.3` and above. -```py -from flask import Flask -from infisical_client import ClientSettings, InfisicalClient, GetSecretOptions, AuthenticationOptions, UniversalAuthMethod +The 1.0.3 version comes with a few key changes that may change how you're using the SDK. +1. **Removal of `rest`**: The SDK no longer exposes the entire Infisical API. This was nessecary as we have moved away from using an OpenAPI generator approach. We aim to add support for more API resources in the near future. If you have any specific requests, please [open an issue](https://github.com/Infisical/python-sdk-official/issues). -app = Flask(__name__) +2. **New response types**: The 1.0.3 release uses return types that differ from the older versions. The new return types such as `BaseSecret`, are all exported from the Infisical SDK. -client = InfisicalClient(ClientSettings( - auth=AuthenticationOptions( - universal_auth=UniversalAuthMethod( - client_id="CLIENT_ID", - client_secret="CLIENT_SECRET", - ) - ) -)) +3. **Property renaming**: Some properties on the responses have been slightly renamed. An example of this would be that the `secret_key` property on the `get_secret_by_name()` method, that has been renamed to `secretKey`. -@app.route("/") -def hello_world(): - # access value +With this in mind, you're ready to upgrade your SDK version to `1.0.3` or above. - name = client.getSecret(options=GetSecretOptions( - environment="dev", - project_id="PROJECT_ID", - secret_name="NAME" - )) +You can refer to our [legacy documentation](https://github.com/Infisical/python-sdk-official/tree/9b0403938ee5ae599d42c5f1fdf9158671a15606?tab=readme-ov-file#infisical-python-sdk) if need be. - return f"Hello! My name is: {name.secret_value}" -``` +## Requirements -This example demonstrates how to use the Infisical Python SDK with a Flask application. The application retrieves a secret named "NAME" and responds to requests with a greeting that includes the secret value. - - - We do not recommend hardcoding your [Machine Identity Tokens](/platform/identities/overview). Setting it as an environment variable would be best. - +Python 3.7+ ## Installation -Run `pip` to add `infisical-python` to your project - -```console -$ pip install infisical-python +```bash +pip install infisicalsdk ``` -Note: You need Python 3.7+. +## Getting Started -## Configuration +```python +from infisical_sdk import InfisicalSDKClient -Import the SDK and create a client instance with your [Machine Identity](/api-reference/overview/authentication). +# Initialize the client +client = InfisicalSDKClient(host="https://app.infisical.com") -```py -from infisical_client import ClientSettings, InfisicalClient, AuthenticationOptions, UniversalAuthMethod +# Authenticate (example using Universal Auth) +client.auth.universal_auth.login( + client_id="", + client_secret="" +) -client = InfisicalClient(ClientSettings( - auth=AuthenticationOptions( - universal_auth=UniversalAuthMethod( - client_id="CLIENT_ID", - client_secret="CLIENT_SECRET", - ) - ) -)) +# Use the SDK to interact with Infisical +secrets = client.secrets.list_secrets(project_id="", environment_slug="dev", secret_path="/") ``` -#### Parameters +## InfisicalSDKClient Parameters - - - - Your Infisical Client ID. +The `InfisicalSDKClient` takes the following parameters, which are used as a global configuration for the lifetime of the SDK instance. - **This field is deprecated and will be removed in future versions.** Please use the `auth` field instead. - - - Your Infisical Client Secret. +- **host** (`str`, _Optional_): The host URL for your Infisical instance. Defaults to `https://app.infisical.com`. +- **token** (`str`, _Optional_): Specify an authentication token to use for all requests. If provided, you will not need to call any of the `auth` methods. Defaults to `None` +- **cache_ttl** (`int`, _Optional_): The SDK has built-in client-side caching for secrets, greatly improving response times. By default, secrets are cached for 1 minute (60 seconds). You can disable caching by setting `cache_ttl` to `None`, or adjust the duration in seconds as needed. - **This field is deprecated and will be removed in future versions.** Please use the `auth` field instead. - - - If you want to directly pass an access token obtained from the authentication endpoints, you can do so. +```python +client = InfisicalSDKClient( + host="https://app.infisical.com", # Defaults to https://app.infisical.com + token="", # If not set, use the client.auth() methods. + cache_ttl = 300 # `None` to disable caching +) +``` - **This field is deprecated and will be removed in future versions.** Please use the `auth` field instead. - +## Core Methods - - Time-to-live (in seconds) for refreshing cached secrets. - If manually set to 0, caching will be disabled, this is not recommended. - +The SDK methods are organized into the following high-level categories: - - Your self-hosted absolute site URL including the protocol (e.g. `https://app.infisical.com`) - +1. `auth`: Handles authentication methods. +2. `secrets`: Manages CRUD operations for secrets. +3. `kms`: Perform cryptographic operations with Infisical KMS. - - Optionally provide a path to a custom SSL certificate file. This can be substituted by setting the `INFISICAL_SSL_CERTIFICATE` environment variable to the contents of the certificate. - +### `auth` - - The authentication object to use for the client. This is required unless you're using environment variables. - - - - - -### Authentication - -The SDK supports a variety of authentication methods. The most common authentication method is Universal Auth, which uses a client ID and client secret to authenticate. +The `Auth` component provides methods for authentication: #### Universal Auth -**Using environment variables** -- `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID` - Your machine identity client ID. -- `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET` - Your machine identity client secret. - -**Using the SDK directly** -```python3 -from infisical_client import ClientSettings, InfisicalClient, AuthenticationOptions, UniversalAuthMethod - -client = InfisicalClient(ClientSettings( - auth=AuthenticationOptions( - universal_auth=UniversalAuthMethod( - client_id="CLIENT_ID", - client_secret="CLIENT_SECRET", - ) - ) -)) -``` - -#### GCP ID Token Auth - - Please note that this authentication method will only work if you're running your application on Google Cloud Platform. - Please [read more](/documentation/platform/identities/gcp-auth) about this authentication method. - - -**Using environment variables** -- `INFISICAL_GCP_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. - -**Using the SDK directly** -```py -from infisical_client import ClientSettings, InfisicalClient, AuthenticationOptions, GCPIDTokenAuthMethod - -client = InfisicalClient(ClientSettings( - auth=AuthenticationOptions( - gcp_id_token=GCPIDTokenAuthMethod( - identity_id="MACHINE_IDENTITY_ID", - ) - ) -)) -``` - -#### GCP IAM Auth - -**Using environment variables** -- `INFISICAL_GCP_IAM_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. -- `INFISICAL_GCP_IAM_SERVICE_ACCOUNT_KEY_FILE_PATH` - The path to your GCP service account key file. - -**Using the SDK directly** -```py -from infisical_client import ClientSettings, InfisicalClient, AuthenticationOptions, GCPIamAuthMethod - - -client = InfisicalClient(ClientSettings( - auth=AuthenticationOptions( - gcp_iam=GCPIamAuthMethod( - identity_id="MACHINE_IDENTITY_ID", - service_account_key_file_path="./path/to/service_account_key.json" - ) - ) -)) -``` - -#### AWS IAM Auth - - Please note that this authentication method will only work if you're running your application on AWS. - Please [read more](/documentation/platform/identities/aws-auth) about this authentication method. - - -**Using environment variables** -- `INFISICAL_AWS_IAM_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. - -**Using the SDK directly** -```py -from infisical_client import ClientSettings, InfisicalClient, AuthenticationOptions, AWSIamAuthMethod - -client = InfisicalClient(ClientSettings( - auth=AuthenticationOptions( - aws_iam=AWSIamAuthMethod(identity_id="MACHINE_IDENTITY_ID") - ) -)) -``` - -#### Azure Auth - - Please note that this authentication method will only work if you're running your application on Azure. - Please [read more](/documentation/platform/identities/azure-auth) about this authentication method. - - -**Using environment variables** -- `INFISICAL_AZURE_AUTH_IDENTITY_ID` - Your Infisical Machine Identity ID. - -**Using the SDK directly** ```python -from infisical_client import InfisicalClient, ClientSettings, AuthenticationOptions, AzureAuthMethod - -kubernetes_client = InfisicalClient(ClientSettings( - auth=AuthenticationOptions( - azure=AzureAuthMethod( - identity_id="YOUR_IDENTITY_ID", - ) - ) -)) +response = client.auth.universal_auth.login( + client_id="", + client_secret="" +) ``` +#### AWS Auth -#### Kubernetes Auth - - Please note that this authentication method will only work if you're running your application on Kubernetes. - Please [read more](/documentation/platform/identities/kubernetes-auth) about this authentication method. - - -**Using environment variables** -- `INFISICAL_KUBERNETES_IDENTITY_ID` - Your Infisical Machine Identity ID. -- `INFISICAL_KUBERNETES_SERVICE_ACCOUNT_TOKEN_PATH_ENV_NAME` - The environment variable name that contains the path to the service account token. This is optional and will default to `/var/run/secrets/kubernetes.io/serviceaccount/token`. - -**Using the SDK directly** ```python -from infisical_client import InfisicalClient, ClientSettings, AuthenticationOptions, KubernetesAuthMethod - -kubernetes_client = InfisicalClient(ClientSettings( - auth=AuthenticationOptions( - kubernetes=KubernetesAuthMethod( - identity_id="YOUR_IDENTITY_ID", - service_account_token_path="/var/run/secrets/kubernetes.io/serviceaccount/token" # Optional - ) - ) -)) +response = client.auth.aws_auth.login(identity_id="") ``` -### Caching +#### OIDC Auth -To reduce the number of API requests, the SDK temporarily stores secrets it retrieves. By default, a secret remains cached for 5 minutes after it's first fetched. Each time it's fetched again, this 5-minute timer resets. You can adjust this caching duration by setting the "cache_ttl" option when creating the client. - -## Working with Secrets - -### client.listSecrets(options) - -```py -client.listSecrets(options=ListSecretsOptions( - environment="dev", - project_id="PROJECT_ID" -)) -``` - -Retrieve all secrets within the Infisical project and environment that client is connected to - -#### Parameters - - - - - The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. - - - The project ID where the secret lives in. - - - - The path from where secrets should be fetched from. - - - - Whether or not to set the fetched secrets to the process environment. If true, you can access the secrets like so `process.env["SECRET_NAME"]`. - - - - Whether or not to fetch secrets recursively from the specified path. Please note that there's a 20-depth limit for recursive fetching. - - - - Whether or not to expand secret references in the fetched secrets. Read about [secret reference](/documentation/platform/secret-reference) - - - - Whether or not to include imported secrets from the current path. Read about [secret import](/documentation/platform/secret-reference) - - - - - -### client.getSecret(options) - -```py -secret = client.getSecret(options=GetSecretOptions( - environment="dev", - project_id="PROJECT_ID", - secret_name="API_KEY" -)) -value = secret.secret_value # get its value -``` - -By default, `getSecret()` fetches and returns a shared secret. If not found, it returns a personal secret. - -#### Parameters - - - - - The key of the secret to retrieve - - - Whether or not to include imported secrets from the current path. Read about [secret import](/documentation/platform/secret-reference) - - - The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. - - - The project ID where the secret lives in. - - - The path from where secret should be fetched from. - - - The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "personal". - - - Whether or not to include imported secrets from the current path. Read about [secret import](/documentation/platform/secret-reference) - - - Whether or not to expand secret references in the fetched secrets. Read about [secret reference](/documentation/platform/secret-reference) - - - - -### client.createSecret(options) - -```py -api_key = client.createSecret(options=CreateSecretOptions( - secret_name="API_KEY", - secret_value="Some API Key", - environment="dev", - project_id="PROJECT_ID" -)) -``` - -Create a new secret in Infisical. - -#### Parameters - - - - - The key of the secret to create. - - - The value of the secret. - - - The project ID where the secret lives in. - - - The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. - - - The path from where secret should be created. - - - The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". - - - - -### client.updateSecret(options) - -```py -client.updateSecret(options=UpdateSecretOptions( - secret_name="API_KEY", - secret_value="NEW_VALUE", - environment="dev", - project_id="PROJECT_ID" -)) -``` - -Update an existing secret in Infisical. - -#### Parameters - - - - - The key of the secret to update. - - - The new value of the secret. - - - The project ID where the secret lives in. - - - The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. - - - The path from where secret should be updated. - - - The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". - - - - -### client.deleteSecret(options) - -```py -client.deleteSecret(options=DeleteSecretOptions( - environment="dev", - project_id="PROJECT_ID", - secret_name="API_KEY" -)) -``` - -Delete a secret in Infisical. - -#### Parameters - - - - - The key of the secret to update. - - - The project ID where the secret lives in. - - - The slug name (dev, prod, etc) of the environment from where secrets should be fetched from. - - - The path from where secret should be deleted. - - - The type of the secret. Valid options are "shared" or "personal". If not specified, the default value is "shared". - - - - -## Cryptography - -### Create a symmetric key - -Create a base64-encoded, 256-bit symmetric key to be used for encryption/decryption. - -```py -key = client.createSymmetricKey() -``` - -#### Returns (string) - -`key` (string): A base64-encoded, 256-bit symmetric key, that can be used for encryption/decryption purposes. - -### Encrypt symmetric - -```py -encryptOptions = EncryptSymmetricOptions( - key=key, - plaintext="Infisical is awesome!" +```python +response = client.auth.oidc_auth.login( + identity_id="", + jwt="" ) - -encryptedData = client.encryptSymmetric(encryptOptions) ``` -#### Parameters +**Parameters:** +- `identity_id` (str): The ID of the OIDC identity configuration in Infisical. +- `jwt` (str): The OIDC JWT token obtained from your identity provider. - - - - The plaintext you want to encrypt. - - - The symmetric key to use for encryption. - - - +This authentication method is useful when integrating with OIDC-compliant identity providers like Okta, Auth0, or any service that issues OIDC tokens. -#### Returns (object) +### `secrets` -`tag` (string): A base64-encoded, 128-bit authentication tag. `iv` (string): A base64-encoded, 96-bit initialization vector. `ciphertext` (string): A base64-encoded, encrypted ciphertext. +This sub-class handles operations related to secrets: -### Decrypt symmetric +#### List Secrets -```py -decryptOptions = DecryptSymmetricOptions( - ciphertext=encryptedData.ciphertext, - iv=encryptedData.iv, - tag=encryptedData.tag, - key=key +```python +secrets = client.secrets.list_secrets( + project_id="", + environment_slug="dev", + secret_path="/", + expand_secret_references=True, # Optional + view_secret_value=True, # Optional + recursive=False, # Optional + include_imports=True, # Optional + tag_filters=[] # Optional ) - -decryptedString = client.decryptSymmetric(decryptOptions) - - ``` -#### Parameters +**Parameters:** +- `project_id` (str): The ID of your project. +- `project_slug` (str): The slug of your project. +- `environment_slug` (str): The environment in which to list secrets (e.g., "dev"). +- `secret_path` (str): The path to the secrets. +- `expand_secret_references` (bool): Whether to expand secret references. +- `view_secret_value` (bool): Whether or not to include the secret value in the response. If set to false, the `secretValue` will be masked with ``. Defaults to true. +- `recursive` (bool): Whether to list secrets recursively. +- `include_imports` (bool): Whether to include imported secrets. +- `tag_filters` (List[str]): Tags to filter secrets. - - - - The ciphertext you want to decrypt. - - - The symmetric key to use for encryption. - - - The initialization vector to use for decryption. - - - The authentication tag to use for decryption. - - - +**Note:** Exactly one of `project_id` or `project_slug` is required. If both are provided, `project_id` takes precedence. -#### Returns (string) +**Returns:** +- `ListSecretsResponse`: The response containing the list of secrets. -`plaintext` (string): The decrypted plaintext. */} +#### Create Secret + +```python +new_secret = client.secrets.create_secret_by_name( + secret_name="NEW_SECRET", + project_id="", + secret_path="/", + environment_slug="dev", + secret_value="secret_value", + secret_comment="Optional comment", + skip_multiline_encoding=False, + secret_reminder_repeat_days=30, # Optional + secret_reminder_note="Remember to update this secret", # Optional + secret_metadata=[{"key": "metadata_key", "value": "metadata_value"}], # Optional + tags_ids=["tag_id_1", "tag_id_2"] # Optional +) +``` + +**Parameters:** +- `secret_name` (str): The name of the secret. +- `project_id` (str): The ID of your project. +- `project_slug` (str): The slug of your project. +- `secret_path` (str): The path to the secret. +- `environment_slug` (str): The environment in which to create the secret. +- `secret_value` (str): The value of the secret. +- `secret_comment` (str, optional): A comment associated with the secret. +- `skip_multiline_encoding` (bool, optional): Whether to skip encoding for multiline secrets. +- `secret_reminder_repeat_days` (Union[float, int], optional): Number of days after which to repeat secret reminders. +- `secret_reminder_note` (str, optional): A note for the secret reminder. +- `secret_metadata` (List[Dict[str, Any]], optional): Metadata associated with the secret. +- `tags_ids` (List[str], optional): IDs of tags to associate with the secret. + +**Note:** Exactly one of `project_id` or `project_slug` is required. If both are provided, `project_id` takes precedence. + +**Returns:** +- `BaseSecret`: The response after creating the secret. + +#### Update Secret + +```python +updated_secret = client.secrets.update_secret_by_name( + current_secret_name="EXISTING_SECRET", + project_id="", + project_slug="", + secret_path="/", + environment_slug="dev", + secret_value="new_secret_value", + secret_comment="Updated comment", # Optional + skip_multiline_encoding=False, + secret_reminder_repeat_days=30, # Optional + secret_reminder_note="Updated reminder note", # Optional + new_secret_name="NEW_NAME", # Optional + secret_metadata=[{"key": "metadata_key", "value": "metadata_value"}], # Optional + tags_ids=["tag_id_1", "tag_id_2"] # Optional +) +``` + +**Parameters:** +- `current_secret_name` (str): The current name of the secret. +- `project_id` (str): The ID of your project. +- `project_slug` (str): The slug of your project. +- `secret_path` (str): The path to the secret. +- `environment_slug` (str): The environment in which to update the secret. +- `secret_value` (str, optional): The new value of the secret. +- `secret_comment` (str, optional): An updated comment associated with the secret. +- `skip_multiline_encoding` (bool, optional): Whether to skip encoding for multiline secrets. +- `secret_reminder_repeat_days` (Union[float, int], optional): Updated number of days after which to repeat secret reminders. +- `secret_reminder_note` (str, optional): An updated note for the secret reminder. +- `new_secret_name` (str, optional): A new name for the secret. +- `secret_metadata` (List[Dict[str, Any]], optional): Metadata associated with the secret. +- `tags_ids` (List[str], optional): IDs of tags to associate with the secret. + +**Note:** Exactly one of `project_id` or `project_slug` is required. If both are provided, `project_id` takes precedence. + +**Returns:** +- `BaseSecret`: The response after updating the secret. + +#### Get Secret by Name + +```python +secret = client.secrets.get_secret_by_name( + secret_name="EXISTING_SECRET", + project_id="", + environment_slug="dev", + secret_path="/", + expand_secret_references=True, # Optional + view_secret_value=True, # Optional + include_imports=True, # Optional + version=None # Optional +) +``` + +**Parameters:** +- `secret_name` (str): The name of the secret. +- `project_id` (str): The ID of your project. +- `project_slug` (str): The slug of your project. +- `environment_slug` (str): The environment in which to retrieve the secret. +- `secret_path` (str): The path to the secret. +- `expand_secret_references` (bool): Whether to expand secret references. +- `view_secret_value` (bool): Whether or not to include the secret value in the response. If set to false, the `secretValue` will be masked with ``. Defaults to true. +- `include_imports` (bool): Whether to include imported secrets. +- `version` (str, optional): The version of the secret to retrieve. Fetches the latest by default. + +**Note:** Exactly one of `project_id` or `project_slug` is required. If both are provided, `project_id` takes precedence. + +**Returns:** +- `BaseSecret`: The response containing the secret. + +#### Delete Secret by Name + +```python +deleted_secret = client.secrets.delete_secret_by_name( + secret_name="EXISTING_SECRET", + project_id="", + environment_slug="dev", + secret_path="/" +) +``` + +**Parameters:** +- `secret_name` (str): The name of the secret to delete. +- `project_id` (str): The ID of your project. +- `project_slug` (str): The slug of your project. +- `environment_slug` (str): The environment in which to delete the secret. +- `secret_path` (str): The path to the secret. + +**Note:** Exactly one of `project_id` or `project_slug` is required. If both are provided, `project_id` takes precedence. + +**Returns:** +- `BaseSecret`: The response after deleting the secret. + +### `kms` + +This sub-class handles KMS related operations: + +#### List KMS Keys + +```python +kms_keys = client.kms.list_keys( + project_id="", + offset=0, # Optional + limit=100, # Optional + order_by=KmsKeysOrderBy.NAME, # Optional + order_direction=OrderDirection.ASC, # Optional + search=None # Optional +) +``` + +**Parameters:** +- `project_id` (str): The ID of your project. +- `offset` (int, optional): The offset to paginate from. +- `limit` (int, optional): The page size for paginating. +- `order_by` (KmsKeysOrderBy, optional): The key property to order the list response by. +- `order_direction` (OrderDirection, optional): The direction to order the list response in. +- `search` (str, optional): The text value to filter key names by. + +**Returns:** +- `ListKmsKeysResponse`: The response containing the list of KMS keys. + +#### Get KMS Key by ID + +```python +kms_key = client.kms.get_key_by_id( + key_id="" +) +``` + +**Parameters:** +- `key_id` (str): The ID of the key to retrieve. + +**Returns:** +- `KmsKey`: The specified key. + +#### Get KMS Key by Name + +```python +kms_key = client.kms.get_key_by_name( + key_name="my-key", + project_id="" +) +``` + +**Parameters:** +- `key_name` (str): The name of the key to retrieve. +- `project_id` (str): The ID of your project. + +**Returns:** +- `KmsKey`: The specified key. + +#### Create KMS Key + +```python +kms_key = client.kms.create_key( + name="my-key", + project_id="", + encryption_algorithm=SymmetricEncryption.AES_GCM_256, + description=None # Optional +) +``` + +**Parameters:** +- `name` (str): The name of the key (must be slug-friendly). +- `project_id` (str): The ID of your project. +- `encryption_algorithm` (SymmetricEncryption): The encryption algorithm this key should use. +- `description` (str, optional): A description of your key. + +**Returns:** +- `KmsKey`: The newly created key. + +#### Update KMS Key + +```python +updated_key = client.kms.update_key( + key_id="", + name="my-updated-key", # Optional + description="Updated description", # Optional + is_disabled=True # Optional +) +``` + +**Parameters:** +- `key_id` (str): The ID of the key to be updated. +- `name` (str, optional): The updated name of the key (must be slug-friendly). +- `description` (str): The updated description of the key. +- `is_disabled` (str): The flag to disable operations with this key. + +**Returns:** +- `KmsKey`: The updated key. + +#### Delete KMS Key + +```python +deleted_key = client.kms.delete_key( + key_id="" +) +``` + +**Parameters:** +- `key_id` (str): The ID of the key to be deleted. + +**Returns:** +- `KmsKey`: The deleted key. + +#### Encrypt Data with KMS Key + +```python +encrypted_data = client.kms.encrypt_data( + key_id="", + base64EncodedPlaintext="TXkgc2VjcmV0IG1lc3NhZ2U=" # must be base64 encoded +) +``` + +**Parameters:** +- `key_id` (str): The ID of the key to encrypt the data with. +- `base64EncodedPlaintext` (str): The plaintext data to encrypt (must be base64 encoded). + +**Returns:** +- `str`: The encrypted ciphertext. + +#### Decrypt Data with KMS Key + +```python +decrypted_data = client.kms.decrypt_data( + key_id="", + ciphertext="Aq96Ry7sMH3k/ogaIB5MiSfH+LblQRBu69lcJe0GfIvI48ZvbWY+9JulyoQYdjAx" +) +``` + +**Parameters:** +- `key_id` (str): The ID of the key to decrypt the data with. +- `ciphertext` (str): The ciphertext returned from the encrypt operation. + +**Returns:** +- `str`: The base64 encoded plaintext. \ No newline at end of file diff --git a/docs/sdks/languages/ruby.mdx b/docs/sdks/languages/ruby.mdx index 5780c48ec..3fb9cb3e1 100644 --- a/docs/sdks/languages/ruby.mdx +++ b/docs/sdks/languages/ruby.mdx @@ -4,9 +4,7 @@ sidebarTitle: "Ruby" icon: "/images/sdks/languages/ruby.svg" --- - - -If you're working with Ruby, the official [Infisical Ruby SDK](https://github.com/infisical/sdk) package is the easiest way to fetch and work with secrets for your application. +If you're working with Ruby, the official Infisical Ruby SDK package is the easiest way to fetch and work with secrets for your application. - [Ruby Package](https://rubygems.org/gems/infisical-sdk) - [Github Repository](https://github.com/infisical/sdk) diff --git a/docs/sdks/languages/rust.mdx b/docs/sdks/languages/rust.mdx index 3d2f16175..40ad78bfb 100644 --- a/docs/sdks/languages/rust.mdx +++ b/docs/sdks/languages/rust.mdx @@ -2,5 +2,494 @@ title: "Infisical Rust SDK" sidebarTitle: "Rust" icon: "/images/sdks/languages/rust.svg" -url: "https://github.com/Infisical/rust-sdk?tab=readme-ov-file#infisical--the-official-infisical-rust-sdk" ---- \ No newline at end of file +--- + +If you're working with Rust, the official Infisical Rust SDK package is the easiest way to fetch and work with secrets for your application. + +### Installation + +```bash +cargo add infisical +``` + +### Getting Started + +The easiest way to get started is to use the builder pattern for both the client and your requests. + +```rust +use infisical::{AuthMethod, Client, InfisicalError, encode_base64, decode_base64}; +use infisical::secrets::GetSecretRequest; + +async fn fetch_secret() -> Result<(), InfisicalError> { + // 1. Build the client. You can chain methods to configure it. + let mut client = Client::builder() + .base_url("https://app.infisical.com") // Optional: defaults to https://app.infisical.com + .build() + .await?; + + // 2. Set up your authentication method and log in. + let auth_method = AuthMethod::new_universal_auth("", ""); + client.login(auth_method).await?; + + // 3. Build a request to get a secret. + // Required parameters (name, project_id, environment) are passed to `builder()`. + let request = GetSecretRequest::builder("API_KEY", "", "dev") + .path("/") // Optional parameters are set with builder methods. + .build(); + + // 4. Make the API call. + let secret = client.secrets().get(request).await?; + + println!("Fetched secret key: {}", secret.secret_key); + // For security, avoid printing the secret value in production code! + // println!("Secret value: {}", secret.secret_value); + + Ok(()) +} +``` + +### Client Configuration + +The `Client::builder()` provides several configuration options: + +```rust +let mut client = Client::builder() + .base_url("https://app.infisical.com") // Optional: set custom Infisical instance URL + .build() + .await?; +``` + +**Parameters** +- `.base_url(url)`: Optional method to set the Infisical instance URL. Defaults to `https://app.infisical.com` for Infisical Cloud. Use `https://eu.infisical.com` for EU and `http://localhost:8080` for local development. + +### Core Methods + +The SDK methods are organized into the following high-level categories: + +- `Client::builder()`: The main entry point for creating a client. +- `client.login()`: Allows client to make authenticated requests to the API. +- `client.secrets()`: Provides access to all CRUD operations for secrets. +- `client.kms()`: Provides access to all KMS (Key Management Service) operations. + +### Helper Functions + +The SDK provides utility functions for common operations: + +```rust +use infisical::{encode_base64, decode_base64}; + +// Base64 encode a string +let encoded = encode_base64("sensitive data"); +println!("Encoded: {}", encoded); + +// Base64 decode a string +let decoded = decode_base64(&encoded)?; +println!("Decoded: {}", decoded); +``` + +**Available Functions** +- `encode_base64(data: &str) -> String`: Encodes a string as base64 +- `decode_base64(data: &str) -> Result`: Decodes a base64 string + +### `secrets` + +All secret operations are accessed via `client.secrets()`. Each operation has a dedicated request builder. + +#### Create Secret + +Create a new secret in your project. + +**Example** + +```rust +use infisical::secrets::CreateSecretRequest; + +let request = CreateSecretRequest::builder( + "API_KEY", + "your-secret-value", + "", + "dev" +) +.path("/") +.secret_comment("A comment for the new secret") +.build(); + +let created_secret = client.secrets().create(request).await?; +``` + +**Parameters** + +- `secret_name`, `secret_value`, `project_id`, `environment`: Required parameters passed to the `builder()` function. +- `.path(path)`: Optional method to set the secret's path (defaults to `/`). +- `.secret_comment(comment)`: Optional method to add a comment. +- `.skip_multiline_encoding(bool)`: Optional method to control multiline encoding (defaults to `false`). +- `.r#type(type)`: Optional method to set the secret type (`shared` or `personal`), defaults to `shared`. + +#### Get Secret + +Retrieve a specific secret by name. + +**Example** + +```rust +use infisical::secrets::GetSecretRequest; + +let request = GetSecretRequest::builder("API_KEY", "", "dev") + .path("/") + .build(); + +let secret = client.secrets().get(request).await?; +``` + +**Parameters** + +- `secret_name`, `project_id`, `environment`: Required parameters passed to the `builder()` function. +- `.path(path)`: Optional method to set the secret's path (defaults to `/`). +- `.expand_secret_references(bool)`: Optional method to control secret reference expansion (defaults to `true`). +- `.r#type(type)`: Optional method to set the secret type (`shared` or `personal`), defaults to `shared`. + +#### List Secrets + +List all secrets in a project and environment. + +**Example** + +```rust +use infisical::secrets::ListSecretsRequest; + +let request = ListSecretsRequest::builder("", "dev") + .path("/") + .recursive(true) + .build(); + +let secrets = client.secrets().list(request).await?; +``` + +**Parameters** + +- `project_id`, `environment`: Required parameters passed to the `builder()` function. +- `.path(path)`: Optional method to set the path from which to list secrets (defaults to `/`). +- `.expand_secret_references(bool)`: Optional method to control secret reference expansion (defaults to `true`). +- `.recursive(bool)`: Optional method to recursively list secrets from sub-folders (defaults to `false`). +- `.attach_to_process_env(bool)`: Optional method to attach fetched secrets to the current process's environment variables (defaults to `false`). + +#### Update Secret + +Update an existing secret. + +**Example** + +```rust +use infisical::secrets::UpdateSecretRequest; + +let request = UpdateSecretRequest::builder("API_KEY", "", "dev") + .secret_value("new-secret-value") // Set the new value + .build(); + +let updated_secret = client.secrets().update(request).await?; +``` + +**Parameters** + +- `secret_name`, `project_id`, `environment`: Required parameters passed to the `builder()` function. +- `.new_secret_name(name)`: Optional method to rename the secret. +- `.secret_value(value)`: Optional method to set a new value for the secret. +- `.path(path)`: Optional method to set the secret's path. +- `.secret_comment(comment)`: Optional method to add or change the comment. +- `.skip_multiline_encoding(bool)`: Optional method to control multiline encoding. +- `.r#type(type)`: Optional method to set the secret type (`shared` or `personal`). + +#### Delete Secret + +Delete a secret from your project. + +**Example** + +```rust +use infisical::secrets::DeleteSecretRequest; + +let request = DeleteSecretRequest::builder("API_KEY", "", "dev") + .path("/") + .build(); + +let deleted_secret = client.secrets().delete(request).await?; +``` + +**Parameters** + +- `secret_name`, `project_id`, `environment`: Required parameters passed to the `builder()` function. +- `.path(path)`: Optional method to set the secret's path (defaults to `/`). +- `.r#type(type)`: Optional method to set the secret type (`shared` or `personal`), defaults to `shared`. + +### `kms` + +All KMS (Key Management Service) operations are accessed via `client.kms()`. Each operation has a dedicated request builder. + +#### List KMS Keys + +List all KMS keys in a project. + +**Example** + +```rust +use infisical::kms::ListKmsKeysRequest; + +let request = ListKmsKeysRequest::builder("").build(); + +let keys = client.kms().list(request).await?; +``` + +**Parameters** + +- `project_id`: Required parameter passed to the `builder()` function. + +#### Get KMS Key + +Retrieve a specific KMS key by ID. + +**Example** + +```rust +use infisical::kms::GetKmsKeyRequest; + +let request = GetKmsKeyRequest::builder("").build(); + +let key = client.kms().get(request).await?; +``` + +**Parameters** + +- `key_id`: Required parameter passed to the `builder()` function. + +#### Get KMS Key by Name + +Retrieve a specific KMS key by name. + +**Example** + +```rust +use infisical::kms::GetKmsKeyByNameRequest; + +let request = GetKmsKeyByNameRequest::builder("").build(); + +let key = client.kms().get_by_name(request).await?; +``` + +**Parameters** + +- `key_name`: Required parameter passed to the `builder()` function. + +#### Create KMS Key + +Create a new KMS key in your project. + +**Example** + +```rust +use infisical::kms::{CreateKmsKeyRequest, EncryptionAlgorithm, KeyUsage}; + +let request = CreateKmsKeyRequest::builder("", "my-key") + .description("A key for encryption operations") + .key_usage(KeyUsage::EncryptDecrypt) + .encryption_algorithm(EncryptionAlgorithm::Aes256Gcm) + .build(); + +let created_key = client.kms().create(request).await?; +``` + +**Parameters** + +- `project_id`, `name`: Required parameters passed to the `builder()` function. +- `.description(description)`: Optional method to set the key description. +- `.key_usage(usage)`: Optional method to set the key usage using the `KeyUsage` enum (defaults to `KeyUsage::EncryptDecrypt`). +- `.encryption_algorithm(algorithm)`: Optional method to set the encryption algorithm using the `EncryptionAlgorithm` enum (defaults to `EncryptionAlgorithm::Aes256Gcm`). + +#### Update KMS Key + +Update an existing KMS key. + +**Example** + +```rust +use infisical::kms::UpdateKmsKeyRequest; + +let request = UpdateKmsKeyRequest::builder("") + .name("updated-key-name") + .description("Updated description") + .is_disabled(false) + .build(); + +let updated_key = client.kms().update(request).await?; +``` + +**Parameters** + +- `key_id`: Required parameter passed to the `builder()` function. +- `.name(name)`: Optional method to rename the key. +- `.description(description)`: Optional method to update the key description. +- `.is_disabled(disabled)`: Optional method to enable or disable the key. + +#### Delete KMS Key + +Delete a KMS key from your project. + +**Example** + +```rust +use infisical::kms::DeleteKmsKeyRequest; + +let request = DeleteKmsKeyRequest::builder("").build(); + +let deleted_key = client.kms().delete(request).await?; +``` + +**Parameters** + +- `key_id`: Required parameter passed to the `builder()` function. + +#### Encrypt Data + +Encrypt data using a KMS key. + +**Example** + +```rust +use infisical::kms::EncryptRequest; + +let request = EncryptRequest::builder("", "sensitive data").build(); + +let ciphertext = client.kms().encrypt(request).await?; +``` + +**Parameters** + +- `key_id`, `plaintext`: Required parameters passed to the `builder()` function. + +#### Decrypt Data + +Decrypt data using a KMS key. + +**Example** + +```rust +use infisical::kms::DecryptRequest; + +let request = DecryptRequest::builder("", "encrypted-data").build(); + +let plaintext = client.kms().decrypt(request).await?; +``` + +**Parameters** + +- `key_id`, `ciphertext`: Required parameters passed to the `builder()` function. + +#### Sign Data + +Sign data using a KMS key. + +**Example** + +```rust +use infisical::kms::{SigningAlgorithm, SignRequest}; + +let request = SignRequest::builder("", "data to sign") + .signing_algorithm(SigningAlgorithm::RsassaPkcs1V15Sha256) + .is_digest(false) + .build(); + +let signature = client.kms().sign(request).await?; +``` + +**Parameters** + +- `key_id`, `data`: Required parameters passed to the `builder()` function. +- `.signing_algorithm(algorithm)`: Optional method to set the signing algorithm using the `SigningAlgorithm` enum (defaults to `SigningAlgorithm::RsassaPkcs1V15Sha256`). +- `.is_digest(is_digest)`: Optional method to indicate if the data is a digest (defaults to `false`). + +#### Verify Signature + +Verify a signature using a KMS key. + +**Example** + +```rust +use infisical::kms::{SigningAlgorithm, VerifyRequest}; + +let request = VerifyRequest::builder("", "data to sign", "signature") + .signing_algorithm(SigningAlgorithm::RsassaPkcs1V15Sha256) + .is_digest(false) + .build(); + +let verification = client.kms().verify(request).await?; +``` + +**Parameters** + +- `key_id`, `data`, `signature`: Required parameters passed to the `builder()` function. +- `.signing_algorithm(algorithm)`: Optional method to set the signing algorithm using the `SigningAlgorithm` enum (defaults to `SigningAlgorithm::RsassaPkcs1V15Sha256`). +- `.is_digest(is_digest)`: Optional method to indicate if the data is a digest (defaults to `false`). + +#### Get Public Key + +Get the public key for a KMS key. + +**Example** + +```rust +let public_key = client.kms().get_public_key("").await?; +``` + +**Parameters** + +- `key_id`: The ID of the key to get the public key for. + +#### Get Signing Algorithms + +Get the available signing algorithms for a KMS key. + +**Example** + +```rust +let algorithms = client.kms().get_signing_algorithms("").await?; +``` + +**Parameters** + +- `key_id`: The ID of the key to get signing algorithms for. + +## Development and Testing + +### Environment Setup + +For development and testing, you'll need to set up environment variables. Create a `.env` file in your project root: + +```env +INFISICAL_CLIENT_ID=your_client_id_here +INFISICAL_CLIENT_SECRET=your_client_secret_here +INFISICAL_BASE_URL=http://localhost:8080 # Optional: for local development + +# Project IDs for different resources +INFISICAL_SECRETS_PROJECT_ID=your_project_id_here +INFISICAL_KMS_PROJECT_ID=your_project_id_here +``` + +### Getting Credentials + +To obtain the required credentials: + +1. **Client ID and Secret**: Create a Universal Auth machine identity in your Infisical project settings +2. **Project ID**: Found in your project settings or URL when viewing a project in the Infisical dashboard + +### Running Tests + +Tests that require authentication are marked with `#[ignore]` and need valid credentials: + +```bash +# Run ignored tests (requires .env file with valid credentials) +cargo test -- --ignored --nocapture + +# Run a specific test +cargo test test_kms_resource -- --ignored --nocapture +``` + +**Note**: Integration tests require a running Infisical instance and valid authentication credentials. \ No newline at end of file diff --git a/docs/sdks/overview.mdx b/docs/sdks/overview.mdx index 32192805d..0b6949108 100644 --- a/docs/sdks/overview.mdx +++ b/docs/sdks/overview.mdx @@ -12,22 +12,22 @@ From local development to production, Infisical SDKs provide the easiest way for - Fetch secrets on demand - + Manage secrets for your Node application on demand - + Manage secrets for your Python application on demand - + Manage secrets for your Java application on demand - + Manage secrets for your .NET application on demand - + Manage secrets for your C++ application on demand - + Manage secrets for your Rust application on demand diff --git a/docs/self-hosting/guides/monitoring-telemetry.mdx b/docs/self-hosting/guides/monitoring-telemetry.mdx new file mode 100644 index 000000000..1c2d05702 --- /dev/null +++ b/docs/self-hosting/guides/monitoring-telemetry.mdx @@ -0,0 +1,440 @@ +--- +title: "Monitoring and Telemetry Setup" +description: "Learn how to set up monitoring and telemetry for your self-hosted Infisical instance using Grafana, Prometheus, and OpenTelemetry." +--- + +Infisical provides comprehensive monitoring and telemetry capabilities to help you monitor the health, performance, and usage of your self-hosted instance. This guide covers setting up monitoring using Grafana with two different telemetry collection approaches. + +## Overview + +Infisical exports metrics in **OpenTelemetry (OTEL) format**, which provides maximum flexibility for your monitoring infrastructure. While this guide focuses on Grafana, the OTEL format means you can easily integrate with: + +- **Cloud-native monitoring**: AWS CloudWatch, Google Cloud Monitoring, Azure Monitor +- **Observability platforms**: Datadog, New Relic, Splunk, Dynatrace +- **Custom backends**: Any system that supports OTEL ingestion +- **Traditional monitoring**: Prometheus, Grafana (as covered in this guide) + +Infisical supports two telemetry collection methods: + +1. **Pull-based (Prometheus)**: Exposes metrics on a dedicated endpoint for Prometheus to scrape +2. **Push-based (OTLP)**: Sends metrics to an OpenTelemetry Collector via OTLP protocol + +Both approaches provide the same metrics data in OTEL format, so you can choose the one that best fits your infrastructure and monitoring strategy. + +## Prerequisites + +- Self-hosted Infisical instance running +- Access to deploy monitoring services (Prometheus, Grafana, etc.) +- Basic understanding of Prometheus and Grafana + +## Environment Variables + +Configure the following environment variables in your Infisical backend: + +```bash +# Enable telemetry collection +OTEL_TELEMETRY_COLLECTION_ENABLED=true + +# Choose export type: "prometheus" or "otlp" +OTEL_EXPORT_TYPE=prometheus + +# For OTLP push mode, also configure: +# OTEL_EXPORT_OTLP_ENDPOINT=http://otel-collector:4318/v1/metrics +# OTEL_COLLECTOR_BASIC_AUTH_USERNAME=your_collector_username +# OTEL_COLLECTOR_BASIC_AUTH_PASSWORD=your_collector_password +# OTEL_OTLP_PUSH_INTERVAL=30000 +``` + +**Note**: The `OTEL_COLLECTOR_BASIC_AUTH_USERNAME` and `OTEL_COLLECTOR_BASIC_AUTH_PASSWORD` values must match the credentials configured in your OpenTelemetry Collector's `basicauth/server` extension. These are not hardcoded values - you configure them in your collector configuration file. + +## Option 1: Pull-based Monitoring (Prometheus) + +This approach exposes metrics on port 9464 at the `/metrics` endpoint, allowing Prometheus to scrape the data. The metrics are exposed in Prometheus format but originate from OpenTelemetry instrumentation. + +### Configuration + +1. **Enable Prometheus export in Infisical**: + + ```bash + OTEL_TELEMETRY_COLLECTION_ENABLED=true + OTEL_EXPORT_TYPE=prometheus + ``` + +2. **Expose the metrics port** in your Infisical backend: + + - **Docker**: Expose port 9464 + - **Kubernetes**: Create a service exposing port 9464 + - **Other**: Ensure port 9464 is accessible to your monitoring stack + +3. **Create Prometheus configuration** (`prometheus.yml`): + + ```yaml + global: + scrape_interval: 30s + evaluation_interval: 30s + + scrape_configs: + - job_name: "infisical" + scrape_interval: 30s + static_configs: + - targets: ["infisical-backend:9464"] # Adjust hostname/port based on your deployment + metrics_path: "/metrics" + ``` + + **Note**: Replace `infisical-backend:9464` with the actual hostname and port where your Infisical backend is running. This could be: + + - **Docker Compose**: `infisical-backend:9464` (service name) + - **Kubernetes**: `infisical-backend.default.svc.cluster.local:9464` (service name) + - **Bare Metal**: `192.168.1.100:9464` (actual IP address) + - **Cloud**: `your-infisical.example.com:9464` (domain name) + +### Deployment Options + +#### Docker Compose + +```yaml +services: + prometheus: + image: prom/prometheus:latest + ports: + - "9090:9090" + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro + command: + - "--config.file=/etc/prometheus/prometheus.yml" + + grafana: + image: grafana/grafana:latest + ports: + - "3000:3000" + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=admin +``` + +#### Kubernetes + +```yaml +# prometheus-deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: prometheus +spec: + replicas: 1 + selector: + matchLabels: + app: prometheus + template: + metadata: + labels: + app: prometheus + spec: + containers: + - name: prometheus + image: prom/prometheus:latest + ports: + - containerPort: 9090 + volumeMounts: + - name: config + mountPath: /etc/prometheus + volumes: + - name: config + configMap: + name: prometheus-config + +--- +# prometheus-service.yaml +apiVersion: v1 +kind: Service +metadata: + name: prometheus +spec: + selector: + app: prometheus + ports: + - port: 9090 + targetPort: 9090 + type: ClusterIP +``` + +#### Helm + +```bash +helm repo add prometheus-community https://prometheus-community.github.io/helm-charts +helm install prometheus prometheus-community/prometheus \ + --set server.config.global.scrape_interval=30s \ + --set server.config.scrape_configs[0].job_name=infisical \ + --set server.config.scrape_configs[0].static_configs[0].targets[0]=infisical-backend:9464 +``` + +## Option 2: Push-based Monitoring (OTLP) + +This approach sends metrics directly to an OpenTelemetry Collector via the OTLP protocol. This gives you the most flexibility as you can configure the collector to export to multiple backends simultaneously. + +### Configuration + +1. **Enable OTLP export in Infisical**: + + ```bash + OTEL_TELEMETRY_COLLECTION_ENABLED=true + OTEL_EXPORT_TYPE=otlp + OTEL_EXPORT_OTLP_ENDPOINT=http://otel-collector:4318/v1/metrics + OTEL_COLLECTOR_BASIC_AUTH_USERNAME=infisical + OTEL_COLLECTOR_BASIC_AUTH_PASSWORD=infisical + OTEL_OTLP_PUSH_INTERVAL=30000 + ``` + +2. **Create OpenTelemetry Collector configuration** (`otel-collector-config.yaml`): + + ```yaml + extensions: + health_check: + pprof: + zpages: + basicauth/server: + htpasswd: + inline: | + your_username:your_password + + receivers: + otlp: + protocols: + http: + endpoint: 0.0.0.0:4318 + auth: + authenticator: basicauth/server + + prometheus: + config: + scrape_configs: + - job_name: otel-collector + scrape_interval: 30s + static_configs: + - targets: [infisical-backend:9464] + metric_relabel_configs: + - action: labeldrop + regex: "service_instance_id|service_name" + + processors: + batch: + + exporters: + prometheus: + endpoint: "0.0.0.0:8889" + auth: + authenticator: basicauth/server + resource_to_telemetry_conversion: + enabled: true + + service: + extensions: [basicauth/server, health_check, pprof, zpages] + pipelines: + metrics: + receivers: [otlp] + processors: [batch] + exporters: [prometheus] + ``` + + **Important**: Replace `your_username:your_password` with your chosen credentials. These must match the values you set in Infisical's `OTEL_COLLECTOR_BASIC_AUTH_USERNAME` and `OTEL_COLLECTOR_BASIC_AUTH_PASSWORD` environment variables. + +3. **Create Prometheus configuration** for the collector: + + ```yaml + global: + scrape_interval: 30s + evaluation_interval: 30s + + scrape_configs: + - job_name: "otel-collector" + scrape_interval: 30s + static_configs: + - targets: ["otel-collector:8889"] # Adjust hostname/port based on your deployment + metrics_path: "/metrics" + ``` + + **Note**: Replace `otel-collector:8889` with the actual hostname and port where your OpenTelemetry Collector is running. This could be: + + - **Docker Compose**: `otel-collector:8889` (service name) + - **Kubernetes**: `otel-collector.default.svc.cluster.local:8889` (service name) + - **Bare Metal**: `192.168.1.100:8889` (actual IP address) + - **Cloud**: `your-collector.example.com:8889` (domain name) + +### Deployment Options + +#### Docker Compose + +```yaml +services: + otel-collector: + image: otel/opentelemetry-collector-contrib:latest + ports: + - 4318:4318 # OTLP http receiver + - 8889:8889 # Prometheus exporter metrics + volumes: + - ./otel-collector-config.yaml:/etc/otelcol-contrib/config.yaml:ro + command: + - "--config=/etc/otelcol-contrib/config.yaml" +``` + +#### Kubernetes + +```yaml +# otel-collector-deployment.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: otel-collector +spec: + replicas: 1 + selector: + matchLabels: + app: otel-collector + template: + metadata: + labels: + app: otel-collector + spec: + containers: + - name: otel-collector + image: otel/opentelemetry-collector-contrib:latest + ports: + - containerPort: 4318 + - containerPort: 8889 + volumeMounts: + - name: config + mountPath: /etc/otelcol-contrib + volumes: + - name: config + configMap: + name: otel-collector-config +``` + +#### Helm + +```bash +helm repo add open-telemetry https://open-telemetry.github.io/opentelemetry-helm-charts +helm install otel-collector open-telemetry/opentelemetry-collector \ + --set config.receivers.otlp.protocols.http.endpoint=0.0.0.0:4318 \ + --set config.exporters.prometheus.endpoint=0.0.0.0:8889 +``` + +## Alternative Backends + +Since Infisical exports in OpenTelemetry format, you can easily configure the collector to send metrics to other backends instead of (or in addition to) Prometheus: + +### Cloud-Native Examples + +```yaml +# Add to your otel-collector-config.yaml exporters section +exporters: + # AWS CloudWatch + awsemf: + region: us-west-2 + log_group_name: /aws/emf/infisical + log_stream_name: metrics + + # Google Cloud Monitoring + googlecloud: + project_id: your-project-id + + # Azure Monitor + azuremonitor: + connection_string: "your-connection-string" + + # Datadog + datadog: + api: + key: "your-api-key" + site: "datadoghq.com" + + # New Relic + newrelic: + apikey: "your-api-key" + host_override: "otlp.nr-data.net" +``` + +### Multi-Backend Configuration + +```yaml +service: + pipelines: + metrics: + receivers: [otlp] + processors: [batch] + exporters: [prometheus, awsemf, datadog] # Send to multiple backends +``` + +## Setting Up Grafana + +1. **Access Grafana**: Navigate to your Grafana instance +2. **Login**: Use your configured credentials +3. **Add Prometheus Data Source**: + - Go to Configuration → Data Sources + - Click "Add data source" + - Select "Prometheus" + - Set URL to your Prometheus endpoint + - Click "Save & Test" + +## Available Metrics + +Infisical exposes the following key metrics in OpenTelemetry format: + +### API Performance Metrics + +- `API_latency` - API request latency histogram in milliseconds + + - **Labels**: `route`, `method`, `statusCode` + - **Example**: Monitor response times for specific endpoints + +- `API_errors` - API error count histogram + - **Labels**: `route`, `method`, `type`, `name` + - **Example**: Track error rates by endpoint and error type + +### Integration & Secret Sync Metrics + +- `integration_secret_sync_errors` - Integration secret sync error count + + - **Labels**: `version`, `integration`, `integrationId`, `type`, `status`, `name`, `projectId` + - **Example**: Monitor integration sync failures across different services + +- `secret_sync_sync_secrets_errors` - Secret sync operation error count + + - **Labels**: `version`, `destination`, `syncId`, `projectId`, `type`, `status`, `name` + - **Example**: Track secret sync failures to external systems + +- `secret_sync_import_secrets_errors` - Secret import operation error count + + - **Labels**: `version`, `destination`, `syncId`, `projectId`, `type`, `status`, `name` + - **Example**: Monitor secret import failures + +- `secret_sync_remove_secrets_errors` - Secret removal operation error count + - **Labels**: `version`, `destination`, `syncId`, `projectId`, `type`, `status`, `name` + - **Example**: Track secret removal operation failures + +### System Metrics + +These metrics are automatically collected by OpenTelemetry's HTTP instrumentation: + +- `http_server_duration` - HTTP server request duration metrics (histogram buckets, count, sum) +- `http_client_duration` - HTTP client request duration metrics (histogram buckets, count, sum) + +### Custom Business Metrics + +- `infisical_secret_operations_total` - Total secret operations +- `infisical_secrets_processed_total` - Total secrets processed + +## Troubleshooting + +### Common Issues + +1. **Metrics not appearing**: + + - Check if `OTEL_TELEMETRY_COLLECTION_ENABLED=true` + - Verify the correct `OTEL_EXPORT_TYPE` is set + - Check network connectivity between services + +2. **Authentication errors**: + + - Verify basic auth credentials in OTLP configuration + - Check if credentials match between Infisical and collector diff --git a/docs/snippets/AppConnectionsBrowser.jsx b/docs/snippets/AppConnectionsBrowser.jsx new file mode 100644 index 000000000..50aec75b3 --- /dev/null +++ b/docs/snippets/AppConnectionsBrowser.jsx @@ -0,0 +1,155 @@ +import React, { useState, useMemo } from 'react'; + +export const AppConnectionsBrowser = () => { + const [searchTerm, setSearchTerm] = useState(''); + const [selectedCategory, setSelectedCategory] = useState('All'); + + const categories = ['All', 'Cloud Providers', 'Databases', 'CI/CD', 'Monitoring', 'Directory Services', 'Identity & Auth']; + + const connections = [ + {"name": "AWS", "slug": "aws", "path": "/integrations/app-connections/aws", "description": "Learn how to connect your AWS applications to pull secrets from Infisical.", "category": "Cloud Providers"}, + {"name": "Azure Key Vault", "slug": "azure-key-vault", "path": "/integrations/app-connections/azure-key-vault", "description": "Learn how to connect your Azure Key Vault to pull secrets from Infisical.", "category": "Cloud Providers"}, + {"name": "Azure App Configuration", "slug": "azure-app-configuration", "path": "/integrations/app-connections/azure-app-configuration", "description": "Learn how to connect your Azure App Configuration to pull secrets from Infisical.", "category": "Cloud Providers"}, + {"name": "Azure Client Secrets", "slug": "azure-client-secrets", "path": "/integrations/app-connections/azure-client-secrets", "description": "Learn how to connect your Azure Client Secrets to pull secrets from Infisical.", "category": "Cloud Providers"}, + {"name": "Azure DevOps", "slug": "azure-devops", "path": "/integrations/app-connections/azure-devops", "description": "Learn how to connect your Azure DevOps to pull secrets from Infisical.", "category": "CI/CD"}, + {"name": "Azure ADCS", "slug": "azure-adcs", "path": "/integrations/app-connections/azure-adcs", "description": "Learn how to connect your Azure ADCS to pull secrets from Infisical.", "category": "Cloud Providers"}, + {"name": "GCP", "slug": "gcp", "path": "/integrations/app-connections/gcp", "description": "Learn how to connect your GCP applications to pull secrets from Infisical.", "category": "Cloud Providers"}, + {"name": "HashiCorp Vault", "slug": "hashicorp-vault", "path": "/integrations/app-connections/hashicorp-vault", "description": "Learn how to connect your HashiCorp Vault to pull secrets from Infisical.", "category": "Cloud Providers"}, + {"name": "1Password", "slug": "1password", "path": "/integrations/app-connections/1password", "description": "Learn how to connect your 1Password to pull secrets from Infisical.", "category": "Cloud Providers"}, + {"name": "Vercel", "slug": "vercel", "path": "/integrations/app-connections/vercel", "description": "Learn how to connect your Vercel application to pull secrets from Infisical.", "category": "Cloud Providers"}, + {"name": "Netlify", "slug": "netlify", "path": "/integrations/app-connections/netlify", "description": "Learn how to connect your Netlify application to pull secrets from Infisical.", "category": "Cloud Providers"}, + {"name": "Railway", "slug": "railway", "path": "/integrations/app-connections/railway", "description": "Learn how to connect your Railway application to pull secrets from Infisical.", "category": "Cloud Providers"}, + {"name": "Fly.io", "slug": "flyio", "path": "/integrations/app-connections/flyio", "description": "Learn how to connect your Fly.io application to pull secrets from Infisical.", "category": "Cloud Providers"}, + {"name": "Render", "slug": "render", "path": "/integrations/app-connections/render", "description": "Learn how to connect your Render application to pull secrets from Infisical.", "category": "Cloud Providers"}, + {"name": "Heroku", "slug": "heroku", "path": "/integrations/app-connections/heroku", "description": "Learn how to connect your Heroku application to pull secrets from Infisical.", "category": "Cloud Providers"}, + {"name": "DigitalOcean", "slug": "digital-ocean", "path": "/integrations/app-connections/digital-ocean", "description": "Learn how to connect your DigitalOcean application to pull secrets from Infisical.", "category": "Cloud Providers"}, + {"name": "Supabase", "slug": "supabase", "path": "/integrations/app-connections/supabase", "description": "Learn how to connect your Supabase application to pull secrets from Infisical.", "category": "Databases"}, + {"name": "Checkly", "slug": "checkly", "path": "/integrations/app-connections/checkly", "description": "Learn how to connect your Checkly application to pull secrets from Infisical.", "category": "Monitoring"}, + {"name": "GitHub", "slug": "github", "path": "/integrations/app-connections/github", "description": "Learn how to connect your GitHub application to pull secrets from Infisical.", "category": "CI/CD"}, + {"name": "GitHub Radar", "slug": "github-radar", "path": "/integrations/app-connections/github-radar", "description": "Learn how to connect your GitHub Radar to pull secrets from Infisical.", "category": "CI/CD"}, + {"name": "GitLab", "slug": "gitlab", "path": "/integrations/app-connections/gitlab", "description": "Learn how to connect your GitLab application to pull secrets from Infisical.", "category": "CI/CD"}, + {"name": "TeamCity", "slug": "teamcity", "path": "/integrations/app-connections/teamcity", "description": "Learn how to connect your TeamCity to pull secrets from Infisical.", "category": "CI/CD"}, + {"name": "Bitbucket", "slug": "bitbucket", "path": "/integrations/app-connections/bitbucket", "description": "Learn how to connect your Bitbucket to pull secrets from Infisical.", "category": "CI/CD"}, + {"name": "Terraform Cloud", "slug": "terraform-cloud", "path": "/integrations/app-connections/terraform-cloud", "description": "Learn how to connect your Terraform Cloud to pull secrets from Infisical.", "category": "Cloud Providers"}, + {"name": "Cloudflare", "slug": "cloudflare", "path": "/integrations/app-connections/cloudflare", "description": "Learn how to connect your Cloudflare application to pull secrets from Infisical.", "category": "Cloud Providers"}, + {"name": "Databricks", "slug": "databricks", "path": "/integrations/app-connections/databricks", "description": "Learn how to connect your Databricks to pull secrets from Infisical.", "category": "Cloud Providers"}, + {"name": "Windmill", "slug": "windmill", "path": "/integrations/app-connections/windmill", "description": "Learn how to connect your Windmill to pull secrets from Infisical.", "category": "Cloud Providers"}, + {"name": "Camunda", "slug": "camunda", "path": "/integrations/app-connections/camunda", "description": "Learn how to connect your Camunda to pull secrets from Infisical.", "category": "Cloud Providers"}, + {"name": "Humanitec", "slug": "humanitec", "path": "/integrations/app-connections/humanitec", "description": "Learn how to connect your Humanitec to pull secrets from Infisical.", "category": "Cloud Providers"}, + {"name": "OCI", "slug": "oci", "path": "/integrations/app-connections/oci", "description": "Learn how to connect your OCI applications to pull secrets from Infisical.", "category": "Cloud Providers"}, + {"name": "Zabbix", "slug": "zabbix", "path": "/integrations/app-connections/zabbix", "description": "Learn how to connect your Zabbix to pull secrets from Infisical.", "category": "Monitoring"}, + {"name": "MySQL", "slug": "mysql", "path": "/integrations/app-connections/mysql", "description": "Learn how to connect your MySQL database to pull secrets from Infisical.", "category": "Databases"}, + {"name": "PostgreSQL", "slug": "postgres", "path": "/integrations/app-connections/postgres", "description": "Learn how to connect your PostgreSQL database to pull secrets from Infisical.", "category": "Databases"}, + {"name": "Microsoft SQL Server", "slug": "mssql", "path": "/integrations/app-connections/mssql", "description": "Learn how to connect your SQL Server database to pull secrets from Infisical.", "category": "Databases"}, + {"name": "Oracle Database", "slug": "oracledb", "path": "/integrations/app-connections/oracledb", "description": "Learn how to connect your Oracle database to pull secrets from Infisical.", "category": "Databases"}, + {"name": "LDAP", "slug": "ldap", "path": "/integrations/app-connections/ldap", "description": "Learn how to connect your LDAP to pull secrets from Infisical.", "category": "Directory Services"}, + {"name": "Auth0", "slug": "auth0", "path": "/integrations/app-connections/auth0", "description": "Learn how to connect your Auth0 to pull secrets from Infisical.", "category": "Identity & Auth"}, + {"name": "Okta", "slug": "okta", "path": "/integrations/app-connections/okta", "description": "Learn how to connect your Okta to pull secrets from Infisical.", "category": "Identity & Auth"} + ].sort(function(a, b) { + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + }); + + const filteredConnections = useMemo(() => { + let filtered = connections; + + if (selectedCategory !== 'All') { + filtered = filtered.filter(connection => connection.category === selectedCategory); + } + + if (searchTerm) { + filtered = filtered.filter(connection => + connection.name.toLowerCase().includes(searchTerm.toLowerCase()) || + connection.description.toLowerCase().includes(searchTerm.toLowerCase()) || + connection.category.toLowerCase().includes(searchTerm.toLowerCase()) + ); + } + + return filtered; + }, [searchTerm, selectedCategory]); + + return ( +
+ {/* Search Bar */} +
+
+
+ + + +
+ setSearchTerm(e.target.value)} + /> +
+
+ + {/* Category Filter */} +
+
+ {categories.map(category => ( + + ))} +
+
+ + {/* Results Count */} +
+

+ {filteredConnections.length} app connection{filteredConnections.length !== 1 ? 's' : ''} found + {selectedCategory !== 'All' && ` in ${selectedCategory}`} + {searchTerm && ` for "${searchTerm}"`} +

+
+ + {/* Connections List */} + {filteredConnections.length > 0 ? ( + + ) : ( +
+
+

No app connections found matching your criteria

+ {searchTerm && ( +

Try adjusting your search terms or filters

+ )} +
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/docs/snippets/DynamicSecretsBrowser.jsx b/docs/snippets/DynamicSecretsBrowser.jsx new file mode 100644 index 000000000..37a26c3c8 --- /dev/null +++ b/docs/snippets/DynamicSecretsBrowser.jsx @@ -0,0 +1,140 @@ +import React, { useState, useMemo } from 'react'; + +export const DynamicSecretsBrowser = () => { + const [searchTerm, setSearchTerm] = useState(''); + const [selectedCategory, setSelectedCategory] = useState('All'); + + const categories = ['All', 'Databases', 'Cloud Providers', 'Message Queues', 'Caches']; + + const dynamicSecrets = [ + {"name": "AWS IAM", "slug": "aws-iam", "path": "/documentation/platform/dynamic-secrets/aws-iam", "description": "Learn how to generate dynamic AWS IAM credentials on-demand.", "category": "Cloud Providers"}, + {"name": "AWS ElastiCache", "slug": "aws-elasticache", "path": "/documentation/platform/dynamic-secrets/aws-elasticache", "description": "Learn how to generate dynamic AWS ElastiCache credentials on-demand.", "category": "Caches"}, + {"name": "Azure Entra ID", "slug": "azure-entra-id", "path": "/documentation/platform/dynamic-secrets/azure-entra-id", "description": "Learn how to generate dynamic Azure Entra ID credentials on-demand.", "category": "Cloud Providers"}, + {"name": "GCP IAM", "slug": "gcp-iam", "path": "/documentation/platform/dynamic-secrets/gcp-iam", "description": "Learn how to generate dynamic GCP IAM credentials on-demand.", "category": "Cloud Providers"}, + {"name": "Cassandra", "slug": "cassandra", "path": "/documentation/platform/dynamic-secrets/cassandra", "description": "Learn how to generate dynamic Cassandra database credentials on-demand.", "category": "Databases"}, + {"name": "Couchbase", "slug": "couchbase", "path": "/documentation/platform/dynamic-secrets/couchbase", "description": "Learn how to generate dynamic Couchbase database credentials on-demand.", "category": "Databases"}, + {"name": "MongoDB", "slug": "mongodb", "path": "/documentation/platform/dynamic-secrets/mongo-db", "description": "Learn how to generate dynamic MongoDB database credentials on-demand.", "category": "Databases"}, + {"name": "MongoDB Atlas", "slug": "mongodb-atlas", "path": "/documentation/platform/dynamic-secrets/mongo-atlas", "description": "Learn how to generate dynamic MongoDB Atlas database credentials on-demand.", "category": "Databases"}, + {"name": "MySQL", "slug": "mysql", "path": "/documentation/platform/dynamic-secrets/mysql", "description": "Learn how to generate dynamic MySQL database credentials on-demand.", "category": "Databases"}, + {"name": "PostgreSQL", "slug": "postgresql", "path": "/documentation/platform/dynamic-secrets/postgresql", "description": "Learn how to generate dynamic PostgreSQL database credentials on-demand.", "category": "Databases"}, + {"name": "Microsoft SQL Server", "slug": "mssql", "path": "/documentation/platform/dynamic-secrets/mssql", "description": "Learn how to generate dynamic Microsoft SQL Server credentials on-demand.", "category": "Databases"}, + {"name": "Oracle Database", "slug": "oracle", "path": "/documentation/platform/dynamic-secrets/oracle", "description": "Learn how to generate dynamic Oracle Database credentials on-demand.", "category": "Databases"}, + {"name": "SAP ASE", "slug": "sap-ase", "path": "/documentation/platform/dynamic-secrets/sap-ase", "description": "Learn how to generate dynamic SAP ASE database credentials on-demand.", "category": "Databases"}, + {"name": "SAP HANA", "slug": "sap-hana", "path": "/documentation/platform/dynamic-secrets/sap-hana", "description": "Learn how to generate dynamic SAP HANA database credentials on-demand.", "category": "Databases"}, + {"name": "Snowflake", "slug": "snowflake", "path": "/documentation/platform/dynamic-secrets/snowflake", "description": "Learn how to generate dynamic Snowflake database credentials on-demand.", "category": "Databases"}, + {"name": "Vertica", "slug": "vertica", "path": "/documentation/platform/dynamic-secrets/vertica", "description": "Learn how to generate dynamic Vertica database credentials on-demand.", "category": "Databases"}, + {"name": "Redis", "slug": "redis", "path": "/documentation/platform/dynamic-secrets/redis", "description": "Learn how to generate dynamic Redis credentials on-demand.", "category": "Caches"}, + {"name": "ElasticSearch", "slug": "elasticsearch", "path": "/documentation/platform/dynamic-secrets/elastic-search", "description": "Learn how to generate dynamic ElasticSearch credentials on-demand.", "category": "Databases"}, + {"name": "RabbitMQ", "slug": "rabbitmq", "path": "/documentation/platform/dynamic-secrets/rabbit-mq", "description": "Learn how to generate dynamic RabbitMQ credentials on-demand.", "category": "Message Queues"}, + {"name": "LDAP", "slug": "ldap", "path": "/documentation/platform/dynamic-secrets/ldap", "description": "Learn how to generate dynamic LDAP credentials on-demand.", "category": "Cloud Providers"}, + {"name": "GitHub", "slug": "github", "path": "/documentation/platform/dynamic-secrets/github", "description": "Learn how to generate dynamic GitHub credentials on-demand.", "category": "Cloud Providers"}, + {"name": "Kubernetes", "slug": "kubernetes", "path": "/documentation/platform/dynamic-secrets/kubernetes", "description": "Learn how to generate dynamic Kubernetes credentials on-demand.", "category": "Cloud Providers"}, + {"name": "TOTP", "slug": "totp", "path": "/documentation/platform/dynamic-secrets/totp", "description": "Learn how to generate dynamic TOTP codes on-demand.", "category": "Cloud Providers"} + ].sort(function(a, b) { + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + }); + + const filteredDynamicSecrets = useMemo(() => { + let filtered = dynamicSecrets; + + if (selectedCategory !== 'All') { + filtered = filtered.filter(secret => secret.category === selectedCategory); + } + + if (searchTerm) { + filtered = filtered.filter(secret => + secret.name.toLowerCase().includes(searchTerm.toLowerCase()) || + secret.description.toLowerCase().includes(searchTerm.toLowerCase()) || + secret.category.toLowerCase().includes(searchTerm.toLowerCase()) + ); + } + + return filtered; + }, [searchTerm, selectedCategory]); + + return ( +
+ {/* Search Bar */} +
+
+
+ + + +
+ setSearchTerm(e.target.value)} + /> +
+
+ + {/* Category Filter */} +
+
+ {categories.map(category => ( + + ))} +
+
+ + {/* Results Count */} +
+

+ {filteredDynamicSecrets.length} dynamic secret{filteredDynamicSecrets.length !== 1 ? 's' : ''} found + {selectedCategory !== 'All' && ` in ${selectedCategory}`} + {searchTerm && ` for "${searchTerm}"`} +

+
+ + {/* Dynamic Secrets List */} + {filteredDynamicSecrets.length > 0 ? ( +
+ {filteredDynamicSecrets.map((secret, index) => ( + +
+
+

+ {secret.name} +

+ + {secret.category} + +
+

+ {secret.description} +

+
+
+ ))} +
+ ) : ( +
+
+

No dynamic secrets found matching your criteria

+ {searchTerm && ( +

Try adjusting your search terms or filters

+ )} +
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/docs/snippets/FrameworkIntegrationsBrowser.jsx b/docs/snippets/FrameworkIntegrationsBrowser.jsx new file mode 100644 index 000000000..f85da41ed --- /dev/null +++ b/docs/snippets/FrameworkIntegrationsBrowser.jsx @@ -0,0 +1,101 @@ +import React, { useState, useMemo } from 'react'; + +export const FrameworkIntegrationsBrowser = () => { + const [searchTerm, setSearchTerm] = useState(''); + + const integrations = [ + {"name": "React", "slug": "react", "path": "/integrations/frameworks/react", "description": "Learn how to integrate Infisical with React applications for secure secret management.", "category": "Web Frameworks"}, + {"name": "Next.js", "slug": "nextjs", "path": "/integrations/frameworks/nextjs", "description": "Learn how to integrate Infisical with Next.js applications.", "category": "Web Frameworks"}, + {"name": "Vue", "slug": "vuejs", "path": "/integrations/frameworks/vue", "description": "Learn how to integrate Infisical with Vue.js applications.", "category": "Web Frameworks"}, + {"name": "Nuxt", "slug": "nuxtjs", "path": "/integrations/frameworks/nuxt", "description": "Learn how to integrate Infisical with Nuxt.js applications.", "category": "Web Frameworks"}, + {"name": "SvelteKit", "slug": "sveltekit", "path": "/integrations/frameworks/sveltekit", "description": "Learn how to integrate Infisical with SvelteKit applications.", "category": "Web Frameworks"}, + {"name": "Express, Fastify, Koa", "slug": "express", "path": "/integrations/frameworks/express", "description": "Learn how to integrate Infisical with Express.js backend applications.", "category": "Web Frameworks"}, + {"name": "NestJS", "slug": "nestjs", "path": "/integrations/frameworks/nestjs", "description": "Learn how to integrate Infisical with NestJS applications.", "category": "Web Frameworks"}, + {"name": "Django", "slug": "django", "path": "/integrations/frameworks/django", "description": "Learn how to integrate Infisical with Django applications.", "category": "Web Frameworks"}, + {"name": "Flask", "slug": "flask", "path": "/integrations/frameworks/flask", "description": "Learn how to integrate Infisical with Flask applications.", "category": "Web Frameworks"}, + {"name": "Ruby on Rails", "slug": "rails", "path": "/integrations/frameworks/rails", "description": "Learn how to integrate Infisical with Ruby on Rails applications.", "category": "Web Frameworks"}, + {"name": "Spring Boot", "slug": "spring-boot-maven", "path": "/integrations/frameworks/spring-boot-maven", "description": "Learn how to integrate Infisical with Spring Boot applications.", "category": "Web Frameworks"}, + {"name": "Laravel", "slug": "laravel", "path": "/integrations/frameworks/laravel", "description": "Learn how to integrate Infisical with Laravel applications.", "category": "Web Frameworks"}, + {"name": ".NET", "slug": "dotnet", "path": "/integrations/frameworks/dotnet", "description": "Learn how to integrate Infisical with .NET applications.", "category": "Web Frameworks"}, + {"name": "Fiber", "slug": "fiber", "path": "/integrations/frameworks/fiber", "description": "Learn how to integrate Infisical with Fiber (Go) framework.", "category": "Web Frameworks"}, + {"name": "Gatsby", "slug": "gatsby", "path": "/integrations/frameworks/gatsby", "description": "Learn how to integrate Infisical with Gatsby applications.", "category": "Web Frameworks"}, + {"name": "Remix", "slug": "remix", "path": "/integrations/frameworks/remix", "description": "Learn how to integrate Infisical with Remix applications.", "category": "Web Frameworks"}, + {"name": "Vite", "slug": "vite", "path": "/integrations/frameworks/vite", "description": "Learn how to integrate Infisical with Vite applications.", "category": "Web Frameworks"}, + {"name": "AB Initio", "slug": "ab-initio", "path": "/integrations/frameworks/ab-initio", "description": "Learn how to integrate Infisical with AB Initio applications.", "category": "Web Frameworks"} + ].sort(function(a, b) { + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + }); + + const filteredIntegrations = useMemo(() => { + if (searchTerm) { + return integrations.filter(integration => + integration.name.toLowerCase().includes(searchTerm.toLowerCase()) || + integration.description.toLowerCase().includes(searchTerm.toLowerCase()) + ); + } + return integrations; + }, [searchTerm]); + + return ( +
+ {/* Search Bar */} +
+
+
+ + + +
+ setSearchTerm(e.target.value)} + /> +
+
+ + {/* Results Count */} +
+

+ {filteredIntegrations.length} framework integration{filteredIntegrations.length !== 1 ? 's' : ''} found + {searchTerm && ` for "${searchTerm}"`} +

+
+ + {/* Integrations List */} + {filteredIntegrations.length > 0 ? ( +
+ {filteredIntegrations.map((integration, index) => ( + +
+
+

+ {integration.name} +

+
+

+ {integration.description} +

+
+
+ ))} +
+ ) : ( +
+
+

No framework integrations found matching your criteria

+ {searchTerm && ( +

Try adjusting your search terms or filters

+ )} +
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/docs/snippets/MachineAuthenticationBrowser.jsx b/docs/snippets/MachineAuthenticationBrowser.jsx new file mode 100644 index 000000000..521493846 --- /dev/null +++ b/docs/snippets/MachineAuthenticationBrowser.jsx @@ -0,0 +1,136 @@ +import React, { useState, useMemo } from 'react'; + +export const MachineAuthenticationBrowser = () => { + const [searchTerm, setSearchTerm] = useState(''); + const [selectedCategory, setSelectedCategory] = useState('All'); + + const categories = ['All', 'Token-based', 'Cloud Provider', 'Kubernetes', 'Certificate-based', 'Directory-based']; + + const authMethods = [ + {"name": "Universal Auth", "slug": "universal-auth", "path": "/documentation/platform/identities/universal-auth", "description": "Learn how to authenticate machines using Universal Auth tokens with client ID and secret.", "category": "Token-based"}, + {"name": "Token Auth", "slug": "token-auth", "path": "/documentation/platform/identities/token-auth", "description": "Learn how to authenticate machines using long-lived access tokens.", "category": "Token-based"}, + {"name": "JWT Auth", "slug": "jwt-auth", "path": "/documentation/platform/identities/jwt-auth", "description": "Learn how to authenticate machines using JSON Web Tokens (JWT).", "category": "Token-based"}, + {"name": "AWS Auth", "slug": "aws-iam-auth", "path": "/documentation/platform/identities/aws-auth", "description": "Learn how to authenticate AWS services and resources using IAM roles.", "category": "Cloud Provider"}, + {"name": "Azure Auth", "slug": "azure-auth", "path": "/documentation/platform/identities/azure-auth", "description": "Learn how to authenticate Azure services using managed identities.", "category": "Cloud Provider"}, + {"name": "GCP Auth", "slug": "gcp-auth", "path": "/documentation/platform/identities/gcp-auth", "description": "Learn how to authenticate GCP services using service accounts.", "category": "Cloud Provider"}, + {"name": "Alibaba Cloud Auth", "slug": "alicloud-auth", "path": "/documentation/platform/identities/alicloud-auth", "description": "Learn how to authenticate Alibaba Cloud services using RAM roles.", "category": "Cloud Provider"}, + {"name": "OCI Auth", "slug": "oci-auth", "path": "/documentation/platform/identities/oci-auth", "description": "Learn how to authenticate Oracle Cloud Infrastructure services.", "category": "Cloud Provider"}, + {"name": "Kubernetes Auth", "slug": "kubernetes-auth", "path": "/documentation/platform/identities/kubernetes-auth", "description": "Learn how to authenticate Kubernetes workloads using service account tokens.", "category": "Kubernetes"}, + {"name": "OIDC Auth", "slug": "oidc-auth-general", "path": "/documentation/platform/identities/oidc-auth/general", "description": "Learn how to authenticate machines using OpenID Connect (OIDC) providers.", "category": "Token-based"}, + {"name": "OIDC Auth for GitHub Actions", "slug": "oidc-auth-github", "path": "/documentation/platform/identities/oidc-auth/github", "description": "Learn how to authenticate GitHub Actions using OIDC.", "category": "Token-based"}, + {"name": "OIDC Auth for GitLab CI/CD", "slug": "oidc-auth-gitlab", "path": "/documentation/platform/identities/oidc-auth/gitlab", "description": "Learn how to authenticate GitLab CI/CD using OIDC.", "category": "Token-based"}, + {"name": "OIDC Auth for Azure", "slug": "oidc-auth-azure", "path": "/documentation/platform/identities/oidc-auth/azure", "description": "Learn how to authenticate Azure services using OIDC.", "category": "Token-based"}, + {"name": "OIDC Auth for CircleCI", "slug": "oidc-auth-circleci", "path": "/documentation/platform/identities/oidc-auth/circleci", "description": "Learn how to authenticate CircleCI workflows using OIDC.", "category": "Token-based"}, + {"name": "OIDC Auth for Terraform Cloud", "slug": "oidc-auth-terraform", "path": "/documentation/platform/identities/oidc-auth/terraform-cloud", "description": "Learn how to authenticate Terraform Cloud using OIDC.", "category": "Token-based"}, + {"name": "OIDC Auth for SPIRE", "slug": "oidc-auth-spire", "path": "/documentation/platform/identities/oidc-auth/spire", "description": "Learn how to authenticate workloads using SPIFFE/SPIRE OIDC.", "category": "Token-based"}, + {"name": "TLS Certificate Auth", "slug": "tls-cert-auth", "path": "/documentation/platform/identities/tls-cert-auth", "description": "Learn how to authenticate machines using TLS client certificates.", "category": "Certificate-based"}, + {"name": "LDAP Auth", "slug": "ldap-auth-general", "path": "/documentation/platform/identities/ldap-auth/general", "description": "Learn how to authenticate machines using LDAP credentials.", "category": "Directory-based"}, + {"name": "LDAP Auth for JumpCloud", "slug": "ldap-auth-jumpcloud", "path": "/documentation/platform/identities/ldap-auth/jumpcloud", "description": "Learn how to authenticate machines using JumpCloud LDAP.", "category": "Directory-based"} + ].sort(function(a, b) { + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + }); + + const filteredAuthMethods = useMemo(() => { + let filtered = authMethods; + + if (selectedCategory !== 'All') { + filtered = filtered.filter(method => method.category === selectedCategory); + } + + if (searchTerm) { + filtered = filtered.filter(method => + method.name.toLowerCase().includes(searchTerm.toLowerCase()) || + method.description.toLowerCase().includes(searchTerm.toLowerCase()) || + method.category.toLowerCase().includes(searchTerm.toLowerCase()) + ); + } + + return filtered; + }, [searchTerm, selectedCategory]); + + return ( +
+ {/* Search Bar */} +
+
+
+ + + +
+ setSearchTerm(e.target.value)} + /> +
+
+ + {/* Category Filter */} +
+
+ {categories.map(category => ( + + ))} +
+
+ + {/* Results Count */} +
+

+ {filteredAuthMethods.length} authentication method{filteredAuthMethods.length !== 1 ? 's' : ''} found + {selectedCategory !== 'All' && ` in ${selectedCategory}`} + {searchTerm && ` for "${searchTerm}"`} +

+
+ + {/* Authentication Methods List */} + {filteredAuthMethods.length > 0 ? ( +
+ {filteredAuthMethods.map((method, index) => ( + +
+
+

+ {method.name} +

+ + {method.category} + +
+

+ {method.description} +

+
+
+ ))} +
+ ) : ( +
+
+

No authentication methods found matching your criteria

+ {searchTerm && ( +

Try adjusting your search terms or filters

+ )} +
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/docs/snippets/RotationsBrowser.jsx b/docs/snippets/RotationsBrowser.jsx new file mode 100644 index 000000000..3d50656b6 --- /dev/null +++ b/docs/snippets/RotationsBrowser.jsx @@ -0,0 +1,126 @@ +import React, { useState, useMemo } from 'react'; + +export const RotationsBrowser = () => { + const [searchTerm, setSearchTerm] = useState(''); + const [selectedCategory, setSelectedCategory] = useState('All'); + + const categories = ['All', 'Databases', 'Identity & Auth', 'Cloud Providers']; + + const rotations = [ + {"name": "AWS IAM User", "slug": "aws-iam-user", "path": "/documentation/platform/secret-rotation/aws-iam-user-secret", "description": "Learn how to automatically rotate AWS IAM user access keys.", "category": "Cloud Providers"}, + {"name": "Azure Client Secret", "slug": "azure-client-secret", "path": "/documentation/platform/secret-rotation/azure-client-secret", "description": "Learn how to automatically rotate Azure client secrets.", "category": "Cloud Providers"}, + {"name": "Auth0 Client Secret", "slug": "auth0-client-secret", "path": "/documentation/platform/secret-rotation/auth0-client-secret", "description": "Learn how to automatically rotate Auth0 client secrets.", "category": "Identity & Auth"}, + {"name": "Okta Client Secret", "slug": "okta-client-secret", "path": "/documentation/platform/secret-rotation/okta-client-secret", "description": "Learn how to automatically rotate Okta client secrets.", "category": "Identity & Auth"}, + {"name": "LDAP Password", "slug": "ldap-password", "path": "/documentation/platform/secret-rotation/ldap-password", "description": "Learn how to automatically rotate LDAP user passwords.", "category": "Identity & Auth"}, + {"name": "MySQL", "slug": "mysql-credentials", "path": "/documentation/platform/secret-rotation/mysql-credentials", "description": "Learn how to automatically rotate MySQL database credentials.", "category": "Databases"}, + {"name": "PostgreSQL", "slug": "postgres-credentials", "path": "/documentation/platform/secret-rotation/postgres-credentials", "description": "Learn how to automatically rotate PostgreSQL database credentials.", "category": "Databases"}, + {"name": "Microsoft SQL Server", "slug": "mssql-credentials", "path": "/documentation/platform/secret-rotation/mssql-credentials", "description": "Learn how to automatically rotate Microsoft SQL Server credentials.", "category": "Databases"}, + {"name": "Oracle Database", "slug": "oracledb-credentials", "path": "/documentation/platform/secret-rotation/oracledb-credentials", "description": "Learn how to automatically rotate Oracle Database credentials.", "category": "Databases"} + ].sort(function(a, b) { + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + }); + + const filteredRotations = useMemo(() => { + let filtered = rotations; + + if (selectedCategory !== 'All') { + filtered = filtered.filter(rotation => rotation.category === selectedCategory); + } + + if (searchTerm) { + filtered = filtered.filter(rotation => + rotation.name.toLowerCase().includes(searchTerm.toLowerCase()) || + rotation.description.toLowerCase().includes(searchTerm.toLowerCase()) || + rotation.category.toLowerCase().includes(searchTerm.toLowerCase()) + ); + } + + return filtered; + }, [searchTerm, selectedCategory]); + + return ( +
+ {/* Search Bar */} +
+
+
+ + + +
+ setSearchTerm(e.target.value)} + /> +
+
+ + {/* Category Filter */} +
+
+ {categories.map(category => ( + + ))} +
+
+ + {/* Results Count */} +
+

+ {filteredRotations.length} secret rotation{filteredRotations.length !== 1 ? 's' : ''} found + {selectedCategory !== 'All' && ` in ${selectedCategory}`} + {searchTerm && ` for "${searchTerm}"`} +

+
+ + {/* Rotations List */} + {filteredRotations.length > 0 ? ( + + ) : ( +
+
+

No secret rotations found matching your criteria

+ {searchTerm && ( +

Try adjusting your search terms or filters

+ )} +
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/docs/snippets/SecretSyncsBrowser.jsx b/docs/snippets/SecretSyncsBrowser.jsx new file mode 100644 index 000000000..01688a6f3 --- /dev/null +++ b/docs/snippets/SecretSyncsBrowser.jsx @@ -0,0 +1,147 @@ +import React, { useState, useMemo } from 'react'; + +export const SecretSyncsBrowser = () => { + const [searchTerm, setSearchTerm] = useState(''); + const [selectedCategory, setSelectedCategory] = useState('All'); + + const categories = ['All', 'Cloud Providers', 'Databases', 'CI/CD', 'Monitoring']; + + const syncs = [ + {"name": "AWS Parameter Store", "slug": "aws-parameter-store", "path": "/integrations/secret-syncs/aws-parameter-store", "description": "Learn how to sync secrets from Infisical to AWS Parameter Store.", "category": "Cloud Providers"}, + {"name": "AWS Secrets Manager", "slug": "aws-secrets-manager", "path": "/integrations/secret-syncs/aws-secrets-manager", "description": "Learn how to sync secrets from Infisical to AWS Secrets Manager.", "category": "Cloud Providers"}, + {"name": "Azure Key Vault", "slug": "azure-key-vault", "path": "/integrations/secret-syncs/azure-key-vault", "description": "Learn how to sync secrets from Infisical to Azure Key Vault.", "category": "Cloud Providers"}, + {"name": "Azure App Configuration", "slug": "azure-app-configuration", "path": "/integrations/secret-syncs/azure-app-configuration", "description": "Learn how to sync secrets from Infisical to Azure App Configuration.", "category": "Cloud Providers"}, + {"name": "Azure DevOps", "slug": "azure-devops", "path": "/integrations/secret-syncs/azure-devops", "description": "Learn how to sync secrets from Infisical to Azure DevOps.", "category": "CI/CD"}, + {"name": "GCP Secret Manager", "slug": "gcp-secret-manager", "path": "/integrations/secret-syncs/gcp-secret-manager", "description": "Learn how to sync secrets from Infisical to GCP Secret Manager.", "category": "Cloud Providers"}, + {"name": "HashiCorp Vault", "slug": "hashicorp-vault", "path": "/integrations/secret-syncs/hashicorp-vault", "description": "Learn how to sync secrets from Infisical to HashiCorp Vault.", "category": "Cloud Providers"}, + {"name": "1Password", "slug": "1password", "path": "/integrations/secret-syncs/1password", "description": "Learn how to sync secrets from Infisical to 1Password.", "category": "Cloud Providers"}, + {"name": "Vercel", "slug": "vercel", "path": "/integrations/secret-syncs/vercel", "description": "Learn how to sync secrets from Infisical to Vercel.", "category": "Cloud Providers"}, + {"name": "Netlify", "slug": "netlify", "path": "/integrations/secret-syncs/netlify", "description": "Learn how to sync secrets from Infisical to Netlify.", "category": "Cloud Providers"}, + {"name": "Railway", "slug": "railway", "path": "/integrations/secret-syncs/railway", "description": "Learn how to sync secrets from Infisical to Railway.", "category": "Cloud Providers"}, + {"name": "Fly.io", "slug": "flyio", "path": "/integrations/secret-syncs/flyio", "description": "Learn how to sync secrets from Infisical to Fly.io.", "category": "Cloud Providers"}, + {"name": "Render", "slug": "render", "path": "/integrations/secret-syncs/render", "description": "Learn how to sync secrets from Infisical to Render.", "category": "Cloud Providers"}, + {"name": "Heroku", "slug": "heroku", "path": "/integrations/secret-syncs/heroku", "description": "Learn how to sync secrets from Infisical to Heroku.", "category": "Cloud Providers"}, + {"name": "DigitalOcean App Platform", "slug": "digital-ocean-app-platform", "path": "/integrations/secret-syncs/digital-ocean-app-platform", "description": "Learn how to sync secrets from Infisical to DigitalOcean App Platform.", "category": "Cloud Providers"}, + {"name": "Supabase", "slug": "supabase", "path": "/integrations/secret-syncs/supabase", "description": "Learn how to sync secrets from Infisical to Supabase.", "category": "Databases"}, + {"name": "Checkly", "slug": "checkly", "path": "/integrations/secret-syncs/checkly", "description": "Learn how to sync secrets from Infisical to Checkly.", "category": "Monitoring"}, + {"name": "GitHub", "slug": "github", "path": "/integrations/secret-syncs/github", "description": "Learn how to sync secrets from Infisical to GitHub.", "category": "CI/CD"}, + {"name": "GitLab", "slug": "gitlab", "path": "/integrations/secret-syncs/gitlab", "description": "Learn how to sync secrets from Infisical to GitLab.", "category": "CI/CD"}, + {"name": "TeamCity", "slug": "teamcity", "path": "/integrations/secret-syncs/teamcity", "description": "Learn how to sync secrets from Infisical to TeamCity.", "category": "CI/CD"}, + {"name": "Bitbucket", "slug": "bitbucket", "path": "/integrations/secret-syncs/bitbucket", "description": "Learn how to sync secrets from Infisical to Bitbucket.", "category": "CI/CD"}, + {"name": "Terraform Cloud", "slug": "terraform-cloud", "path": "/integrations/secret-syncs/terraform-cloud", "description": "Learn how to sync secrets from Infisical to Terraform Cloud.", "category": "Cloud Providers"}, + {"name": "Cloudflare Pages", "slug": "cloudflare-pages", "path": "/integrations/secret-syncs/cloudflare-pages", "description": "Learn how to sync secrets from Infisical to Cloudflare Pages.", "category": "Cloud Providers"}, + {"name": "Cloudflare Workers", "slug": "cloudflare-workers", "path": "/integrations/secret-syncs/cloudflare-workers", "description": "Learn how to sync secrets from Infisical to Cloudflare Workers.", "category": "Cloud Providers"}, + {"name": "Databricks", "slug": "databricks", "path": "/integrations/secret-syncs/databricks", "description": "Learn how to sync secrets from Infisical to Databricks.", "category": "Cloud Providers"}, + {"name": "Windmill", "slug": "windmill", "path": "/integrations/secret-syncs/windmill", "description": "Learn how to sync secrets from Infisical to Windmill.", "category": "Cloud Providers"}, + {"name": "Camunda", "slug": "camunda", "path": "/integrations/secret-syncs/camunda", "description": "Learn how to sync secrets from Infisical to Camunda.", "category": "Cloud Providers"}, + {"name": "Humanitec", "slug": "humanitec", "path": "/integrations/secret-syncs/humanitec", "description": "Learn how to sync secrets from Infisical to Humanitec.", "category": "Cloud Providers"}, + {"name": "OCI Vault", "slug": "oci-vault", "path": "/integrations/secret-syncs/oci-vault", "description": "Learn how to sync secrets from Infisical to OCI Vault.", "category": "Cloud Providers"}, + {"name": "Zabbix", "slug": "zabbix", "path": "/integrations/secret-syncs/zabbix", "description": "Learn how to sync secrets from Infisical to Zabbix.", "category": "Monitoring"} + ].sort(function(a, b) { + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + }); + + const filteredSyncs = useMemo(() => { + let filtered = syncs; + + if (selectedCategory !== 'All') { + filtered = filtered.filter(sync => sync.category === selectedCategory); + } + + if (searchTerm) { + filtered = filtered.filter(sync => + sync.name.toLowerCase().includes(searchTerm.toLowerCase()) || + sync.description.toLowerCase().includes(searchTerm.toLowerCase()) || + sync.category.toLowerCase().includes(searchTerm.toLowerCase()) + ); + } + + return filtered; + }, [searchTerm, selectedCategory]); + + return ( +
+ {/* Search Bar */} +
+
+
+ + + +
+ setSearchTerm(e.target.value)} + /> +
+
+ + {/* Category Filter */} +
+
+ {categories.map(category => ( + + ))} +
+
+ + {/* Results Count */} +
+

+ {filteredSyncs.length} secret sync{filteredSyncs.length !== 1 ? 's' : ''} found + {selectedCategory !== 'All' && ` in ${selectedCategory}`} + {searchTerm && ` for "${searchTerm}"`} +

+
+ + {/* Secret Syncs List */} + {filteredSyncs.length > 0 ? ( + + ) : ( +
+
+

No secret syncs found matching your criteria

+ {searchTerm && ( +

Try adjusting your search terms or filters

+ )} +
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/docs/snippets/UserAuthenticationBrowser.jsx b/docs/snippets/UserAuthenticationBrowser.jsx new file mode 100644 index 000000000..76b77ad93 --- /dev/null +++ b/docs/snippets/UserAuthenticationBrowser.jsx @@ -0,0 +1,135 @@ +import React, { useState, useMemo } from 'react'; + +export const UserAuthenticationBrowser = () => { + const [searchTerm, setSearchTerm] = useState(''); + const [selectedCategory, setSelectedCategory] = useState('All'); + + const categories = ['All', 'SSO', 'LDAP', 'SCIM', 'General']; + + const authMethods = [ + {"name": "Auth0 OIDC", "slug": "auth0-oidc-sso", "path": "/documentation/platform/sso/auth0-oidc", "description": "Learn how to configure Auth0 OIDC SSO for user authentication in Infisical.", "category": "SSO"}, + {"name": "Auth0 SAML", "slug": "auth0-saml-sso", "path": "/documentation/platform/sso/auth0-saml", "description": "Learn how to configure Auth0 SAML SSO for user authentication in Infisical.", "category": "SSO"}, + {"name": "Entra ID / Azure AD SAML", "slug": "azure-ad-sso", "path": "/documentation/platform/sso/azure", "description": "Learn how to configure Azure Active Directory (Entra ID) SSO for user authentication in Infisical.", "category": "SSO"}, + {"name": "Google", "slug": "google-sso", "path": "/documentation/platform/sso/google", "description": "Learn how to configure Google SSO for user authentication in Infisical.", "category": "SSO"}, + {"name": "Google SAML", "slug": "google-saml-sso", "path": "/documentation/platform/sso/google-saml", "description": "Learn how to configure Google SAML SSO for user authentication in Infisical.", "category": "SSO"}, + {"name": "GitHub", "slug": "github-sso", "path": "/documentation/platform/sso/github", "description": "Learn how to configure GitHub SSO for user authentication in Infisical.", "category": "SSO"}, + {"name": "GitLab", "slug": "gitlab-sso", "path": "/documentation/platform/sso/gitlab", "description": "Learn how to configure GitLab SSO for user authentication in Infisical.", "category": "SSO"}, + {"name": "JumpCloud", "slug": "jumpcloud-sso", "path": "/documentation/platform/sso/jumpcloud", "description": "Learn how to configure JumpCloud SSO for user authentication in Infisical.", "category": "SSO"}, + {"name": "Keycloak OIDC", "slug": "keycloak-oidc-sso", "path": "/documentation/platform/sso/keycloak-oidc/overview", "description": "Learn how to configure Keycloak OIDC SSO for user authentication in Infisical.", "category": "SSO"}, + {"name": "Keycloak SAML", "slug": "keycloak-saml-sso", "path": "/documentation/platform/sso/keycloak-saml", "description": "Learn how to configure Keycloak SAML SSO for user authentication in Infisical.", "category": "SSO"}, + {"name": "Okta", "slug": "okta-oidc-sso", "path": "/documentation/platform/sso/okta", "description": "Learn how to configure Okta OIDC SSO for user authentication in Infisical.", "category": "SSO"}, + {"name": "Okta SAML", "slug": "okta-saml-sso", "path": "/documentation/platform/sso/okta-saml", "description": "Learn how to configure Okta SAML SSO for user authentication in Infisical.", "category": "SSO"}, + {"name": "OneLogin SAML", "slug": "onelogin-saml-sso", "path": "/documentation/platform/sso/onelogin-saml", "description": "Learn how to configure OneLogin SAML SSO for user authentication in Infisical.", "category": "SSO"}, + {"name": "General OIDC", "slug": "general-oidc-sso", "path": "/documentation/platform/sso/general-oidc", "description": "Learn how to configure generic OIDC providers for SSO in Infisical.", "category": "SSO"}, + {"name": "General SAML 2.0", "slug": "general-saml-sso", "path": "/documentation/platform/sso/general-saml", "description": "Learn how to configure generic SAML 2.0 providers for SSO in Infisical.", "category": "SSO"}, + {"name": "LDAP", "slug": "ldap", "path": "/documentation/platform/ldap/overview", "description": "Learn how to configure LDAP authentication for user login in Infisical.", "category": "LDAP"}, + {"name": "SCIM", "slug": "scim", "path": "/documentation/platform/scim/overview", "description": "Learn how to configure SCIM provisioning for automated user management in Infisical.", "category": "SCIM"}, + {"name": "Email/Password", "slug": "email-password", "path": "/documentation/getting-started/introduction", "description": "Learn how to use standard email and password authentication in Infisical.", "category": "General"} + ].sort(function(a, b) { + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + }); + + const filteredAuthMethods = useMemo(() => { + let filtered = authMethods; + + if (selectedCategory !== 'All') { + filtered = filtered.filter(method => method.category === selectedCategory); + } + + if (searchTerm) { + filtered = filtered.filter(method => + method.name.toLowerCase().includes(searchTerm.toLowerCase()) || + method.description.toLowerCase().includes(searchTerm.toLowerCase()) || + method.category.toLowerCase().includes(searchTerm.toLowerCase()) + ); + } + + return filtered; + }, [searchTerm, selectedCategory]); + + return ( +
+ {/* Search Bar */} +
+
+
+ + + +
+ setSearchTerm(e.target.value)} + /> +
+
+ + {/* Category Filter */} +
+
+ {categories.map(category => ( + + ))} +
+
+ + {/* Results Count */} +
+

+ {filteredAuthMethods.length} user authentication method{filteredAuthMethods.length !== 1 ? 's' : ''} found + {selectedCategory !== 'All' && ` in ${selectedCategory}`} + {searchTerm && ` for "${searchTerm}"`} +

+
+ + {/* Authentication Methods List */} + {filteredAuthMethods.length > 0 ? ( +
+ {filteredAuthMethods.map((method, index) => ( + +
+
+

+ {method.name} +

+ + {method.category} + +
+

+ {method.description} +

+
+
+ ))} +
+ ) : ( +
+
+

No user authentication methods found matching your criteria

+ {searchTerm && ( +

Try adjusting your search terms or filters

+ )} +
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/frontend/public/images/integrations/EnvKey.png b/frontend/public/images/integrations/EnvKey.png new file mode 100644 index 000000000..bf1ce03ed Binary files /dev/null and b/frontend/public/images/integrations/EnvKey.png differ diff --git a/frontend/src/components/projects/NewProjectModal.tsx b/frontend/src/components/projects/NewProjectModal.tsx index fa3ae2c70..1bbe9f6f1 100644 --- a/frontend/src/components/projects/NewProjectModal.tsx +++ b/frontend/src/components/projects/NewProjectModal.tsx @@ -153,7 +153,7 @@ const NewProjectForm = ({ onOpenChange }: NewProjectFormProps) => { reset(); onOpenChange(false); navigate({ - to: getProjectHomePage(project.type), + to: getProjectHomePage(project.type, project.environments), params: { projectId: project.id } }); } catch (err) { diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/ChecklySyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/ChecklySyncFields.tsx index 50c645ee2..c55313898 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/ChecklySyncFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/ChecklySyncFields.tsx @@ -5,14 +5,15 @@ import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/Se import { FilterableSelect, FormControl } from "@app/components/v2"; import { TChecklyAccount, - useChecklyConnectionListAccounts + useChecklyConnectionListAccounts, + useChecklyConnectionListGroups } from "@app/hooks/api/appConnections/checkly"; import { SecretSync } from "@app/hooks/api/secretSyncs"; import { TSecretSyncForm } from "../schemas"; export const ChecklySyncFields = () => { - const { control, setValue } = useFormContext< + const { control, setValue, watch } = useFormContext< TSecretSyncForm & { destination: SecretSync.Checkly } >(); @@ -25,12 +26,24 @@ export const ChecklySyncFields = () => { } ); + const accountId = watch("destinationConfig.accountId"); + + const { data: groups = [], isPending: isGroupsLoading } = useChecklyConnectionListGroups( + connectionId, + accountId, + { + enabled: Boolean(connectionId && accountId) + } + ); + return ( <> { setValue("destinationConfig.accountId", ""); setValue("destinationConfig.accountName", ""); + setValue("destinationConfig.groupId", undefined); + setValue("destinationConfig.groupName", undefined); }} /> { )} /> + + ( + + p.id === value) ?? null} + onChange={(option) => { + const v = option as SingleValue; + onChange(v?.id ?? null); + setValue("destinationConfig.groupName", v?.name ?? undefined); + }} + options={groups} + placeholder="Select a group..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> + + )} + /> ); }; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/RenderSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/RenderSyncFields.tsx index 3d3f8df93..b88e0d6e6 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/RenderSyncFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/RenderSyncFields.tsx @@ -5,7 +5,9 @@ import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/Se import { FilterableSelect, FormControl, Select, SelectItem } from "@app/components/v2"; import { RENDER_SYNC_SCOPES } from "@app/helpers/secretSyncs"; import { + TRenderEnvironmentGroup, TRenderService, + useRenderConnectionListEnvironmentGroups, useRenderConnectionListServices } from "@app/hooks/api/appConnections/render"; import { SecretSync } from "@app/hooks/api/secretSyncs"; @@ -19,6 +21,7 @@ export const RenderSyncFields = () => { >(); const connectionId = useWatch({ name: "connection.id", control }); + const selectedScope = useWatch({ name: "destinationConfig.scope", control }); const { data: services = [], isPending: isServicesPending } = useRenderConnectionListServices( connectionId, @@ -27,11 +30,17 @@ export const RenderSyncFields = () => { } ); + const { data: groups = [], isPending: isGroupsPending } = + useRenderConnectionListEnvironmentGroups(connectionId, { + enabled: Boolean(connectionId) && selectedScope === RenderSyncScope.EnvironmentGroup + }); + return ( <> { setValue("destinationConfig.serviceId", ""); + setValue("destinationConfig.environmentGroupId", ""); setValue("destinationConfig.type", RenderSyncType.Env); setValue("destinationConfig.scope", RenderSyncScope.Service); }} @@ -83,30 +92,67 @@ export const RenderSyncFields = () => { )} /> - ( - - service.id === value) ?? []) : []} - onChange={(option) => { - onChange((option as SingleValue)?.id ?? null); - setValue( - "destinationConfig.serviceName", - (option as SingleValue)?.name ?? "" - ); - }} - options={services} - placeholder="Select a service..." - getOptionLabel={(option) => option.name} - getOptionValue={(option) => option.id.toString()} - /> - - )} - /> + {selectedScope === RenderSyncScope.Service && ( + ( + + service.id === value) ?? []) : []} + onChange={(option) => { + onChange((option as SingleValue)?.id ?? null); + setValue( + "destinationConfig.serviceName", + (option as SingleValue)?.name ?? "" + ); + }} + options={services} + placeholder="Select a service..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id.toString()} + /> + + )} + /> + )} + + {selectedScope === RenderSyncScope.EnvironmentGroup && ( + ( + + g.id === value) ?? []) : []} + onChange={(option) => { + onChange((option as SingleValue)?.id ?? null); + setValue( + "destinationConfig.environmentGroupName", + (option as SingleValue)?.name ?? "" + ); + }} + options={groups} + placeholder="Select an environment group..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id.toString()} + /> + + )} + /> + )} ); }; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/ChecklySyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/ChecklySyncReviewFields.tsx index d520699ce..0f86a0c06 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/ChecklySyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/ChecklySyncReviewFields.tsx @@ -6,7 +6,16 @@ import { SecretSync } from "@app/hooks/api/secretSyncs"; export const ChecklySyncReviewFields = () => { const { watch } = useFormContext(); - const accountName = watch("destinationConfig.accountName"); + const config = watch("destinationConfig"); - return {accountName}; + return ( + <> + + {config.accountName ?? config.accountId} + + {config.groupId && ( + {config.groupName ?? config.groupId} + )} + + ); }; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/RenderSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/RenderSyncReviewFields.tsx index 15f5b6625..f195f0e72 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/RenderSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/RenderSyncReviewFields.tsx @@ -4,6 +4,7 @@ import { GenericFieldLabel } from "@app/components/secret-syncs"; import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; import { Badge } from "@app/components/v2"; import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { RenderSyncScope } from "@app/hooks/api/secretSyncs/types/render-sync"; export const RenderSyncOptionsReviewFields = () => { const { watch } = useFormContext(); @@ -27,13 +28,20 @@ export const RenderSyncOptionsReviewFields = () => { export const RenderSyncReviewFields = () => { const { watch } = useFormContext(); - const serviceName = watch("destinationConfig.serviceName"); - const scope = watch("destinationConfig.scope"); + const config = watch("destinationConfig"); return ( <> - {scope} - {serviceName} + {config.scope} + {config.scope === RenderSyncScope.Service ? ( + + {config.serviceName ?? config.serviceId} + + ) : ( + + {config.environmentGroupName ?? config.environmentGroupId} + + )} ); }; diff --git a/frontend/src/components/secret-syncs/forms/schemas/checkly-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/checkly-sync-destination-schema.ts index 9ada6e276..7684df981 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/checkly-sync-destination-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/checkly-sync-destination-schema.ts @@ -8,7 +8,15 @@ export const ChecklySyncDestinationSchema = BaseSecretSyncSchema().merge( destination: z.literal(SecretSync.Checkly), destinationConfig: z.object({ accountId: z.string(), - accountName: z.string() + accountName: z.string(), + groupId: z + .string() + .nullish() + .transform((val) => val || undefined), + groupName: z + .string() + .nullish() + .transform((val) => val || undefined) }) }) ); diff --git a/frontend/src/components/secret-syncs/forms/schemas/render-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/render-sync-destination-schema.ts index 83e5347eb..b65705c14 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/render-sync-destination-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/render-sync-destination-schema.ts @@ -17,6 +17,12 @@ export const RenderSyncDestinationSchema = BaseSecretSyncSchema( serviceId: z.string().trim().min(1, "Service is required"), serviceName: z.string().trim().optional(), type: z.nativeEnum(RenderSyncType) + }), + z.object({ + scope: z.literal(RenderSyncScope.EnvironmentGroup), + environmentGroupId: z.string().trim().min(1, "Environment Group ID is required"), + environmentGroupName: z.string().trim().optional(), + type: z.nativeEnum(RenderSyncType) }) ]) }) diff --git a/frontend/src/components/v2/Button/Button.tsx b/frontend/src/components/v2/Button/Button.tsx index 2daa76930..179a55714 100644 --- a/frontend/src/components/v2/Button/Button.tsx +++ b/frontend/src/components/v2/Button/Button.tsx @@ -204,7 +204,7 @@ export const Button = forwardRef( {leftIcon && (
{value ? formatDateTime({ timestamp: value, timezone, dateFormat }) - : "Pick a date and time"} + : `Select Date${hideTime ? "" : " and Time"}`}
diff --git a/frontend/src/components/v2/EmptyState/EmptyState.tsx b/frontend/src/components/v2/EmptyState/EmptyState.tsx index 9816a3fe2..f6b926953 100644 --- a/frontend/src/components/v2/EmptyState/EmptyState.tsx +++ b/frontend/src/components/v2/EmptyState/EmptyState.tsx @@ -10,6 +10,7 @@ type Props = { children?: ReactNode; icon?: IconDefinition; iconSize?: SizeProp; + titleClassName?: string; }; export const EmptyState = ({ @@ -17,7 +18,8 @@ export const EmptyState = ({ className, children, icon = faCubesStacked, - iconSize = "2x" + iconSize = "2x", + titleClassName }: Props) => (
-
{title}
+
{title}
{children}
diff --git a/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx b/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx index 47a5acd56..7ab8b0f3c 100644 --- a/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx +++ b/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx @@ -67,7 +67,7 @@ export const FilterableSelect = ({ }), menuPortal: (provided) => ({ ...provided, - zIndex: 9999 + zIndex: 99999 }) }} tabSelectsValue={tabSelectsValue} diff --git a/frontend/src/components/v2/SecretInput/SecretInput.tsx b/frontend/src/components/v2/SecretInput/SecretInput.tsx index 6f3f4283a..747af73d5 100644 --- a/frontend/src/components/v2/SecretInput/SecretInput.tsx +++ b/frontend/src/components/v2/SecretInput/SecretInput.tsx @@ -6,20 +6,12 @@ import { useToggle } from "@app/hooks"; import { HIDDEN_SECRET_VALUE } from "@app/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretItem"; const REGEX = /(\${([a-zA-Z0-9-_.]+)})/g; -const replaceContentWithDot = (str: string) => { - let finalStr = ""; - for (let i = 0; i < str.length; i += 1) { - const char = str.at(i); - finalStr += char === "\n" ? "\n" : "*"; - } - return finalStr; -}; const syntaxHighlight = (content?: string | null, isVisible?: boolean, isImport?: boolean) => { if (isImport && !content) return "IMPORTED"; if (content === "") return "EMPTY"; if (!content) return "EMPTY"; - if (!isVisible) return replaceContentWithDot(content); + if (!isVisible) return HIDDEN_SECRET_VALUE; let skipNext = false; const formattedContent = content.split(REGEX).flatMap((el, i) => { diff --git a/frontend/src/components/v2/Table/Table.tsx b/frontend/src/components/v2/Table/Table.tsx index cc180ffb6..b8ca27dc1 100644 --- a/frontend/src/components/v2/Table/Table.tsx +++ b/frontend/src/components/v2/Table/Table.tsx @@ -1,4 +1,4 @@ -import { DetailedHTMLProps, HTMLAttributes, ReactNode, TdHTMLAttributes } from "react"; +import { DetailedHTMLProps, forwardRef, HTMLAttributes, ReactNode, TdHTMLAttributes } from "react"; import { twMerge } from "tailwind-merge"; import { Skeleton } from "../Skeleton"; @@ -9,22 +9,20 @@ export type TableContainerProps = { className?: string; } & DetailedHTMLProps, HTMLDivElement>; -export const TableContainer = ({ - children, - className, - isRounded = true, - ...props -}: TableContainerProps): JSX.Element => ( -
- {children} -
+export const TableContainer = forwardRef( + ({ children, className, isRounded = true, ...props }, ref): JSX.Element => ( +
+ {children} +
+ ) ); // main parent table diff --git a/frontend/src/context/OrgPermissionContext/index.tsx b/frontend/src/context/OrgPermissionContext/index.tsx index fccd53935..f3bc0195d 100644 --- a/frontend/src/context/OrgPermissionContext/index.tsx +++ b/frontend/src/context/OrgPermissionContext/index.tsx @@ -2,6 +2,7 @@ export { useOrgPermission } from "./OrgPermissionContext"; export type { TOrgPermission } from "./types"; export { OrgPermissionActions, + OrgPermissionAuditLogsActions, OrgPermissionBillingActions, OrgPermissionGroupActions, OrgPermissionIdentityActions, diff --git a/frontend/src/context/OrgPermissionContext/types.ts b/frontend/src/context/OrgPermissionContext/types.ts index 50e147aa0..4a569be06 100644 --- a/frontend/src/context/OrgPermissionContext/types.ts +++ b/frontend/src/context/OrgPermissionContext/types.ts @@ -52,6 +52,7 @@ export enum OrgPermissionSubjects { Gateway = "gateway", SecretShare = "secret-share", GithubOrgSync = "github-org-sync", + GithubOrgSyncManual = "github-org-sync-manual", MachineIdentityAuthTemplate = "machine-identity-auth-template" } @@ -71,6 +72,10 @@ export enum OrgPermissionAppConnectionActions { Connect = "connect" } +export enum OrgPermissionAuditLogsActions { + Read = "read" +} + export enum OrgPermissionKmipActions { Proxy = "proxy", Setup = "setup" @@ -111,6 +116,7 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.IncidentAccount] | [OrgPermissionActions, OrgPermissionSubjects.Scim] | [OrgPermissionActions, OrgPermissionSubjects.GithubOrgSync] + | [OrgPermissionActions, OrgPermissionSubjects.GithubOrgSyncManual] | [OrgPermissionActions, OrgPermissionSubjects.Sso] | [OrgPermissionActions, OrgPermissionSubjects.Ldap] | [OrgPermissionGroupActions, OrgPermissionSubjects.Groups] @@ -118,7 +124,7 @@ export type OrgPermissionSet = | [OrgPermissionBillingActions, OrgPermissionSubjects.Billing] | [OrgPermissionActions, OrgPermissionSubjects.Kms] | [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole] - | [OrgPermissionActions, OrgPermissionSubjects.AuditLogs] + | [OrgPermissionAuditLogsActions, OrgPermissionSubjects.AuditLogs] | [OrgPermissionActions, OrgPermissionSubjects.ProjectTemplates] | [OrgPermissionAppConnectionActions, OrgPermissionSubjects.AppConnections] | [OrgPermissionIdentityActions, OrgPermissionSubjects.Identity] diff --git a/frontend/src/context/ProjectPermissionContext/index.tsx b/frontend/src/context/ProjectPermissionContext/index.tsx index f564ac74e..a1669e18f 100644 --- a/frontend/src/context/ProjectPermissionContext/index.tsx +++ b/frontend/src/context/ProjectPermissionContext/index.tsx @@ -2,6 +2,7 @@ export { useProjectPermission } from "./ProjectPermissionContext"; export type { ProjectPermissionSet, TProjectPermission } from "./types"; export { ProjectPermissionActions, + ProjectPermissionAuditLogsActions, ProjectPermissionCertificateActions, ProjectPermissionCmekActions, ProjectPermissionDynamicSecretActions, diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index dad72cada..acfab612f 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -150,6 +150,10 @@ export enum ProjectPermissionSecretEventActions { SubscribeImportMutations = "subscribe-on-import-mutations" } +export enum ProjectPermissionAuditLogsActions { + Read = "read" +} + export enum PermissionConditionOperators { $IN = "$in", $ALL = "$all", @@ -365,7 +369,7 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.Groups] | [ProjectPermissionActions, ProjectPermissionSub.Integrations] | [ProjectPermissionActions, ProjectPermissionSub.Webhooks] - | [ProjectPermissionActions, ProjectPermissionSub.AuditLogs] + | [ProjectPermissionAuditLogsActions, ProjectPermissionSub.AuditLogs] | [ProjectPermissionActions, ProjectPermissionSub.Environments] | [ProjectPermissionActions, ProjectPermissionSub.IpAllowList] | [ProjectPermissionActions, ProjectPermissionSub.Settings] diff --git a/frontend/src/context/index.tsx b/frontend/src/context/index.tsx index 833956d77..bfb504664 100644 --- a/frontend/src/context/index.tsx +++ b/frontend/src/context/index.tsx @@ -2,6 +2,7 @@ export { useOrganization } from "./OrganizationContext"; export type { TOrgPermission } from "./OrgPermissionContext"; export { OrgPermissionActions, + OrgPermissionAuditLogsActions, OrgPermissionBillingActions, OrgPermissionGroupActions, OrgPermissionIdentityActions, @@ -11,6 +12,7 @@ export { export type { TProjectPermission } from "./ProjectPermissionContext"; export { ProjectPermissionActions, + ProjectPermissionAuditLogsActions, ProjectPermissionCertificateActions, ProjectPermissionCmekActions, ProjectPermissionDynamicSecretActions, diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index 7ecc92811..88d9bb0d1 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -13,6 +13,7 @@ import { AppConnection } from "@app/hooks/api/appConnections/enums"; import { Auth0ConnectionMethod, AwsConnectionMethod, + AzureADCSConnectionMethod, AzureAppConfigurationConnectionMethod, AzureClientSecretsConnectionMethod, AzureDevOpsConnectionMethod, @@ -76,6 +77,7 @@ export const APP_CONNECTION_MAP: Record< image: "Microsoft Azure.png" }, [AppConnection.AzureDevOps]: { name: "Azure DevOps", image: "Microsoft Azure.png" }, + [AppConnection.AzureADCS]: { name: "Azure ADCS", image: "Microsoft Azure.png" }, [AppConnection.Databricks]: { name: "Databricks", image: "Databricks.png" }, [AppConnection.Humanitec]: { name: "Humanitec", image: "Humanitec.png" }, [AppConnection.TerraformCloud]: { name: "Terraform Cloud", image: "Terraform Cloud.png" }, @@ -151,6 +153,7 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) case MsSqlConnectionMethod.UsernameAndPassword: case MySqlConnectionMethod.UsernameAndPassword: case OracleDBConnectionMethod.UsernameAndPassword: + case AzureADCSConnectionMethod.UsernamePassword: return { name: "Username & Password", icon: faLock }; case HCVaultConnectionMethod.AccessToken: case TeamCityConnectionMethod.AccessToken: diff --git a/frontend/src/helpers/datetime.ts b/frontend/src/helpers/datetime.ts index 6e0fb8e48..d8a5e90c6 100644 --- a/frontend/src/helpers/datetime.ts +++ b/frontend/src/helpers/datetime.ts @@ -22,3 +22,62 @@ export const formatDateTime = ({ } return format(date, dateFormat); }; + +// Helper function to convert seconds to value and unit +export const getObjectFromSeconds = ( + totalSeconds: number, + activeUnits?: Array<"s" | "m" | "h" | "d" | "w" | "y"> +): { value: number; unit: "s" | "m" | "h" | "d" | "w" | "y" } => { + const SECONDS_IN_MINUTE = 60; + const SECONDS_IN_HOUR = SECONDS_IN_MINUTE * 60; + const SECONDS_IN_DAY = SECONDS_IN_HOUR * 24; + const SECONDS_IN_WEEK = SECONDS_IN_DAY * 7; + const SECONDS_IN_YEAR = SECONDS_IN_DAY * 365; + + const activeUnitsSet = activeUnits ? new Set(activeUnits) : null; + + const isUnitActive = (unit: "s" | "m" | "h" | "d" | "w" | "y"): boolean => { + return activeUnitsSet ? activeUnitsSet.has(unit) : true; + }; + + if ( + isUnitActive("y") && + totalSeconds >= SECONDS_IN_YEAR && + totalSeconds % SECONDS_IN_YEAR === 0 + ) { + return { value: totalSeconds / SECONDS_IN_YEAR, unit: "y" }; + } + + if ( + isUnitActive("w") && + totalSeconds >= SECONDS_IN_WEEK && + totalSeconds % SECONDS_IN_WEEK === 0 + ) { + return { value: totalSeconds / SECONDS_IN_WEEK, unit: "w" }; + } + + if (isUnitActive("d") && totalSeconds >= SECONDS_IN_DAY && totalSeconds % SECONDS_IN_DAY === 0) { + return { value: totalSeconds / SECONDS_IN_DAY, unit: "d" }; + } + + if ( + isUnitActive("h") && + totalSeconds >= SECONDS_IN_HOUR && + totalSeconds % SECONDS_IN_HOUR === 0 + ) { + return { value: totalSeconds / SECONDS_IN_HOUR, unit: "h" }; + } + + if ( + isUnitActive("m") && + totalSeconds >= SECONDS_IN_MINUTE && + totalSeconds % SECONDS_IN_MINUTE === 0 + ) { + return { value: totalSeconds / SECONDS_IN_MINUTE, unit: "m" }; + } + + return { + value: totalSeconds, + unit: "s" + }; +}; diff --git a/frontend/src/helpers/project.ts b/frontend/src/helpers/project.ts index 3b04b263d..7c9185556 100644 --- a/frontend/src/helpers/project.ts +++ b/frontend/src/helpers/project.ts @@ -1,6 +1,6 @@ import { apiRequest } from "@app/config/request"; import { createWorkspace } from "@app/hooks/api/workspace/queries"; -import { ProjectType } from "@app/hooks/api/workspace/types"; +import { ProjectType, WorkspaceEnv } from "@app/hooks/api/workspace/types"; const secretsToBeAdded = [ { @@ -72,12 +72,14 @@ export const getProjectBaseURL = (type: ProjectType) => { } }; -export const getProjectHomePage = (type: ProjectType) => { +// @ts-expect-error akhilmhdh: will remove this later +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const getProjectHomePage = (type: ProjectType, environments: WorkspaceEnv[]) => { switch (type) { case ProjectType.SecretManager: - return "/projects/secret-management/$projectId/overview"; + return "/projects/secret-management/$projectId/overview" as const; case ProjectType.CertificateManager: - return "/projects/cert-management/$projectId/subscribers"; + return "/projects/cert-management/$projectId/subscribers" as const; case ProjectType.SecretScanning: return `/projects/${type}/$projectId/data-sources` as const; default: diff --git a/frontend/src/helpers/secretSyncs.ts b/frontend/src/helpers/secretSyncs.ts index 8da61583e..6ce82cfb2 100644 --- a/frontend/src/helpers/secretSyncs.ts +++ b/frontend/src/helpers/secretSyncs.ts @@ -212,5 +212,9 @@ export const RENDER_SYNC_SCOPES: Record[]; bypassers?: Bypasser[]; approvals?: number; secretPath: string; @@ -190,7 +193,7 @@ export type TCreateAccessPolicyDTO = { export type TUpdateAccessPolicyDTO = { id: string; name?: string; - approvers?: Approver[]; + approvers?: Omit[]; bypassers?: Bypasser[]; secretPath?: string; environments?: string[]; diff --git a/frontend/src/hooks/api/appConnections/checkly/queries.tsx b/frontend/src/hooks/api/appConnections/checkly/queries.tsx index 452c21245..95156aaa4 100644 --- a/frontend/src/hooks/api/appConnections/checkly/queries.tsx +++ b/frontend/src/hooks/api/appConnections/checkly/queries.tsx @@ -8,7 +8,9 @@ import { TChecklyAccount } from "./types"; const checklyConnectionKeys = { all: [...appConnectionKeys.all, "checkly"] as const, listAccounts: (connectionId: string) => - [...checklyConnectionKeys.all, "workspace-scopes", connectionId] as const + [...checklyConnectionKeys.all, "workspace-scopes", connectionId] as const, + listGroups: (connectionId: string, accountId: string) => + [...checklyConnectionKeys.all, "groups", connectionId, accountId] as const }; export const useChecklyConnectionListAccounts = ( @@ -35,3 +37,29 @@ export const useChecklyConnectionListAccounts = ( ...options }); }; + +export const useChecklyConnectionListGroups = ( + connectionId: string, + accountId: string, + options?: Omit< + UseQueryOptions< + TChecklyAccount[], + unknown, + TChecklyAccount[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: checklyConnectionKeys.listGroups(connectionId, accountId), + queryFn: async () => { + const { data } = await apiRequest.get<{ groups: TChecklyAccount[] }>( + `/api/v1/app-connections/checkly/${connectionId}/accounts/${accountId}/groups` + ); + + return data.groups; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index eff3c9b62..7b041b797 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -7,6 +7,7 @@ export enum AppConnection { AzureAppConfiguration = "azure-app-configuration", AzureClientSecrets = "azure-client-secrets", AzureDevOps = "azure-devops", + AzureADCS = "azure-adcs", Databricks = "databricks", Humanitec = "humanitec", TerraformCloud = "terraform-cloud", diff --git a/frontend/src/hooks/api/appConnections/queries.tsx b/frontend/src/hooks/api/appConnections/queries.tsx index 0b55cb79b..dbccade88 100644 --- a/frontend/src/hooks/api/appConnections/queries.tsx +++ b/frontend/src/hooks/api/appConnections/queries.tsx @@ -54,13 +54,13 @@ export const useAppConnectionOptions = ( export const useGetAppConnectionOption = (app: T) => { const { data: options = [], isPending } = useAppConnectionOptions(); - return useMemo( - () => ({ - option: (options.find((opt) => opt.app === app) as TAppConnectionOptionMap[T]) ?? {}, + return useMemo(() => { + const foundOption = options.find((opt) => opt.app === app); + return { + option: (foundOption as TAppConnectionOptionMap[T]) ?? {}, isLoading: isPending - }), - [options, app, isPending] - ); + }; + }, [options, app, isPending]); }; export const useListAppConnections = ( diff --git a/frontend/src/hooks/api/appConnections/render/queries.tsx b/frontend/src/hooks/api/appConnections/render/queries.tsx index 728c46bd9..93ec24738 100644 --- a/frontend/src/hooks/api/appConnections/render/queries.tsx +++ b/frontend/src/hooks/api/appConnections/render/queries.tsx @@ -3,12 +3,14 @@ import { useQuery, UseQueryOptions } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { appConnectionKeys } from "../queries"; -import { TRenderService } from "./types"; +import { TRenderEnvironmentGroup, TRenderService } from "./types"; const renderConnectionKeys = { all: [...appConnectionKeys.all, "render"] as const, listServices: (connectionId: string) => - [...renderConnectionKeys.all, "services", connectionId] as const + [...renderConnectionKeys.all, "services", connectionId] as const, + listEnvironmentGroups: (connectionId: string) => + [...renderConnectionKeys.all, "environment-groups", connectionId] as const }; export const useRenderConnectionListServices = ( @@ -35,3 +37,28 @@ export const useRenderConnectionListServices = ( ...options }); }; + +export const useRenderConnectionListEnvironmentGroups = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TRenderEnvironmentGroup[], + unknown, + TRenderEnvironmentGroup[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: renderConnectionKeys.listEnvironmentGroups(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/app-connections/render/${connectionId}/environment-groups` + ); + + return data; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/render/types.ts b/frontend/src/hooks/api/appConnections/render/types.ts index ec51adb78..d422a7ae5 100644 --- a/frontend/src/hooks/api/appConnections/render/types.ts +++ b/frontend/src/hooks/api/appConnections/render/types.ts @@ -2,3 +2,8 @@ export type TRenderService = { id: string; name: string; }; + +export type TRenderEnvironmentGroup = { + id: string; + name: string; +}; diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts index 4191cb890..67d8feb48 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -32,7 +32,7 @@ export type TAzureKeyVaultConnectionOption = TAppConnectionOptionBase & { }; export type TAzureAppConfigurationConnectionOption = TAppConnectionOptionBase & { - app: AppConnection.AzureKeyVault; + app: AppConnection.AzureAppConfiguration; oauthClientId?: string; }; @@ -164,9 +164,14 @@ export type TOktaConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Okta; }; +export type TAzureAdCsConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.AzureADCS; +}; + export type TAppConnectionOption = | TAwsConnectionOption | TGitHubConnectionOption + | TGitHubRadarConnectionOption | TGcpConnectionOption | TAzureAppConfigurationConnectionOption | TAzureKeyVaultConnectionOption @@ -184,6 +189,7 @@ export type TAppConnectionOption = | TWindmillConnectionOption | TAuth0ConnectionOption | THCVaultConnectionOption + | TLdapConnectionOption | TTeamCityConnectionOption | TOCIConnectionOption | TOnePassConnectionOption @@ -196,9 +202,11 @@ export type TAppConnectionOption = | TZabbixConnectionOption | TRailwayConnectionOption | TChecklyConnectionOption + | TSupabaseConnectionOption | TDigitalOceanConnectionOption | TNetlifyConnectionOption - | TOktaConnectionOption; + | TOktaConnectionOption + | TAzureAdCsConnectionOption; export type TAppConnectionOptionMap = { [AppConnection.AWS]: TAwsConnectionOption; @@ -238,4 +246,5 @@ export type TAppConnectionOptionMap = { [AppConnection.DigitalOcean]: TDigitalOceanConnectionOption; [AppConnection.Netlify]: TNetlifyConnectionOption; [AppConnection.Okta]: TOktaConnectionOption; + [AppConnection.AzureADCS]: TAzureAdCsConnectionOption; }; diff --git a/frontend/src/hooks/api/appConnections/types/azure-adcs-connection.ts b/frontend/src/hooks/api/appConnections/types/azure-adcs-connection.ts new file mode 100644 index 000000000..2da9d56b5 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/azure-adcs-connection.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; + +import { AppConnection } from "../enums"; +import { TRootAppConnection } from "./root-connection"; + +export enum AzureADCSConnectionMethod { + UsernamePassword = "username-password" +} + +export const CreateAzureADCSConnectionSchema = z.object({ + adcsUrl: z.string().url().min(1, "ADCS URL is required"), + username: z.string().min(1, "Username is required"), + password: z.string().min(1, "Password is required") +}); + +export type TCreateAzureADCSConnection = z.infer; + +export type TAzureADCSConnection = TRootAppConnection & { app: AppConnection.AzureADCS } & { + method: AzureADCSConnectionMethod.UsernamePassword; + credentials: TCreateAzureADCSConnection; +}; diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index f07dcd283..e63979a8e 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -3,6 +3,7 @@ import { TOnePassConnection } from "./1password-connection"; import { TAppConnectionOption } from "./app-options"; import { TAuth0Connection } from "./auth0-connection"; import { TAwsConnection } from "./aws-connection"; +import { TAzureADCSConnection } from "./azure-adcs-connection"; import { TAzureAppConfigurationConnection } from "./azure-app-configuration-connection"; import { TAzureClientSecretsConnection } from "./azure-client-secrets-connection"; import { TAzureDevOpsConnection } from "./azure-devops-connection"; @@ -41,6 +42,7 @@ import { TZabbixConnection } from "./zabbix-connection"; export * from "./1password-connection"; export * from "./auth0-connection"; export * from "./aws-connection"; +export * from "./azure-adcs-connection"; export * from "./azure-app-configuration-connection"; export * from "./azure-client-secrets-connection"; export * from "./azure-devops-connection"; @@ -83,6 +85,7 @@ export type TAppConnection = | TAzureAppConfigurationConnection | TAzureClientSecretsConnection | TAzureDevOpsConnection + | TAzureADCSConnection | TDatabricksConnection | THumanitecConnection | TTerraformCloudConnection @@ -156,6 +159,7 @@ export type TAppConnectionMap = { [AppConnection.AzureAppConfiguration]: TAzureAppConfigurationConnection; [AppConnection.AzureClientSecrets]: TAzureClientSecretsConnection; [AppConnection.AzureDevOps]: TAzureDevOpsConnection; + [AppConnection.AzureADCS]: TAzureADCSConnection; [AppConnection.Databricks]: TDatabricksConnection; [AppConnection.Humanitec]: THumanitecConnection; [AppConnection.TerraformCloud]: TTerraformCloudConnection; diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index 696f5745f..186a8e539 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -40,6 +40,7 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.GET_IDENTITY_UNIVERSAL_AUTH]: "Get universal auth", [EventType.CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET]: "Create universal auth client secret", [EventType.REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET]: "Revoke universal auth client secret", + [EventType.CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS]: "Clear universal auth lockouts", [EventType.GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS]: "Get universal auth client secrets", [EventType.CREATE_ENVIRONMENT]: "Create environment", [EventType.UPDATE_ENVIRONMENT]: "Update environment", diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index 159a61808..4fe8948dd 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -46,6 +46,7 @@ export enum EventType { GET_IDENTITY_UNIVERSAL_AUTH = "get-identity-universal-auth", CREATE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "create-identity-universal-auth-client-secret", REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "revoke-identity-universal-auth-client-secret", + CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS = "clear-identity-universal-auth-lockouts", GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS = "get-identity-universal-auth-client-secret", LOGIN_IDENTITY_LDAP_AUTH = "login-identity-ldap-auth", diff --git a/frontend/src/hooks/api/auditLogs/types.tsx b/frontend/src/hooks/api/auditLogs/types.tsx index 0b9e201fe..1ca819e60 100644 --- a/frontend/src/hooks/api/auditLogs/types.tsx +++ b/frontend/src/hooks/api/auditLogs/types.tsx @@ -326,6 +326,14 @@ interface RevokeIdentityUniversalAuthClientSecretEvent { }; } +interface ClearIdentityUniversalAuthLockoutsEvent { + type: EventType.CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS; + metadata: { + identityId: string; + clientSecretId: string; + }; +} + interface CreateEnvironmentEvent { type: EventType.CREATE_ENVIRONMENT; metadata: { @@ -892,6 +900,7 @@ export type Event = | CreateIdentityUniversalAuthClientSecretEvent | GetIdentityUniversalAuthClientSecretsEvent | RevokeIdentityUniversalAuthClientSecretEvent + | ClearIdentityUniversalAuthLockoutsEvent | CreateEnvironmentEvent | UpdateEnvironmentEvent | DeleteEnvironmentEvent diff --git a/frontend/src/hooks/api/ca/constants.tsx b/frontend/src/hooks/api/ca/constants.tsx index fa59b3ae0..778656da4 100644 --- a/frontend/src/hooks/api/ca/constants.tsx +++ b/frontend/src/hooks/api/ca/constants.tsx @@ -1,7 +1,7 @@ import { AppConnection } from "../appConnections/enums"; import { SshCaStatus } from "../sshCa"; import { SshCertTemplateStatus } from "../sshCertificateTemplates"; -import { AcmeDnsProvider, CaStatus, InternalCaType } from "./enums"; +import { AcmeDnsProvider, CaCapability, CaStatus, CaType, InternalCaType } from "./enums"; export const caTypeToNameMap: { [K in InternalCaType]: string } = { [InternalCaType.ROOT]: "Root", @@ -24,6 +24,28 @@ export const ACME_DNS_PROVIDER_APP_CONNECTION_MAP: Record = { + [CaType.INTERNAL]: [ + CaCapability.ISSUE_CERTIFICATES, + CaCapability.REVOKE_CERTIFICATES, + CaCapability.RENEW_CERTIFICATES + ], + [CaType.ACME]: [ + CaCapability.ISSUE_CERTIFICATES, + CaCapability.REVOKE_CERTIFICATES, + CaCapability.RENEW_CERTIFICATES + ], + [CaType.AZURE_AD_CS]: [CaCapability.ISSUE_CERTIFICATES, CaCapability.RENEW_CERTIFICATES] +}; + +/** + * Check if a certificate authority type supports a specific capability + */ +export const caSupportsCapability = (caType: CaType, capability: CaCapability): boolean => { + const capabilities = CA_TYPE_CAPABILITIES_MAP[caType] || []; + return capabilities.includes(capability); +}; + export const getCaStatusBadgeVariant = (status: CaStatus | SshCaStatus | SshCertTemplateStatus) => { switch (status) { case CaStatus.ACTIVE: diff --git a/frontend/src/hooks/api/ca/enums.tsx b/frontend/src/hooks/api/ca/enums.tsx index b010faa07..a68f3e862 100644 --- a/frontend/src/hooks/api/ca/enums.tsx +++ b/frontend/src/hooks/api/ca/enums.tsx @@ -1,6 +1,7 @@ export enum CaType { INTERNAL = "internal", - ACME = "acme" + ACME = "acme", + AZURE_AD_CS = "azure-ad-cs" } export enum InternalCaType { @@ -22,3 +23,9 @@ export enum AcmeDnsProvider { ROUTE53 = "route53", Cloudflare = "cloudflare" } + +export enum CaCapability { + ISSUE_CERTIFICATES = "issue-certificates", + REVOKE_CERTIFICATES = "revoke-certificates", + RENEW_CERTIFICATES = "renew-certificates" +} diff --git a/frontend/src/hooks/api/ca/index.tsx b/frontend/src/hooks/api/ca/index.tsx index 82e9ea3be..36526ec18 100644 --- a/frontend/src/hooks/api/ca/index.tsx +++ b/frontend/src/hooks/api/ca/index.tsx @@ -9,6 +9,7 @@ export { useUpdateCa } from "./mutations"; export { + useGetAzureAdcsTemplates, useGetCa, useGetCaById, useGetCaCert, @@ -17,5 +18,6 @@ export { useGetCaCrls, useGetCaCsr, useListCasByProjectId, - useListCasByTypeAndProjectId + useListCasByTypeAndProjectId, + useListExternalCasByProjectId } from "./queries"; diff --git a/frontend/src/hooks/api/ca/mutations.tsx b/frontend/src/hooks/api/ca/mutations.tsx index c05760beb..f6204d4fc 100644 --- a/frontend/src/hooks/api/ca/mutations.tsx +++ b/frontend/src/hooks/api/ca/mutations.tsx @@ -39,6 +39,10 @@ export const useUpdateCa = () => { queryClient.invalidateQueries({ queryKey: caKeys.getCaByNameAndProjectId(caName, projectId) }); + // Invalidate external CAs list + queryClient.invalidateQueries({ + queryKey: caKeys.listExternalCasByProjectId(projectId) + }); } }); }; @@ -57,6 +61,10 @@ export const useCreateCa = () => { queryClient.invalidateQueries({ queryKey: caKeys.listCasByTypeAndProjectId(type, projectId) }); + // Invalidate external CAs list + queryClient.invalidateQueries({ + queryKey: caKeys.listExternalCasByProjectId(projectId) + }); } }); }; @@ -79,6 +87,10 @@ export const useDeleteCa = () => { queryClient.invalidateQueries({ queryKey: caKeys.listCasByTypeAndProjectId(type, projectId) }); + // Invalidate external CAs list + queryClient.invalidateQueries({ + queryKey: caKeys.listExternalCasByProjectId(projectId) + }); } }); }; diff --git a/frontend/src/hooks/api/ca/queries.tsx b/frontend/src/hooks/api/ca/queries.tsx index 4f10c6e84..68e8d2c12 100644 --- a/frontend/src/hooks/api/ca/queries.tsx +++ b/frontend/src/hooks/api/ca/queries.tsx @@ -4,20 +4,25 @@ import { apiRequest } from "@app/config/request"; import { TCertificateTemplate } from "../certificateTemplates/types"; import { CaType } from "./enums"; -import { TCertificateAuthority, TUnifiedCertificateAuthority } from "./types"; +import { TAzureAdCsTemplate, TCertificateAuthority, TUnifiedCertificateAuthority } from "./types"; export const caKeys = { getCaById: (caId: string) => [{ caId }, "ca"], getCaByNameAndProjectId: (caName: string, projectId: string) => [{ caName, projectId }, "ca"], listCasByTypeAndProjectId: (type: CaType, projectId: string) => [{ type, projectId }, "cas"], listCasByProjectId: (projectId: string) => [{ projectId }, "cas"], + listExternalCasByProjectId: (projectId: string) => [{ projectId }, "external-cas"], getCaCerts: (caId: string) => [{ caId }, "ca-cert"], getCaCrls: (caId: string) => [{ caId }, "ca-crls"], getCaCert: (caId: string) => [{ caId }, "ca-cert"], getCaCsr: (caId: string) => [{ caId }, "ca-csr"], getCaCrl: (caId: string) => [{ caId }, "ca-crl"], getCaCertTemplates: (caId: string) => [{ caId }, "ca-cert-templates"], - getCaEstConfig: (caId: string) => [{ caId }, "ca-est-config"] + getCaEstConfig: (caId: string) => [{ caId }, "ca-est-config"], + getAzureAdcsTemplates: (caId: string, projectId: string) => [ + { caId, projectId }, + "azure-adcs-templates" + ] }; export const useGetCa = ({ @@ -67,6 +72,34 @@ export const useListCasByProjectId = (projectId: string) => { }); }; +export const useListExternalCasByProjectId = (projectId: string) => { + return useQuery({ + queryKey: caKeys.listExternalCasByProjectId(projectId), + queryFn: async () => { + const [acmeResponse, azureAdCsResponse] = await Promise.allSettled([ + apiRequest.get( + `/api/v1/pki/ca/${CaType.ACME}?projectId=${projectId}` + ), + apiRequest.get( + `/api/v1/pki/ca/${CaType.AZURE_AD_CS}?projectId=${projectId}` + ) + ]); + + const allCas: TUnifiedCertificateAuthority[] = []; + + if (acmeResponse.status === "fulfilled") { + allCas.push(...acmeResponse.value.data); + } + + if (azureAdCsResponse.status === "fulfilled") { + allCas.push(...azureAdCsResponse.value.data); + } + + return allCas; + } + }); +}; + export const useGetCaById = (caId: string) => { return useQuery({ queryKey: caKeys.getCaById(caId), @@ -156,3 +189,22 @@ export const useGetCaCertTemplates = (caId: string) => { enabled: Boolean(caId) }); }; + +export const useGetAzureAdcsTemplates = ({ + caId, + projectId +}: { + caId: string; + projectId: string; +}) => { + return useQuery({ + queryKey: caKeys.getAzureAdcsTemplates(caId, projectId), + queryFn: async () => { + const { data } = await apiRequest.get<{ + templates: TAzureAdCsTemplate[]; + }>(`/api/v1/pki/ca/azure-ad-cs/${caId}/templates?projectId=${projectId}`); + return data; + }, + enabled: Boolean(caId && projectId) + }); +}; diff --git a/frontend/src/hooks/api/ca/types.ts b/frontend/src/hooks/api/ca/types.ts index f9f812cb2..336688ca4 100644 --- a/frontend/src/hooks/api/ca/types.ts +++ b/frontend/src/hooks/api/ca/types.ts @@ -16,6 +16,21 @@ export type TAcmeCertificateAuthority = { }; directoryUrl: string; accountEmail: string; + eabKid?: string; + eabHmacKey?: string; + }; +}; + +export type TAzureAdCsCertificateAuthority = { + id: string; + projectId: string; + type: CaType.AZURE_AD_CS; + status: CaStatus; + name: string; + enableDirectIssuance: boolean; + configuration: { + azureAdcsConnectionId: string; + templateName: string; }; }; @@ -48,6 +63,7 @@ export type TInternalCertificateAuthority = { export type TUnifiedCertificateAuthority = | TAcmeCertificateAuthority + | TAzureAdCsCertificateAuthority | TInternalCertificateAuthority; export type TCreateCertificateAuthorityDTO = Omit; @@ -114,6 +130,12 @@ export type TSignIntermediateResponse = { serialNumber: string; }; +export type TAzureAdCsTemplate = { + id: string; + name: string; + description?: string; +}; + export type TImportCaCertificateDTO = { caId: string; projectSlug: string; diff --git a/frontend/src/hooks/api/dashboard/queries.tsx b/frontend/src/hooks/api/dashboard/queries.tsx index e748e3e5b..fde3a5ee3 100644 --- a/frontend/src/hooks/api/dashboard/queries.tsx +++ b/frontend/src/hooks/api/dashboard/queries.tsx @@ -1,5 +1,6 @@ import { useCallback } from "react"; import { useQuery, UseQueryOptions } from "@tanstack/react-query"; +import { AxiosError } from "axios"; import { apiRequest } from "@app/config/request"; import { @@ -273,6 +274,12 @@ export const useGetProjectSecretsDetails = ( ...options, // wait for all values to be available enabled: Boolean(projectId) && (options?.enabled ?? true), + retry: (count, error) => { + // don't retry 404s + if (error instanceof AxiosError && error.status === 404) return false; + + return count <= 5; + }, queryKey: dashboardKeys.getProjectSecretsDetails({ secretPath, search, diff --git a/frontend/src/hooks/api/dashboard/types.ts b/frontend/src/hooks/api/dashboard/types.ts index 78c56394f..fbd5107cd 100644 --- a/frontend/src/hooks/api/dashboard/types.ts +++ b/frontend/src/hooks/api/dashboard/types.ts @@ -72,7 +72,7 @@ export type DashboardProjectSecretsOverview = Omit< DashboardProjectSecretsOverviewResponse, "secrets" | "secretRotations" > & { - secrets?: SecretV3RawSanitized[]; + secrets?: (SecretV3RawSanitized & { sourceEnv?: string })[]; secretRotations?: (TSecretRotationV2 & { secrets: (SecretV3RawSanitized | null)[]; })[]; diff --git a/frontend/src/hooks/api/githubOrgSyncConfig/index.tsx b/frontend/src/hooks/api/githubOrgSyncConfig/index.tsx index 585426469..66b0246dc 100644 --- a/frontend/src/hooks/api/githubOrgSyncConfig/index.tsx +++ b/frontend/src/hooks/api/githubOrgSyncConfig/index.tsx @@ -1,6 +1,7 @@ export { useCreateGithubSyncOrgConfig, useDeleteGithubSyncOrgConfig, + useSyncAllGithubTeams, useUpdateGithubSyncOrgConfig } from "./mutations"; export { githubOrgSyncConfigQueryKeys } from "./queries"; diff --git a/frontend/src/hooks/api/githubOrgSyncConfig/mutations.tsx b/frontend/src/hooks/api/githubOrgSyncConfig/mutations.tsx index 0cb9b56e8..3c78550ed 100644 --- a/frontend/src/hooks/api/githubOrgSyncConfig/mutations.tsx +++ b/frontend/src/hooks/api/githubOrgSyncConfig/mutations.tsx @@ -40,3 +40,19 @@ export const useDeleteGithubSyncOrgConfig = () => { } }); }; + +export const useSyncAllGithubTeams = () => { + return useMutation({ + mutationFn: async (): Promise<{ + totalUsers: number; + errors: string[]; + createdTeams: string[]; + updatedTeams: string[]; + removedMemberships: number; + syncDuration: number; + }> => { + const response = await apiRequest.post("/api/v1/github-org-sync-config/sync-all-teams"); + return response.data; + } + }); +}; diff --git a/frontend/src/hooks/api/identities/mutations.tsx b/frontend/src/hooks/api/identities/mutations.tsx index c01f1aa85..5189f187c 100644 --- a/frontend/src/hooks/api/identities/mutations.tsx +++ b/frontend/src/hooks/api/identities/mutations.tsx @@ -18,6 +18,7 @@ import { AddIdentityTlsCertAuthDTO, AddIdentityTokenAuthDTO, AddIdentityUniversalAuthDTO, + ClearIdentityUniversalAuthLockoutsDTO, ClientSecretData, CreateIdentityDTO, CreateIdentityUniversalAuthClientSecretDTO, @@ -148,7 +149,11 @@ export const useAddIdentityUniversalAuth = () => { accessTokenTTL, accessTokenMaxTTL, accessTokenNumUsesLimit, - accessTokenTrustedIps + accessTokenTrustedIps, + lockoutEnabled, + lockoutThreshold, + lockoutDurationSeconds, + lockoutCounterResetSeconds }) => { const { data: { identityUniversalAuth } @@ -157,7 +162,11 @@ export const useAddIdentityUniversalAuth = () => { accessTokenTTL, accessTokenMaxTTL, accessTokenNumUsesLimit, - accessTokenTrustedIps + accessTokenTrustedIps, + lockoutEnabled, + lockoutThreshold, + lockoutDurationSeconds, + lockoutCounterResetSeconds }); return identityUniversalAuth; }, @@ -183,7 +192,11 @@ export const useUpdateIdentityUniversalAuth = () => { accessTokenMaxTTL, accessTokenNumUsesLimit, accessTokenTrustedIps, - accessTokenPeriod + accessTokenPeriod, + lockoutEnabled, + lockoutThreshold, + lockoutDurationSeconds, + lockoutCounterResetSeconds }) => { const { data: { identityUniversalAuth } @@ -193,7 +206,11 @@ export const useUpdateIdentityUniversalAuth = () => { accessTokenMaxTTL, accessTokenNumUsesLimit, accessTokenTrustedIps, - accessTokenPeriod + accessTokenPeriod, + lockoutEnabled, + lockoutThreshold, + lockoutDurationSeconds, + lockoutCounterResetSeconds }); return identityUniversalAuth; }, @@ -275,6 +292,25 @@ export const useRevokeIdentityUniversalAuthClientSecret = () => { }); }; +export const useClearIdentityUniversalAuthLockouts = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ identityId }) => { + const { + data: { deleted } + } = await apiRequest.post<{ deleted: number }>( + `/api/v1/auth/universal-auth/identities/${identityId}/clear-lockouts` + ); + return deleted; + }, + onSuccess: (_, { identityId }) => { + queryClient.invalidateQueries({ + queryKey: identitiesKeys.getIdentityUniversalAuth(identityId) + }); + } + }); +}; + export const useAddIdentityGcpAuth = () => { const queryClient = useQueryClient(); return useMutation({ diff --git a/frontend/src/hooks/api/identities/types.ts b/frontend/src/hooks/api/identities/types.ts index 7098ce244..36f9eae4e 100644 --- a/frontend/src/hooks/api/identities/types.ts +++ b/frontend/src/hooks/api/identities/types.ts @@ -16,6 +16,7 @@ export type Identity = { name: string; hasDeleteProtection: boolean; authMethods: IdentityAuthMethod[]; + activeLockoutAuthMethods: IdentityAuthMethod[]; createdAt: string; updatedAt: string; isInstanceAdmin?: boolean; @@ -113,6 +114,10 @@ export type IdentityUniversalAuth = { accessTokenNumUsesLimit: number; accessTokenTrustedIps: IdentityTrustedIp[]; accessTokenPeriod: number; + lockoutEnabled: boolean; + lockoutThreshold: number; + lockoutDurationSeconds: number; + lockoutCounterResetSeconds: number; }; export type AddIdentityUniversalAuthDTO = { @@ -128,6 +133,10 @@ export type AddIdentityUniversalAuthDTO = { accessTokenTrustedIps: { ipAddress: string; }[]; + lockoutEnabled: boolean; + lockoutThreshold: number; + lockoutDurationSeconds: number; + lockoutCounterResetSeconds: number; }; export type UpdateIdentityUniversalAuthDTO = { @@ -143,6 +152,10 @@ export type UpdateIdentityUniversalAuthDTO = { accessTokenTrustedIps?: { ipAddress: string; }[]; + lockoutEnabled?: boolean; + lockoutThreshold?: number; + lockoutDurationSeconds?: number; + lockoutCounterResetSeconds?: number; }; export type DeleteIdentityUniversalAuthDTO = { @@ -558,6 +571,10 @@ export type DeleteIdentityUniversalAuthClientSecretDTO = { clientSecretId: string; }; +export type ClearIdentityUniversalAuthLockoutsDTO = { + identityId: string; +}; + export type IdentityTokenAuth = { identityId: string; accessTokenTTL: number; diff --git a/frontend/src/hooks/api/migration/mutations.tsx b/frontend/src/hooks/api/migration/mutations.tsx index 529d5c463..182797954 100644 --- a/frontend/src/hooks/api/migration/mutations.tsx +++ b/frontend/src/hooks/api/migration/mutations.tsx @@ -46,18 +46,21 @@ export const useImportVault = () => { vaultAccessToken, vaultNamespace, vaultUrl, - mappingType + mappingType, + gatewayId }: { vaultAccessToken: string; vaultNamespace?: string; vaultUrl: string; mappingType: string; + gatewayId?: string; }) => { await apiRequest.post("/api/v3/external-migration/vault/", { vaultAccessToken, vaultNamespace, vaultUrl, - mappingType + mappingType, + gatewayId }); } }); diff --git a/frontend/src/hooks/api/organization/queries.tsx b/frontend/src/hooks/api/organization/queries.tsx index cd620bb64..15e1b861c 100644 --- a/frontend/src/hooks/api/organization/queries.tsx +++ b/frontend/src/hooks/api/organization/queries.tsx @@ -104,6 +104,7 @@ export const useUpdateOrg = () => { mutationFn: ({ name, authEnforced, + googleSsoAuthEnforced, scimEnabled, slug, orgId, @@ -125,6 +126,7 @@ export const useUpdateOrg = () => { return apiRequest.patch(`/api/v1/organization/${orgId}`, { name, authEnforced, + googleSsoAuthEnforced, scimEnabled, slug, defaultMembershipRoleSlug, diff --git a/frontend/src/hooks/api/organization/types.ts b/frontend/src/hooks/api/organization/types.ts index 73267f0f6..71f2a8b90 100644 --- a/frontend/src/hooks/api/organization/types.ts +++ b/frontend/src/hooks/api/organization/types.ts @@ -9,6 +9,7 @@ export type Organization = { createAt: string; updatedAt: string; authEnforced: boolean; + googleSsoAuthEnforced: boolean; bypassOrgAuthEnabled: boolean; orgAuthMethod: string; scimEnabled: boolean; @@ -34,6 +35,7 @@ export type UpdateOrgDTO = { orgId: string; name?: string; authEnforced?: boolean; + googleSsoAuthEnforced?: boolean; scimEnabled?: boolean; slug?: string; defaultMembershipRoleSlug?: string; diff --git a/frontend/src/hooks/api/pkiSubscriber/types.ts b/frontend/src/hooks/api/pkiSubscriber/types.ts index 628cfec0a..23ca6baf3 100644 --- a/frontend/src/hooks/api/pkiSubscriber/types.ts +++ b/frontend/src/hooks/api/pkiSubscriber/types.ts @@ -10,6 +10,16 @@ export enum SubscriberOperationStatus { FAILED = "failed" } +export type TPkiSubscriberProperties = { + azureTemplateType?: string; + organization?: string; + organizationalUnit?: string; + country?: string; + state?: string; + locality?: string; + emailAddress?: string; +}; + export type TPkiSubscriber = { id: string; projectId: string; @@ -27,6 +37,7 @@ export type TPkiSubscriber = { lastOperationStatus?: SubscriberOperationStatus; lastOperationMessage?: string; lastOperationAt?: string; + properties?: TPkiSubscriberProperties; }; export type TCreatePkiSubscriberDTO = { @@ -40,6 +51,7 @@ export type TCreatePkiSubscriberDTO = { extendedKeyUsages: CertExtendedKeyUsage[]; enableAutoRenewal?: boolean; autoRenewalPeriodInDays?: number; + properties?: TPkiSubscriberProperties; }; export type TUpdatePkiSubscriberDTO = { @@ -55,6 +67,7 @@ export type TUpdatePkiSubscriberDTO = { extendedKeyUsages?: CertExtendedKeyUsage[]; enableAutoRenewal?: boolean; autoRenewalPeriodInDays?: number; + properties?: TPkiSubscriberProperties; }; export type TDeletePkiSubscriberDTO = { diff --git a/frontend/src/hooks/api/secretApproval/types.ts b/frontend/src/hooks/api/secretApproval/types.ts index 8fd86624d..1785ffa8a 100644 --- a/frontend/src/hooks/api/secretApproval/types.ts +++ b/frontend/src/hooks/api/secretApproval/types.ts @@ -20,6 +20,7 @@ export enum ApproverType { } export type Approver = { + isOrgMembershipActive: boolean; id: string; type: ApproverType; }; @@ -49,7 +50,7 @@ export type TCreateSecretPolicyDTO = { name?: string; environments: string[]; secretPath: string; - approvers?: Approver[]; + approvers?: Omit[]; bypassers?: Bypasser[]; approvals?: number; enforcementLevel: EnforcementLevel; @@ -59,7 +60,7 @@ export type TCreateSecretPolicyDTO = { export type TUpdateSecretPolicyDTO = { id: string; name?: string; - approvers?: Approver[]; + approvers?: Omit[]; bypassers?: Bypasser[]; secretPath?: string; approvals?: number; diff --git a/frontend/src/hooks/api/secretApprovalRequest/types.ts b/frontend/src/hooks/api/secretApprovalRequest/types.ts index caf1df320..0eef3fa3d 100644 --- a/frontend/src/hooks/api/secretApprovalRequest/types.ts +++ b/frontend/src/hooks/api/secretApprovalRequest/types.ts @@ -53,6 +53,7 @@ export type TSecretApprovalRequest = { firstName: string; lastName: string; username: string; + isOrgMembershipActive: boolean; }[]; workspace: string; environment: string; @@ -62,6 +63,7 @@ export type TSecretApprovalRequest = { status: "open" | "close"; policy: Omit & { approvers: { + isOrgMembershipActive: boolean; userId: string; email: string; firstName: string; diff --git a/frontend/src/hooks/api/secretSyncs/types/checkly-sync.ts b/frontend/src/hooks/api/secretSyncs/types/checkly-sync.ts index 40718d261..10a8601de 100644 --- a/frontend/src/hooks/api/secretSyncs/types/checkly-sync.ts +++ b/frontend/src/hooks/api/secretSyncs/types/checkly-sync.ts @@ -3,11 +3,17 @@ import { AppConnection } from "@app/hooks/api/appConnections/enums"; import { SecretSync } from "@app/hooks/api/secretSyncs"; import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; +export enum ChecklySyncScope { + Global = "global", + Group = "group" +} export type TChecklySync = TRootSecretSync & { destination: SecretSync.Checkly; destinationConfig: { accountId: string; accountName: string; + groupId?: string; + groupName?: string; }; connection: { app: AppConnection.Checkly; diff --git a/frontend/src/hooks/api/secretSyncs/types/render-sync.ts b/frontend/src/hooks/api/secretSyncs/types/render-sync.ts index 3d66de623..4d217e840 100644 --- a/frontend/src/hooks/api/secretSyncs/types/render-sync.ts +++ b/frontend/src/hooks/api/secretSyncs/types/render-sync.ts @@ -4,12 +4,19 @@ import { RootSyncOptions, TRootSecretSync } from "@app/hooks/api/secretSyncs/typ export type TRenderSync = TRootSecretSync & { destination: SecretSync.Render; - destinationConfig: { - scope: RenderSyncScope.Service; - type: RenderSyncType; - serviceId: string; - serviceName?: string; - }; + destinationConfig: + | { + type: RenderSyncType; + scope: RenderSyncScope.Service; + serviceId: string; + serviceName?: string | undefined; + } + | { + type: RenderSyncType; + scope: RenderSyncScope.EnvironmentGroup; + environmentGroupId: string; + environmentGroupName?: string | undefined; + }; connection: { app: AppConnection.Render; @@ -23,7 +30,8 @@ export type TRenderSync = TRootSecretSync & { }; export enum RenderSyncScope { - Service = "service" + Service = "service", + EnvironmentGroup = "environment-group" } export enum RenderSyncType { diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index 4ded71cfe..338599fe0 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -48,6 +48,7 @@ export type SubscriptionPlan = { externalKms: boolean; pkiEst: boolean; enforceMfa: boolean; + enforceGoogleSSO: boolean; projectTemplates: boolean; kmip: boolean; secretScanning: boolean; diff --git a/frontend/src/hooks/api/users/mutation.tsx b/frontend/src/hooks/api/users/mutation.tsx index 84bd05e07..b108a98c4 100644 --- a/frontend/src/hooks/api/users/mutation.tsx +++ b/frontend/src/hooks/api/users/mutation.tsx @@ -26,16 +26,16 @@ export const useAddUserToWsNonE2EE = () => { }); }; -export const sendEmailVerificationCode = async (username: string) => { +export const sendEmailVerificationCode = async (token: string) => { return apiRequest.post("/api/v2/users/me/emails/code", { - username + token }); }; export const useSendEmailVerificationCode = () => { return useMutation({ - mutationFn: async (username: string) => { - await sendEmailVerificationCode(username); + mutationFn: async (token: string) => { + await sendEmailVerificationCode(token); return {}; } }); diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts index 3af283b7e..0a43e850e 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -83,6 +83,7 @@ export type TProjectMembership = { export type TWorkspaceUser = { id: string; user: { + isOrgMembershipActive: boolean; email: string; username: string; firstName: string; diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index 7a0e3d2e2..3c7a6d30c 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -47,7 +47,8 @@ import { UpdateEnvironmentDTO, UpdatePitVersionLimitDTO, UpdateProjectDTO, - Workspace + Workspace, + WorkspaceEnv } from "./types"; export const fetchWorkspaceById = async (workspaceId: string) => { @@ -396,12 +397,16 @@ export const useDeleteWorkspace = () => { export const useCreateWsEnvironment = () => { const queryClient = useQueryClient(); - return useMutation({ - mutationFn: ({ workspaceId, name, slug }) => { - return apiRequest.post(`/api/v1/workspace/${workspaceId}/environments`, { - name, - slug - }); + return useMutation({ + mutationFn: async ({ workspaceId, name, slug }) => { + const { data } = await apiRequest.post<{ environment: WorkspaceEnv }>( + `/api/v1/workspace/${workspaceId}/environments`, + { + name, + slug + } + ); + return data.environment; }, onSuccess: () => { queryClient.invalidateQueries({ diff --git a/frontend/src/hooks/useResizableColWidth.tsx b/frontend/src/hooks/useResizableColWidth.tsx index f2ad80625..6af269217 100644 --- a/frontend/src/hooks/useResizableColWidth.tsx +++ b/frontend/src/hooks/useResizableColWidth.tsx @@ -1,12 +1,13 @@ -import { MouseEvent, useCallback, useEffect, useRef, useState } from "react"; +import { MouseEvent, RefObject, useCallback, useEffect, useRef, useState } from "react"; type Params = { minWidth: number; maxWidth: number; initialWidth: number; + ref: RefObject; }; -export const useResizableColWidth = ({ minWidth, maxWidth, initialWidth }: Params) => { +export const useResizableColWidth = ({ minWidth, maxWidth, initialWidth, ref }: Params) => { const [colWidth, setColWidth] = useState(initialWidth); const [isResizing, setIsResizing] = useState(false); const startX = useRef(0); @@ -63,6 +64,28 @@ export const useResizableColWidth = ({ minWidth, maxWidth, initialWidth }: Param }; }, [isResizing, handleMouseMove, handleMouseUp]); + useEffect(() => { + const element = ref?.current; + if (!element) return; + + const handleResize = () => { + if (colWidth > maxWidth) { + setColWidth(Math.max(maxWidth, minWidth)); + } else if (ref.current?.clientWidth && colWidth > ref.current.clientWidth * 0.9) { + // this else is a fallback to ensure col is always visible + setColWidth(initialWidth); + } + }; + + const resizeObserver = new ResizeObserver(handleResize); + resizeObserver.observe(element); + + // eslint-disable-next-line consistent-return + return () => { + resizeObserver.disconnect(); + }; + }, [ref, maxWidth, colWidth]); + return { colWidth, handleMouseDown, diff --git a/frontend/src/hooks/utils/secrets-overview.tsx b/frontend/src/hooks/utils/secrets-overview.tsx index e136df3f2..d77b41f5f 100644 --- a/frontend/src/hooks/utils/secrets-overview.tsx +++ b/frontend/src/hooks/utils/secrets-overview.tsx @@ -130,7 +130,11 @@ export const useSecretOverview = (secrets: DashboardProjectSecretsOverview["secr const getEnvSecretKeyCount = useCallback( (env: string) => { - return secrets?.filter((secret) => secret.env === env).length ?? 0; + return ( + secrets?.filter((secret) => + secret.sourceEnv ? secret.sourceEnv === env : secret.env === env + ).length ?? 0 + ); }, [secrets] ); diff --git a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx index a46578726..5c37d4404 100644 --- a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx @@ -240,6 +240,13 @@ export const Navbar = () => { return; } + if (org.googleSsoAuthEnforced) { + await logout.mutateAsync(); + window.open(`/api/v1/sso/redirect/google?org_slug=${org.slug}`); + window.close(); + return; + } + handleOrgChange(org?.id); }} variant="plain" diff --git a/frontend/src/layouts/ProjectLayout/components/AssumePrivilegeModeBanner/AssumePrivilegeModeBanner.tsx b/frontend/src/layouts/ProjectLayout/components/AssumePrivilegeModeBanner/AssumePrivilegeModeBanner.tsx index 2b4fcff80..995083ee7 100644 --- a/frontend/src/layouts/ProjectLayout/components/AssumePrivilegeModeBanner/AssumePrivilegeModeBanner.tsx +++ b/frontend/src/layouts/ProjectLayout/components/AssumePrivilegeModeBanner/AssumePrivilegeModeBanner.tsx @@ -36,7 +36,10 @@ export const AssumePrivilegeModeBanner = () => { }, { onSuccess: () => { - const url = getProjectHomePage(currentWorkspace.type); + const url = getProjectHomePage( + currentWorkspace.type, + currentWorkspace.environments + ); window.location.href = url.replace("$projectId", currentWorkspace.id); } } diff --git a/frontend/src/layouts/ProjectLayout/components/ProjectSelect/ProjectSelect.tsx b/frontend/src/layouts/ProjectLayout/components/ProjectSelect/ProjectSelect.tsx index b66717a5c..ebc28a227 100644 --- a/frontend/src/layouts/ProjectLayout/components/ProjectSelect/ProjectSelect.tsx +++ b/frontend/src/layouts/ProjectLayout/components/ProjectSelect/ProjectSelect.tsx @@ -101,7 +101,7 @@ export const ProjectSelect = () => {
{ // to reproduce change this back to router.push and switch between two projects with different env count // look into this on dashboard revamp const url = linkOptions({ - to: getProjectHomePage(workspace.type), + to: getProjectHomePage(workspace.type, workspace.environments), params: { projectId: workspace.id } diff --git a/frontend/src/layouts/SecretManagerLayout/SecretManagerLayout.tsx b/frontend/src/layouts/SecretManagerLayout/SecretManagerLayout.tsx index ad31d9017..0584e8e9c 100644 --- a/frontend/src/layouts/SecretManagerLayout/SecretManagerLayout.tsx +++ b/frontend/src/layouts/SecretManagerLayout/SecretManagerLayout.tsx @@ -11,7 +11,7 @@ import { faVault } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Link, Outlet } from "@tanstack/react-router"; +import { Link, Outlet, useLocation } from "@tanstack/react-router"; import { motion } from "framer-motion"; import { Badge, Lottie, Menu, MenuGroup, MenuItem } from "@app/components/v2"; @@ -31,6 +31,7 @@ export const SecretManagerLayout = () => { const { t } = useTranslation(); const workspaceId = currentWorkspace?.id || ""; const projectSlug = currentWorkspace?.slug || ""; + const location = useLocation(); const { data: secretApprovalReqCount } = useGetSecretApprovalRequestCount({ workspaceId @@ -73,11 +74,21 @@ export const SecretManagerLayout = () => { {({ isActive }) => ( - +
diff --git a/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx b/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx index f331e4bda..9e6850f82 100644 --- a/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx +++ b/frontend/src/pages/auth/SelectOrgPage/SelectOrgSection.tsx @@ -82,25 +82,40 @@ export const SelectOrganizationSection = () => { } } - if (organization.authEnforced && !canBypassOrgAuth) { + if ((organization.authEnforced || organization.googleSsoAuthEnforced) && !canBypassOrgAuth) { + const authToken = jwtDecode(getAuthToken()) as { authMethod: AuthMethod }; + // org has an org-level auth method enabled (e.g. SAML) // -> logout + redirect to SAML SSO - await logout.mutateAsync(); let url = ""; if (organization.orgAuthMethod === AuthMethod.OIDC) { url = `/api/v1/sso/oidc/login?orgSlug=${organization.slug}${ callbackPort ? `&callbackPort=${callbackPort}` : "" }`; - } else { + } else if (organization.orgAuthMethod === AuthMethod.SAML) { url = `/api/v1/sso/redirect/saml2/organizations/${organization.slug}`; if (callbackPort) { url += `?callback_port=${callbackPort}`; } + } else if ( + organization.googleSsoAuthEnforced && + authToken.authMethod !== AuthMethod.GOOGLE + ) { + url = `/api/v1/sso/redirect/google?org_slug=${organization.slug}`; + + if (callbackPort) { + url += `&callback_port=${callbackPort}`; + } } - window.location.href = url; - return; + // we are conditionally checking if the url is set because it may not be set if google SSO is enforced, but the user is already logged in with google SSO + // see line 103-106 + if (url) { + await logout.mutateAsync(); + window.location.href = url; + return; + } } const { token, isMfaEnabled, mfaMethod } = await selectOrg diff --git a/frontend/src/pages/auth/SignUpSsoPage/components/EmailConfirmationStep/EmailConfirmationStep.tsx b/frontend/src/pages/auth/SignUpSsoPage/components/EmailConfirmationStep/EmailConfirmationStep.tsx index a200d3f7c..983b1ec61 100644 --- a/frontend/src/pages/auth/SignUpSsoPage/components/EmailConfirmationStep/EmailConfirmationStep.tsx +++ b/frontend/src/pages/auth/SignUpSsoPage/components/EmailConfirmationStep/EmailConfirmationStep.tsx @@ -114,7 +114,16 @@ export const EmailConfirmationStep = ({ const resendCode = async () => { try { - await sendEmailVerificationCode(username); + const queryParams = new URLSearchParams(window.location.search); + const token = queryParams.get("token"); + if (!token) { + createNotification({ + text: "Failed to resend code, no token found", + type: "error" + }); + return; + } + await sendEmailVerificationCode(token); createNotification({ text: "Successfully resent code", type: "success" diff --git a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx index e0bc547e0..68757f549 100644 --- a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx +++ b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx @@ -42,29 +42,77 @@ import { import { UsePopUpState } from "@app/hooks/usePopUp"; import { slugSchema } from "@app/lib/schemas"; -const schema = z +const REQUIRED_EAB_DIRECTORIES = [ + "https://acme.digicert.com/v2/acme/directory", + "https://acme.zerossl.com/v2/DV90", + "https://acme.ssl.com/sslcom-dv-rsa", + "https://acme.ssl.com/sslcom-dv-ecc", + "https://dv.acme-v02.api.pki.goog/directory", + "https://acme.sectigo.com/v2/OV", + "https://acme.sectigo.com/v2/EV", + "https://acme.cisco.com/ACMEv2/directory" +]; + +const baseSchema = z.object({ + type: z.nativeEnum(CaType), + name: slugSchema({ + field: "Name" + }), + enableDirectIssuance: z.boolean(), + status: z.nativeEnum(CaStatus) +}); + +const acmeConfigurationSchema = z .object({ - type: z.nativeEnum(CaType), - name: slugSchema({ - field: "Name" + dnsAppConnection: z.object({ + id: z.string(), + name: z.string() }), - enableDirectIssuance: z.boolean(), - status: z.nativeEnum(CaStatus), - configuration: z.object({ - dnsAppConnection: z.object({ - id: z.string(), - name: z.string() - }), - // currently specific to Route53 & Cloudflare but can be extended to others by differentiating via the provider property - dnsProviderConfig: z.object({ - provider: z.nativeEnum(AcmeDnsProvider), - hostedZoneId: z.string() - }), - directoryUrl: z.string(), - accountEmail: z.string() - }) + dnsProviderConfig: z.object({ + provider: z.nativeEnum(AcmeDnsProvider), + hostedZoneId: z.string() + }), + directoryUrl: z.string(), + accountEmail: z.string(), + eabKid: z.string().optional(), + eabHmacKey: z.string().optional() }) - .required(); + .superRefine((data, ctx) => { + if (REQUIRED_EAB_DIRECTORIES.includes(data.directoryUrl)) { + if (!data.eabKid || data.eabKid.trim() === "") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "EAB Key Identifier (KID) is required for this directory URL", + path: ["eabKid"] + }); + } + if (!data.eabHmacKey || data.eabHmacKey.trim() === "") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "EAB HMAC Key is required for this directory URL", + path: ["eabHmacKey"] + }); + } + } + }); + +const azureAdCsConfigurationSchema = z.object({ + azureAdcsConnection: z.object({ + id: z.string(), + name: z.string() + }) +}); + +const schema = z.discriminatedUnion("type", [ + baseSchema.extend({ + type: z.literal(CaType.ACME), + configuration: acmeConfigurationSchema + }), + baseSchema.extend({ + type: z.literal(CaType.AZURE_AD_CS), + configuration: azureAdCsConfigurationSchema + }) +]); export type FormData = z.infer; @@ -73,12 +121,15 @@ type Props = { handlePopUpToggle: (popUpName: keyof UsePopUpState<["ca"]>, state?: boolean) => void; }; -const caTypes = [{ label: "ACME", value: CaType.ACME }]; +const caTypes = [ + { label: "ACME", value: CaType.ACME }, + { label: "Azure AD Certificate Service", value: CaType.AZURE_AD_CS } +]; export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { const { currentWorkspace } = useWorkspace(); - const { data: ca } = useGetCa({ + const { data: ca, isLoading: isCaLoading } = useGetCa({ caName: (popUp?.ca?.data as { name: string })?.name || "", projectId: currentWorkspace?.id || "", type: (popUp?.ca?.data as { type: CaType })?.type || "" @@ -94,29 +145,60 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { formState: { isSubmitting }, watch } = useForm({ - resolver: zodResolver(schema), - defaultValues: { - type: CaType.ACME, - name: "", - status: CaStatus.ACTIVE, - enableDirectIssuance: true, - configuration: { - dnsAppConnection: { - id: "", - name: "" - }, - dnsProviderConfig: { - provider: AcmeDnsProvider.ROUTE53, - hostedZoneId: "" - }, - directoryUrl: "", - accountEmail: "" - } - } + resolver: zodResolver(schema) }); const caType = watch("type"); - const dnsProvider = watch("configuration.dnsProviderConfig.provider"); + const configuration = watch("configuration"); + const dnsProvider = + caType === CaType.ACME && configuration && "dnsProviderConfig" in configuration + ? configuration.dnsProviderConfig.provider + : undefined; + const directoryUrl = + caType === CaType.ACME && configuration && "directoryUrl" in configuration + ? configuration.directoryUrl + : undefined; + + useEffect(() => { + const initialType = (popUp?.ca?.data as { type: CaType })?.type; + if (!ca && popUp?.ca?.isOpen) { + if (initialType === CaType.AZURE_AD_CS) { + reset({ + type: CaType.AZURE_AD_CS, + name: "", + status: CaStatus.ACTIVE, + enableDirectIssuance: false, + configuration: { + azureAdcsConnection: { + id: "", + name: "" + } + } + }); + } else { + reset({ + type: CaType.ACME, + name: "", + status: CaStatus.ACTIVE, + enableDirectIssuance: true, + configuration: { + dnsAppConnection: { + id: "", + name: "" + }, + dnsProviderConfig: { + provider: AcmeDnsProvider.ROUTE53, + hostedZoneId: "" + }, + directoryUrl: "", + accountEmail: "", + eabKid: "", + eabHmacKey: "" + } + }); + } + } + }, [popUp?.ca?.isOpen, popUp?.ca?.data, reset, ca]); const { data: availableRoute53Connections, isPending: isRoute53Pending } = useListAvailableAppConnections(AppConnection.AWS, { @@ -128,25 +210,42 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { enabled: caType === CaType.ACME }); - const availableConnections: TAvailableAppConnection[] = useMemo( - () => [...(availableRoute53Connections || []), ...(availableCloudflareConnections || [])], - [availableRoute53Connections, availableCloudflareConnections] - ); + const { data: availableAzureConnections, isPending: isAzurePending } = + useListAvailableAppConnections(AppConnection.AzureADCS, { + enabled: caType === CaType.AZURE_AD_CS + }); - const isPending = isRoute53Pending || isCloudflarePending; + const availableConnections: TAvailableAppConnection[] = useMemo(() => { + if (caType === CaType.AZURE_AD_CS) { + return availableAzureConnections || []; + } + return [...(availableRoute53Connections || []), ...(availableCloudflareConnections || [])]; + }, [ + caType, + availableRoute53Connections, + availableCloudflareConnections, + availableAzureConnections + ]); - const dnsAppConnection = watch("configuration.dnsAppConnection"); + const isPending = + isRoute53Pending || isCloudflarePending || (isAzurePending && caType === CaType.AZURE_AD_CS); + + const dnsAppConnection = + caType === CaType.ACME && configuration && "dnsAppConnection" in configuration + ? configuration.dnsAppConnection + : { id: "", name: "" }; const { data: cloudflareZones = [], isPending: isZonesPending } = useCloudflareConnectionListZones(dnsAppConnection.id, { enabled: dnsProvider === AcmeDnsProvider.Cloudflare && !!dnsAppConnection.id }); + // Populate form with CA data when editing useEffect(() => { - if (ca) { - if (ca.type !== CaType.INTERNAL && availableConnections?.length) { + if (ca && !isCaLoading) { + if (ca.type === CaType.ACME && availableConnections?.length) { const selectedConnection = availableConnections?.find( - (connection) => connection.id === ca?.configuration.dnsAppConnectionId + (connection) => connection.id === ca.configuration.dnsAppConnectionId ); reset({ @@ -164,35 +263,70 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { hostedZoneId: ca.configuration.dnsProviderConfig.hostedZoneId }, directoryUrl: ca.configuration.directoryUrl, - accountEmail: ca.configuration.accountEmail + accountEmail: ca.configuration.accountEmail, + eabKid: ca.configuration.eabKid, + eabHmacKey: ca.configuration.eabHmacKey + } + }); + } else if (ca.type === CaType.AZURE_AD_CS && availableConnections?.length) { + const selectedConnection = availableConnections?.find( + (connection) => connection.id === ca.configuration.azureAdcsConnectionId + ); + + reset({ + type: ca.type, + name: ca.name, + status: ca.status, + enableDirectIssuance: false, + configuration: { + azureAdcsConnection: { + id: ca.configuration.azureAdcsConnectionId, + name: selectedConnection?.name || "" + } } }); } } - }, [ca, availableConnections]); + }, [ca, availableConnections, reset, isCaLoading]); const onFormSubmit = async ({ type, name, enableDirectIssuance, status, - configuration + configuration: formConfiguration }: FormData) => { try { if (!currentWorkspace?.slug) return; - if (ca && type !== CaType.INTERNAL) { + let configPayload: any; + + if (type === CaType.ACME && "dnsAppConnection" in formConfiguration) { + configPayload = { + dnsProviderConfig: formConfiguration.dnsProviderConfig, + directoryUrl: formConfiguration.directoryUrl, + accountEmail: formConfiguration.accountEmail, + dnsAppConnectionId: formConfiguration.dnsAppConnection.id, + eabKid: formConfiguration.eabKid, + eabHmacKey: formConfiguration.eabHmacKey + }; + } else if (type === CaType.AZURE_AD_CS && "azureAdcsConnection" in formConfiguration) { + configPayload = { + azureAdcsConnectionId: formConfiguration.azureAdcsConnection.id + }; + } else { + throw new Error("Invalid certificate authority configuration"); + } + + if (ca) { await updateMutateAsync({ caName: ca.name, projectId: currentWorkspace.id, name, type, status, - enableDirectIssuance, - configuration: { - ...configuration, - dnsAppConnectionId: configuration.dnsAppConnection.id - } + enableDirectIssuance: type === CaType.AZURE_AD_CS ? false : enableDirectIssuance, + configuration: configPayload }); } else { await createMutateAsync({ @@ -200,11 +334,8 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { name, type, status, - enableDirectIssuance, - configuration: { - ...configuration, - dnsAppConnectionId: configuration.dnsAppConnection.id - } + enableDirectIssuance: type === CaType.AZURE_AD_CS ? false : enableDirectIssuance, + configuration: configPayload }); } @@ -232,7 +363,7 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { handlePopUpToggle("ca", isOpen); }} > - +
{ca && ( @@ -307,7 +438,11 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { ( { )} /> + ( + + + + )} + /> + ( + + + + )} + /> )} - { - return ( - - field.onChange(value)} - isChecked={field.value} - > -

Enable Direct Issuance

-
+ {caType === CaType.AZURE_AD_CS && ( + ( + + { + onChange(newValue); + }} + isLoading={isPending} + options={availableConnections} + placeholder="Select connection..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> - ); - }} - /> + )} + control={control} + name="configuration.azureAdcsConnection" + /> + )} + {caType === CaType.ACME && ( + { + return ( + + field.onChange(value)} + isChecked={field.value} + > +

Enable Direct Issuance

+
+
+ ); + }} + /> + )}