diff --git a/.env.example b/.env.example index 05a888db0..059ec124f 100644 --- a/.env.example +++ b/.env.example @@ -123,8 +123,17 @@ INF_APP_CONNECTION_GITHUB_RADAR_APP_WEBHOOK_SECRET= INF_APP_CONNECTION_GCP_SERVICE_ACCOUNT_CREDENTIAL= # azure app connection -INF_APP_CONNECTION_AZURE_CLIENT_ID= -INF_APP_CONNECTION_AZURE_CLIENT_SECRET= +INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID= +INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_SECRET= + +INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID= +INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET= + +INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID= +INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_SECRET= + +INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_ID= +INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_SECRET= # datadog SHOULD_USE_DATADOG_TRACER= diff --git a/Dockerfile.fips.standalone-infisical b/Dockerfile.fips.standalone-infisical index d2b2a2d87..b95794832 100644 --- a/Dockerfile.fips.standalone-infisical +++ b/Dockerfile.fips.standalone-infisical @@ -145,7 +145,11 @@ RUN wget https://www.openssl.org/source/openssl-3.1.2.tar.gz \ && cd openssl-3.1.2 \ && ./Configure enable-fips \ && make \ - && make install_fips + && make install_fips \ + && cd / \ + && rm -rf /openssl-build \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* # Install Infisical CLI RUN curl -1sLf 'https://artifacts-cli.infisical.com/setup.deb.sh' | bash \ @@ -186,12 +190,11 @@ ENV NODE_ENV production ENV STANDALONE_BUILD true ENV STANDALONE_MODE true ENV ChrystokiConfigurationPath=/usr/safenet/lunaclient/ -ENV NODE_OPTIONS="--max-old-space-size=1024" +ENV NODE_OPTIONS="--max-old-space-size=8192 --force-fips" # FIPS mode of operation: ENV OPENSSL_CONF=/backend/nodejs.fips.cnf ENV OPENSSL_MODULES=/usr/local/lib/ossl-modules -ENV NODE_OPTIONS=--force-fips ENV FIPS_ENABLED=true diff --git a/backend/Dockerfile.dev.fips b/backend/Dockerfile.dev.fips index 977362e03..b954ccd50 100644 --- a/backend/Dockerfile.dev.fips +++ b/backend/Dockerfile.dev.fips @@ -59,7 +59,11 @@ RUN wget https://www.openssl.org/source/openssl-3.1.2.tar.gz \ && cd openssl-3.1.2 \ && ./Configure enable-fips \ && make \ - && make install_fips + && make install_fips \ + && cd / \ + && rm -rf /openssl-build \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* # ? App setup diff --git a/backend/package-lock.json b/backend/package-lock.json index a5c106540..cb9efa148 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "backend", "version": "1.0.0", + "hasInstallScript": true, "license": "ISC", "dependencies": { "@aws-sdk/client-elasticache": "^3.637.0", @@ -61,7 +62,7 @@ "ajv": "^8.12.0", "argon2": "^0.31.2", "aws-sdk": "^2.1553.0", - "axios": "^1.6.7", + "axios": "^1.11.0", "axios-retry": "^4.0.0", "bcrypt": "^5.1.1", "botbuilder": "^4.23.2", @@ -13699,14 +13700,16 @@ } }, "node_modules/@types/request/node_modules/form-data": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.2.tgz", - "integrity": "sha512-GgwY0PS7DbXqajuGf4OYlsrIu3zgxD6Vvql43IBhm6MahqA5SK/7mwhtNj2AdH2z35YR34ujJ7BN+3fFC3jP5Q==", + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.5.tgz", + "integrity": "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", - "combined-stream": "^1.0.6", - "mime-types": "^2.1.12", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.35", "safe-buffer": "^5.2.1" }, "engines": { @@ -15230,13 +15233,13 @@ } }, "node_modules/axios": { - "version": "1.7.9", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz", - "integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", + "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", + "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, @@ -18761,13 +18764,15 @@ } }, "node_modules/form-data": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", - "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" }, "engines": { diff --git a/backend/package.json b/backend/package.json index bd355dd22..01bb0c42a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -181,7 +181,7 @@ "ajv": "^8.12.0", "argon2": "^0.31.2", "aws-sdk": "^2.1553.0", - "axios": "^1.6.7", + "axios": "^1.11.0", "axios-retry": "^4.0.0", "bcrypt": "^5.1.1", "botbuilder": "^4.23.2", diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 7fac20c0e..185a32356 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -489,6 +489,11 @@ import { TWorkflowIntegrationsInsert, TWorkflowIntegrationsUpdate } from "@app/db/schemas"; +import { + TAccessApprovalPoliciesEnvironments, + TAccessApprovalPoliciesEnvironmentsInsert, + TAccessApprovalPoliciesEnvironmentsUpdate +} from "@app/db/schemas/access-approval-policies-environments"; import { TIdentityLdapAuths, TIdentityLdapAuthsInsert, @@ -510,6 +515,11 @@ import { TRemindersRecipientsInsert, TRemindersRecipientsUpdate } from "@app/db/schemas/reminders-recipients"; +import { + TSecretApprovalPoliciesEnvironments, + TSecretApprovalPoliciesEnvironmentsInsert, + TSecretApprovalPoliciesEnvironmentsUpdate +} from "@app/db/schemas/secret-approval-policies-environments"; import { TSecretReminderRecipients, TSecretReminderRecipientsInsert, @@ -887,6 +897,12 @@ declare module "knex/types/tables" { TAccessApprovalPoliciesBypassersUpdate >; + [TableName.AccessApprovalPolicyEnvironment]: KnexOriginal.CompositeTableType< + TAccessApprovalPoliciesEnvironments, + TAccessApprovalPoliciesEnvironmentsInsert, + TAccessApprovalPoliciesEnvironmentsUpdate + >; + [TableName.AccessApprovalRequest]: KnexOriginal.CompositeTableType< TAccessApprovalRequests, TAccessApprovalRequestsInsert, @@ -935,6 +951,11 @@ declare module "knex/types/tables" { TSecretApprovalRequestSecretTagsInsert, TSecretApprovalRequestSecretTagsUpdate >; + [TableName.SecretApprovalPolicyEnvironment]: KnexOriginal.CompositeTableType< + TSecretApprovalPoliciesEnvironments, + TSecretApprovalPoliciesEnvironmentsInsert, + TSecretApprovalPoliciesEnvironmentsUpdate + >; [TableName.SecretRotation]: KnexOriginal.CompositeTableType< TSecretRotations, TSecretRotationsInsert, diff --git a/backend/src/db/migrations/20250722152841_add-policies-environments-table.ts b/backend/src/db/migrations/20250722152841_add-policies-environments-table.ts new file mode 100644 index 000000000..57ec13203 --- /dev/null +++ b/backend/src/db/migrations/20250722152841_add-policies-environments-table.ts @@ -0,0 +1,96 @@ +import { Knex } from "knex"; + +import { selectAllTableCols } from "@app/lib/knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.AccessApprovalPolicyEnvironment))) { + await knex.schema.createTable(TableName.AccessApprovalPolicyEnvironment, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.uuid("policyId").notNullable(); + t.foreign("policyId").references("id").inTable(TableName.AccessApprovalPolicy).onDelete("CASCADE"); + t.uuid("envId").notNullable(); + t.foreign("envId").references("id").inTable(TableName.Environment); + t.timestamps(true, true, true); + t.unique(["policyId", "envId"]); + }); + + await createOnUpdateTrigger(knex, TableName.AccessApprovalPolicyEnvironment); + + const existingAccessApprovalPolicies = await knex(TableName.AccessApprovalPolicy) + .select(selectAllTableCols(TableName.AccessApprovalPolicy)) + .whereNotNull(`${TableName.AccessApprovalPolicy}.envId`); + + const accessApprovalPolicies = existingAccessApprovalPolicies.map(async (policy) => { + await knex(TableName.AccessApprovalPolicyEnvironment).insert({ + policyId: policy.id, + envId: policy.envId + }); + }); + + await Promise.all(accessApprovalPolicies); + } + if (!(await knex.schema.hasTable(TableName.SecretApprovalPolicyEnvironment))) { + await knex.schema.createTable(TableName.SecretApprovalPolicyEnvironment, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.uuid("policyId").notNullable(); + t.foreign("policyId").references("id").inTable(TableName.SecretApprovalPolicy).onDelete("CASCADE"); + t.uuid("envId").notNullable(); + t.foreign("envId").references("id").inTable(TableName.Environment); + t.timestamps(true, true, true); + t.unique(["policyId", "envId"]); + }); + + await createOnUpdateTrigger(knex, TableName.SecretApprovalPolicyEnvironment); + + const existingSecretApprovalPolicies = await knex(TableName.SecretApprovalPolicy) + .select(selectAllTableCols(TableName.SecretApprovalPolicy)) + .whereNotNull(`${TableName.SecretApprovalPolicy}.envId`); + + const secretApprovalPolicies = existingSecretApprovalPolicies.map(async (policy) => { + await knex(TableName.SecretApprovalPolicyEnvironment).insert({ + policyId: policy.id, + envId: policy.envId + }); + }); + + await Promise.all(secretApprovalPolicies); + } + + await knex.schema.alterTable(TableName.AccessApprovalPolicy, (t) => { + t.dropForeign(["envId"]); + + // Add the new foreign key constraint with ON DELETE SET NULL + t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("SET NULL"); + }); + + await knex.schema.alterTable(TableName.SecretApprovalPolicy, (t) => { + t.dropForeign(["envId"]); + + // Add the new foreign key constraint with ON DELETE SET NULL + t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("SET NULL"); + }); +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.AccessApprovalPolicyEnvironment)) { + await knex.schema.dropTableIfExists(TableName.AccessApprovalPolicyEnvironment); + await dropOnUpdateTrigger(knex, TableName.AccessApprovalPolicyEnvironment); + } + if (await knex.schema.hasTable(TableName.SecretApprovalPolicyEnvironment)) { + await knex.schema.dropTableIfExists(TableName.SecretApprovalPolicyEnvironment); + await dropOnUpdateTrigger(knex, TableName.SecretApprovalPolicyEnvironment); + } + + await knex.schema.alterTable(TableName.AccessApprovalPolicy, (t) => { + t.dropForeign(["envId"]); + t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("CASCADE"); + }); + + await knex.schema.alterTable(TableName.SecretApprovalPolicy, (t) => { + t.dropForeign(["envId"]); + t.foreign("envId").references("id").inTable(TableName.Environment).onDelete("CASCADE"); + }); +} diff --git a/backend/src/db/migrations/20250725144940_fix-secret-reminders-migration.ts b/backend/src/db/migrations/20250725144940_fix-secret-reminders-migration.ts new file mode 100644 index 000000000..b720c97ae --- /dev/null +++ b/backend/src/db/migrations/20250725144940_fix-secret-reminders-migration.ts @@ -0,0 +1,111 @@ +/* eslint-disable no-await-in-loop */ +import { Knex } from "knex"; + +import { chunkArray } from "@app/lib/fn"; +import { logger } from "@app/lib/logger"; + +import { TableName } from "../schemas"; +import { TReminders, TRemindersInsert } from "../schemas/reminders"; + +export async function up(knex: Knex): Promise { + logger.info("Initializing secret reminders migration"); + const hasReminderTable = await knex.schema.hasTable(TableName.Reminder); + + if (hasReminderTable) { + const secretsWithLatestVersions = await knex(TableName.SecretV2) + .whereNotNull(`${TableName.SecretV2}.reminderRepeatDays`) + .whereRaw(`"${TableName.SecretV2}"."reminderRepeatDays" > 0`) + .innerJoin(TableName.SecretVersionV2, (qb) => { + void qb + .on(`${TableName.SecretVersionV2}.secretId`, "=", `${TableName.SecretV2}.id`) + .andOn(`${TableName.SecretVersionV2}.reminderRepeatDays`, "=", `${TableName.SecretV2}.reminderRepeatDays`); + }) + .whereIn([`${TableName.SecretVersionV2}.secretId`, `${TableName.SecretVersionV2}.version`], (qb) => { + void qb + .select(["v2.secretId", knex.raw("MIN(v2.version) as version")]) + .from(`${TableName.SecretVersionV2} as v2`) + .innerJoin(`${TableName.SecretV2} as s2`, "v2.secretId", "s2.id") + .whereRaw(`v2."reminderRepeatDays" = s2."reminderRepeatDays"`) + .whereNotNull("v2.reminderRepeatDays") + .whereRaw(`v2."reminderRepeatDays" > 0`) + .groupBy("v2.secretId"); + }) + // Add LEFT JOIN with Reminder table to check for existing reminders + .leftJoin(TableName.Reminder, `${TableName.Reminder}.secretId`, `${TableName.SecretV2}.id`) + // Only include secrets that don't already have reminders + .whereNull(`${TableName.Reminder}.secretId`) + .select( + knex.ref("id").withSchema(TableName.SecretV2).as("secretId"), + knex.ref("reminderRepeatDays").withSchema(TableName.SecretV2).as("reminderRepeatDays"), + knex.ref("reminderNote").withSchema(TableName.SecretV2).as("reminderNote"), + knex.ref("createdAt").withSchema(TableName.SecretVersionV2).as("createdAt") + ); + + logger.info(`Found ${secretsWithLatestVersions.length} reminders to migrate`); + + const reminderInserts: TRemindersInsert[] = []; + if (secretsWithLatestVersions.length > 0) { + secretsWithLatestVersions.forEach((secret) => { + if (!secret.reminderRepeatDays) return; + + const now = new Date(); + const createdAt = new Date(secret.createdAt); + let nextReminderDate = new Date(createdAt); + nextReminderDate.setDate(nextReminderDate.getDate() + secret.reminderRepeatDays); + + // If the next reminder date is in the past, calculate the proper next occurrence + if (nextReminderDate < now) { + const daysSinceCreation = Math.floor((now.getTime() - createdAt.getTime()) / (1000 * 60 * 60 * 24)); + const daysIntoCurrentCycle = daysSinceCreation % secret.reminderRepeatDays; + const daysUntilNextReminder = secret.reminderRepeatDays - daysIntoCurrentCycle; + + nextReminderDate = new Date(now); + nextReminderDate.setDate(now.getDate() + daysUntilNextReminder); + } + + reminderInserts.push({ + secretId: secret.secretId, + message: secret.reminderNote, + repeatDays: secret.reminderRepeatDays, + nextReminderDate + }); + }); + + const commitBatches = chunkArray(reminderInserts, 2000); + for (const commitBatch of commitBatches) { + const insertedReminders = (await knex + .batchInsert(TableName.Reminder, commitBatch) + .returning("*")) as TReminders[]; + + const insertedReminderSecretIds = insertedReminders.map((reminder) => reminder.secretId).filter(Boolean); + + const recipients = await knex(TableName.SecretReminderRecipients) + .whereRaw(`??.?? IN (${insertedReminderSecretIds.map(() => "?").join(",")})`, [ + TableName.SecretReminderRecipients, + "secretId", + ...insertedReminderSecretIds + ]) + .select( + knex.ref("userId").withSchema(TableName.SecretReminderRecipients).as("userId"), + knex.ref("secretId").withSchema(TableName.SecretReminderRecipients).as("secretId") + ); + const reminderRecipients = recipients.map((recipient) => ({ + reminderId: insertedReminders.find((reminder) => reminder.secretId === recipient.secretId)?.id, + userId: recipient.userId + })); + + const filteredRecipients = reminderRecipients.filter((recipient) => Boolean(recipient.reminderId)); + await knex.batchInsert(TableName.ReminderRecipient, filteredRecipients); + } + logger.info(`Successfully migrated ${reminderInserts.length} secret reminders`); + } + + logger.info("Secret reminders migration completed"); + } else { + logger.warn("Reminder table does not exist, skipping migration"); + } +} + +export async function down(): Promise { + logger.info("Rollback not implemented for secret reminders fix migration"); +} diff --git a/backend/src/db/migrations/utils/env-config.ts b/backend/src/db/migrations/utils/env-config.ts index debaea03f..de32f4db9 100644 --- a/backend/src/db/migrations/utils/env-config.ts +++ b/backend/src/db/migrations/utils/env-config.ts @@ -53,7 +53,7 @@ export const getMigrationEnvConfig = async (superAdminDAL: TSuperAdminDALFactory let envCfg = Object.freeze(parsedEnv.data); - const fipsEnabled = await crypto.initialize(superAdminDAL); + const fipsEnabled = await crypto.initialize(superAdminDAL, envCfg); // Fix for 128-bit entropy encryption key expansion issue: // In FIPS it is not ideal to expand a 128-bit key into 256-bit. We solved this issue in the past by creating the ROOT_ENCRYPTION_KEY. diff --git a/backend/src/db/schemas/access-approval-policies-environments.ts b/backend/src/db/schemas/access-approval-policies-environments.ts new file mode 100644 index 000000000..fa2a859c2 --- /dev/null +++ b/backend/src/db/schemas/access-approval-policies-environments.ts @@ -0,0 +1,25 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const AccessApprovalPoliciesEnvironmentsSchema = z.object({ + id: z.string().uuid(), + policyId: z.string().uuid(), + envId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TAccessApprovalPoliciesEnvironments = z.infer; +export type TAccessApprovalPoliciesEnvironmentsInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TAccessApprovalPoliciesEnvironmentsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 2e71486bf..55ec12faa 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -100,6 +100,7 @@ export enum TableName { AccessApprovalPolicyBypasser = "access_approval_policies_bypassers", AccessApprovalRequest = "access_approval_requests", AccessApprovalRequestReviewer = "access_approval_requests_reviewers", + AccessApprovalPolicyEnvironment = "access_approval_policies_environments", SecretApprovalPolicy = "secret_approval_policies", SecretApprovalPolicyApprover = "secret_approval_policies_approvers", SecretApprovalPolicyBypasser = "secret_approval_policies_bypassers", @@ -107,6 +108,7 @@ export enum TableName { SecretApprovalRequestReviewer = "secret_approval_requests_reviewers", SecretApprovalRequestSecret = "secret_approval_requests_secrets", SecretApprovalRequestSecretTag = "secret_approval_request_secret_tags", + SecretApprovalPolicyEnvironment = "secret_approval_policies_environments", SecretRotation = "secret_rotations", SecretRotationOutput = "secret_rotation_outputs", SamlConfig = "saml_configs", diff --git a/backend/src/db/schemas/secret-approval-policies-environments.ts b/backend/src/db/schemas/secret-approval-policies-environments.ts new file mode 100644 index 000000000..0420fe75d --- /dev/null +++ b/backend/src/db/schemas/secret-approval-policies-environments.ts @@ -0,0 +1,25 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const SecretApprovalPoliciesEnvironmentsSchema = z.object({ + id: z.string().uuid(), + policyId: z.string().uuid(), + envId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TSecretApprovalPoliciesEnvironments = z.infer; +export type TSecretApprovalPoliciesEnvironmentsInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TSecretApprovalPoliciesEnvironmentsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/ee/routes/v1/access-approval-policy-router.ts b/backend/src/ee/routes/v1/access-approval-policy-router.ts index 177f5e1fd..ef44344de 100644 --- a/backend/src/ee/routes/v1/access-approval-policy-router.ts +++ b/backend/src/ee/routes/v1/access-approval-policy-router.ts @@ -17,52 +17,66 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi rateLimit: writeLimit }, schema: { - body: z.object({ - projectSlug: z.string().trim(), - name: z.string().optional(), - secretPath: z.string().trim().min(1, { message: "Secret path cannot be empty" }).transform(removeTrailingSlash), - environment: z.string(), - approvers: z - .discriminatedUnion("type", [ - z.object({ - type: z.literal(ApproverType.Group), - id: z.string(), - sequence: z.number().int().default(1) - }), - z.object({ - type: z.literal(ApproverType.User), - id: z.string().optional(), - username: z.string().optional(), - sequence: z.number().int().default(1) + body: z + .object({ + projectSlug: z.string().trim(), + name: z.string().optional(), + secretPath: z + .string() + .trim() + .min(1, { message: "Secret path cannot be empty" }) + .transform(removeTrailingSlash), + environment: z.string().optional(), + environments: z.string().array().optional(), + approvers: z + .discriminatedUnion("type", [ + z.object({ + type: z.literal(ApproverType.Group), + id: z.string(), + sequence: z.number().int().default(1) + }), + z.object({ + type: z.literal(ApproverType.User), + id: z.string().optional(), + username: z.string().optional(), + sequence: z.number().int().default(1) + }) + ]) + .array() + .max(100, "Cannot have more than 100 approvers") + .min(1, { message: "At least one approver should be provided" }) + .refine( + // @ts-expect-error this is ok + (el) => el.every((i) => Boolean(i?.id) || Boolean(i?.username)), + "Must provide either username or id" + ), + bypassers: z + .discriminatedUnion("type", [ + z.object({ type: z.literal(BypasserType.Group), id: z.string() }), + z.object({ + type: z.literal(BypasserType.User), + id: z.string().optional(), + username: z.string().optional() + }) + ]) + .array() + .max(100, "Cannot have more than 100 bypassers") + .optional(), + approvalsRequired: z + .object({ + numberOfApprovals: z.number().int(), + stepNumber: z.number().int() }) - ]) - .array() - .max(100, "Cannot have more than 100 approvers") - .min(1, { message: "At least one approver should be provided" }) - .refine( - // @ts-expect-error this is ok - (el) => el.every((i) => Boolean(i?.id) || Boolean(i?.username)), - "Must provide either username or id" - ), - bypassers: z - .discriminatedUnion("type", [ - z.object({ type: z.literal(BypasserType.Group), id: z.string() }), - z.object({ type: z.literal(BypasserType.User), id: z.string().optional(), username: z.string().optional() }) - ]) - .array() - .max(100, "Cannot have more than 100 bypassers") - .optional(), - approvalsRequired: z - .object({ - numberOfApprovals: z.number().int(), - stepNumber: z.number().int() - }) - .array() - .optional(), - approvals: z.number().min(1).default(1), - enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard), - allowedSelfApprovals: z.boolean().default(true) - }), + .array() + .optional(), + approvals: z.number().min(1).default(1), + enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard), + allowedSelfApprovals: z.boolean().default(true) + }) + .refine( + (val) => Boolean(val.environment) || Boolean(val.environments), + "Must provide either environment or environments" + ), response: { 200: z.object({ approval: sapPubSchema @@ -78,7 +92,8 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi actorOrgId: req.permission.orgId, ...req.body, projectSlug: req.body.projectSlug, - name: req.body.name ?? `${req.body.environment}-${nanoid(3)}`, + name: + req.body.name ?? `${req.body.environment || req.body.environments?.join("-").substring(0, 250)}-${nanoid(3)}`, enforcementLevel: req.body.enforcementLevel }); return { approval }; @@ -211,6 +226,7 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi approvals: z.number().min(1).optional(), enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard), allowedSelfApprovals: z.boolean().default(true), + environments: z.array(z.string()).optional(), approvalsRequired: z .object({ numberOfApprovals: z.number().int(), diff --git a/backend/src/ee/routes/v1/secret-approval-policy-router.ts b/backend/src/ee/routes/v1/secret-approval-policy-router.ts index 46b2544b2..dc87b83f2 100644 --- a/backend/src/ee/routes/v1/secret-approval-policy-router.ts +++ b/backend/src/ee/routes/v1/secret-approval-policy-router.ts @@ -17,34 +17,45 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi rateLimit: writeLimit }, schema: { - body: z.object({ - workspaceId: z.string(), - name: z.string().optional(), - environment: z.string(), - secretPath: z - .string() - .min(1, { message: "Secret path cannot be empty" }) - .transform((val) => removeTrailingSlash(val)), - approvers: z - .discriminatedUnion("type", [ - z.object({ type: z.literal(ApproverType.Group), id: z.string() }), - z.object({ type: z.literal(ApproverType.User), id: z.string().optional(), username: z.string().optional() }) - ]) - .array() - .min(1, { message: "At least one approver should be provided" }) - .max(100, "Cannot have more than 100 approvers"), - bypassers: z - .discriminatedUnion("type", [ - z.object({ type: z.literal(BypasserType.Group), id: z.string() }), - z.object({ type: z.literal(BypasserType.User), id: z.string().optional(), username: z.string().optional() }) - ]) - .array() - .max(100, "Cannot have more than 100 bypassers") - .optional(), - approvals: z.number().min(1).default(1), - enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard), - allowedSelfApprovals: z.boolean().default(true) - }), + body: z + .object({ + workspaceId: z.string(), + name: z.string().optional(), + environment: z.string().optional(), + environments: z.string().array().optional(), + secretPath: z + .string() + .min(1, { message: "Secret path cannot be empty" }) + .transform((val) => removeTrailingSlash(val)), + approvers: z + .discriminatedUnion("type", [ + z.object({ type: z.literal(ApproverType.Group), id: z.string() }), + z.object({ + type: z.literal(ApproverType.User), + id: z.string().optional(), + username: z.string().optional() + }) + ]) + .array() + .min(1, { message: "At least one approver should be provided" }) + .max(100, "Cannot have more than 100 approvers"), + bypassers: z + .discriminatedUnion("type", [ + z.object({ type: z.literal(BypasserType.Group), id: z.string() }), + z.object({ + type: z.literal(BypasserType.User), + id: z.string().optional(), + username: z.string().optional() + }) + ]) + .array() + .max(100, "Cannot have more than 100 bypassers") + .optional(), + approvals: z.number().min(1).default(1), + enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard), + allowedSelfApprovals: z.boolean().default(true) + }) + .refine((data) => data.environment || data.environments, "At least one environment should be provided"), response: { 200: z.object({ approval: sapPubSchema @@ -60,7 +71,7 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi actorOrgId: req.permission.orgId, projectId: req.body.workspaceId, ...req.body, - name: req.body.name ?? `${req.body.environment}-${nanoid(3)}`, + name: req.body.name ?? `${req.body.environment || req.body.environments?.join(",")}-${nanoid(3)}`, enforcementLevel: req.body.enforcementLevel }); return { approval }; @@ -103,7 +114,8 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi .optional() .transform((val) => (val ? removeTrailingSlash(val) : undefined)), enforcementLevel: z.nativeEnum(EnforcementLevel).optional(), - allowedSelfApprovals: z.boolean().default(true) + allowedSelfApprovals: z.boolean().default(true), + environments: z.array(z.string()).optional() }), response: { 200: z.object({ diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts index 995534f8f..9baf762d6 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts @@ -26,6 +26,7 @@ export interface TAccessApprovalPolicyDALFactory >, customFilter?: { policyId?: string; + envId?: string; }, tx?: Knex ) => Promise< @@ -55,11 +56,6 @@ export interface TAccessApprovalPolicyDALFactory allowedSelfApprovals: boolean; secretPath: string; deletedAt?: Date | null | undefined; - environment: { - id: string; - name: string; - slug: string; - }; projectId: string; bypassers: ( | { @@ -72,6 +68,11 @@ export interface TAccessApprovalPolicyDALFactory type: BypasserType.Group; } )[]; + environments: { + id: string; + name: string; + slug: string; + }[]; }[] >; findById: ( @@ -95,11 +96,11 @@ export interface TAccessApprovalPolicyDALFactory allowedSelfApprovals: boolean; secretPath: string; deletedAt?: Date | null | undefined; - environment: { + environments: { id: string; name: string; slug: string; - }; + }[]; projectId: string; } | undefined @@ -143,6 +144,26 @@ export interface TAccessApprovalPolicyDALFactory } | undefined >; + findPolicyByEnvIdAndSecretPath: ( + { envIds, secretPath }: { envIds: string[]; secretPath: string }, + tx?: Knex + ) => Promise<{ + name: string; + id: string; + createdAt: Date; + updatedAt: Date; + approvals: number; + enforcementLevel: string; + allowedSelfApprovals: boolean; + secretPath: string; + deletedAt?: Date | null | undefined; + environments: { + id: string; + name: string; + slug: string; + }[]; + projectId: string; + }>; } export interface TAccessApprovalPolicyServiceFactory { @@ -367,6 +388,7 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient): TAccessApprovalPo filter: TFindFilter, customFilter?: { policyId?: string; + envId?: string; } ) => { const result = await tx(TableName.AccessApprovalPolicy) @@ -377,7 +399,17 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient): TAccessApprovalPo void qb.where(`${TableName.AccessApprovalPolicy}.id`, "=", customFilter.policyId); } }) - .join(TableName.Environment, `${TableName.AccessApprovalPolicy}.envId`, `${TableName.Environment}.id`) + .join( + TableName.AccessApprovalPolicyEnvironment, + `${TableName.AccessApprovalPolicy}.id`, + `${TableName.AccessApprovalPolicyEnvironment}.policyId` + ) + .join(TableName.Environment, `${TableName.AccessApprovalPolicyEnvironment}.envId`, `${TableName.Environment}.id`) + .where((qb) => { + if (customFilter?.envId) { + void qb.where(`${TableName.AccessApprovalPolicyEnvironment}.envId`, "=", customFilter.envId); + } + }) .leftJoin( TableName.AccessApprovalPolicyApprover, `${TableName.AccessApprovalPolicy}.id`, @@ -404,7 +436,7 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient): TAccessApprovalPo .select(tx.ref("bypasserGroupId").withSchema(TableName.AccessApprovalPolicyBypasser)) .select(tx.ref("name").withSchema(TableName.Environment).as("envName")) .select(tx.ref("slug").withSchema(TableName.Environment).as("envSlug")) - .select(tx.ref("id").withSchema(TableName.Environment).as("envId")) + .select(tx.ref("id").withSchema(TableName.Environment).as("environmentId")) .select(tx.ref("projectId").withSchema(TableName.Environment)) .select(selectAllTableCols(TableName.AccessApprovalPolicy)); @@ -448,6 +480,15 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient): TAccessApprovalPo sequence: approverSequence, approvalsRequired }) + }, + { + key: "environmentId", + label: "environments" as const, + mapper: ({ environmentId: id, envName, envSlug }) => ({ + id, + name: envName, + slug: envSlug + }) } ] }); @@ -470,11 +511,6 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient): TAccessApprovalPo data: docs, key: "id", parentMapper: (data) => ({ - environment: { - id: data.envId, - name: data.envName, - slug: data.envSlug - }, projectId: data.projectId, ...AccessApprovalPoliciesSchema.parse(data) // secretPath: data.secretPath || undefined, @@ -517,6 +553,15 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient): TAccessApprovalPo id, type: BypasserType.Group as const }) + }, + { + key: "environmentId", + label: "environments" as const, + mapper: ({ environmentId: id, envName, envSlug }) => ({ + id, + name: envName, + slug: envSlug + }) } ] }); @@ -545,14 +590,20 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient): TAccessApprovalPo // eslint-disable-next-line @typescript-eslint/no-misused-promises buildFindFilter( { - envId, secretPath }, TableName.AccessApprovalPolicy ) ) + .join( + TableName.AccessApprovalPolicyEnvironment, + `${TableName.AccessApprovalPolicyEnvironment}.policyId`, + `${TableName.AccessApprovalPolicy}.id` + ) + .where(`${TableName.AccessApprovalPolicyEnvironment}.envId`, "=", envId) .orderBy("deletedAt", "desc") .orderByRaw(`"deletedAt" IS NULL`) + .select(selectAllTableCols(TableName.AccessApprovalPolicy)) .first(); return result; @@ -561,5 +612,81 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient): TAccessApprovalPo } }; - return { ...accessApprovalPolicyOrm, find, findById, softDeleteById, findLastValidPolicy }; + const findPolicyByEnvIdAndSecretPath: TAccessApprovalPolicyDALFactory["findPolicyByEnvIdAndSecretPath"] = async ( + { envIds, secretPath }, + tx + ) => { + try { + const docs = await (tx || db.replicaNode())(TableName.AccessApprovalPolicy) + .join( + TableName.AccessApprovalPolicyEnvironment, + `${TableName.AccessApprovalPolicyEnvironment}.policyId`, + `${TableName.AccessApprovalPolicy}.id` + ) + .join( + TableName.Environment, + `${TableName.AccessApprovalPolicyEnvironment}.envId`, + `${TableName.Environment}.id` + ) + .where( + // eslint-disable-next-line @typescript-eslint/no-misused-promises + buildFindFilter( + { + $in: { + envId: envIds + } + }, + TableName.AccessApprovalPolicyEnvironment + ) + ) + .where( + // eslint-disable-next-line @typescript-eslint/no-misused-promises + buildFindFilter( + { + secretPath + }, + TableName.AccessApprovalPolicy + ) + ) + .whereNull(`${TableName.AccessApprovalPolicy}.deletedAt`) + .orderBy("deletedAt", "desc") + .orderByRaw(`"deletedAt" IS NULL`) + .select(selectAllTableCols(TableName.AccessApprovalPolicy)) + .select(db.ref("name").withSchema(TableName.Environment).as("envName")) + .select(db.ref("slug").withSchema(TableName.Environment).as("envSlug")) + .select(db.ref("id").withSchema(TableName.Environment).as("environmentId")) + .select(db.ref("projectId").withSchema(TableName.Environment)); + const formattedDocs = sqlNestRelationships({ + data: docs, + key: "id", + parentMapper: (data) => ({ + projectId: data.projectId, + ...AccessApprovalPoliciesSchema.parse(data) + }), + childrenMapper: [ + { + key: "environmentId", + label: "environments" as const, + mapper: ({ environmentId: id, envName, envSlug }) => ({ + id, + name: envName, + slug: envSlug + }) + } + ] + }); + return formattedDocs?.[0]; + } catch (error) { + throw new DatabaseError({ error, name: "findPolicyByEnvIdAndSecretPath" }); + } + }; + + return { + ...accessApprovalPolicyOrm, + find, + findById, + softDeleteById, + findLastValidPolicy, + findPolicyByEnvIdAndSecretPath + }; }; diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-environment-dal.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-environment-dal.ts new file mode 100644 index 000000000..f0d8079cf --- /dev/null +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-environment-dal.ts @@ -0,0 +1,32 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { buildFindFilter, ormify, selectAllTableCols } from "@app/lib/knex"; + +export type TAccessApprovalPolicyEnvironmentDALFactory = ReturnType; + +export const accessApprovalPolicyEnvironmentDALFactory = (db: TDbClient) => { + const accessApprovalPolicyEnvironmentOrm = ormify(db, TableName.AccessApprovalPolicyEnvironment); + + const findAvailablePoliciesByEnvId = async (envId: string, tx?: Knex) => { + try { + const docs = await (tx || db.replicaNode())(TableName.AccessApprovalPolicyEnvironment) + .join( + TableName.AccessApprovalPolicy, + `${TableName.AccessApprovalPolicyEnvironment}.policyId`, + `${TableName.AccessApprovalPolicy}.id` + ) + // eslint-disable-next-line @typescript-eslint/no-misused-promises + .where(buildFindFilter({ envId }, TableName.AccessApprovalPolicyEnvironment)) + .whereNull(`${TableName.AccessApprovalPolicy}.deletedAt`) + .select(selectAllTableCols(TableName.AccessApprovalPolicyEnvironment)); + return docs; + } catch (error) { + throw new DatabaseError({ error, name: "findAvailablePoliciesByEnvId" }); + } + }; + + return { ...accessApprovalPolicyEnvironmentOrm, findAvailablePoliciesByEnvId }; +}; diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts index 6d656bafa..0b3c4e128 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts @@ -21,6 +21,7 @@ import { TAccessApprovalPolicyBypasserDALFactory } from "./access-approval-policy-approver-dal"; import { TAccessApprovalPolicyDALFactory } from "./access-approval-policy-dal"; +import { TAccessApprovalPolicyEnvironmentDALFactory } from "./access-approval-policy-environment-dal"; import { ApproverType, BypasserType, @@ -45,12 +46,14 @@ type TAccessApprovalPolicyServiceFactoryDep = { additionalPrivilegeDAL: Pick; accessApprovalRequestReviewerDAL: Pick; orgMembershipDAL: Pick; + accessApprovalPolicyEnvironmentDAL: TAccessApprovalPolicyEnvironmentDALFactory; }; export const accessApprovalPolicyServiceFactory = ({ accessApprovalPolicyDAL, accessApprovalPolicyApproverDAL, accessApprovalPolicyBypasserDAL, + accessApprovalPolicyEnvironmentDAL, groupDAL, permissionService, projectEnvDAL, @@ -63,21 +66,22 @@ export const accessApprovalPolicyServiceFactory = ({ }: TAccessApprovalPolicyServiceFactoryDep): TAccessApprovalPolicyServiceFactory => { const $policyExists = async ({ envId, + envIds, secretPath, policyId }: { - envId: string; + envId?: string; + envIds?: string[]; secretPath: string; policyId?: string; }) => { - const policy = await accessApprovalPolicyDAL - .findOne({ - envId, - secretPath, - deletedAt: null - }) - .catch(() => null); - + if (!envId && !envIds) { + throw new BadRequestError({ message: "Must provide either envId or envIds" }); + } + const policy = await accessApprovalPolicyDAL.findPolicyByEnvIdAndSecretPath({ + secretPath, + envIds: envId ? [envId] : (envIds as string[]) + }); return policyId ? policy && policy.id !== policyId : Boolean(policy); }; @@ -93,6 +97,7 @@ export const accessApprovalPolicyServiceFactory = ({ bypassers, projectSlug, environment, + environments, enforcementLevel, allowedSelfApprovals, approvalsRequired @@ -125,13 +130,23 @@ export const accessApprovalPolicyServiceFactory = ({ ProjectPermissionActions.Create, ProjectPermissionSub.SecretApproval ); - const env = await projectEnvDAL.findOne({ slug: environment, projectId: project.id }); - if (!env) throw new NotFoundError({ message: `Environment with slug '${environment}' not found` }); + const mergedEnvs = (environment ? [environment] : environments) || []; + if (mergedEnvs.length === 0) { + throw new BadRequestError({ message: "Must provide either environment or environments" }); + } + const envs = await projectEnvDAL.find({ $in: { slug: mergedEnvs }, projectId: project.id }); + if (!envs.length || envs.length !== mergedEnvs.length) { + const notFoundEnvs = mergedEnvs.filter((env) => !envs.find((el) => el.slug === env)); + throw new NotFoundError({ message: `One or more environments not found: ${notFoundEnvs.join(", ")}` }); + } - if (await $policyExists({ envId: env.id, secretPath })) { - throw new BadRequestError({ - message: `A policy for secret path '${secretPath}' already exists in environment '${environment}'` - }); + for (const env of envs) { + // eslint-disable-next-line no-await-in-loop + if (await $policyExists({ envId: env.id, secretPath })) { + throw new BadRequestError({ + message: `A policy for secret path '${secretPath}' already exists in environment '${env.slug}'` + }); + } } let approverUserIds = userApprovers; @@ -199,7 +214,7 @@ export const accessApprovalPolicyServiceFactory = ({ const accessApproval = await accessApprovalPolicyDAL.transaction(async (tx) => { const doc = await accessApprovalPolicyDAL.create( { - envId: env.id, + envId: envs[0].id, approvals, secretPath, name, @@ -208,6 +223,10 @@ export const accessApprovalPolicyServiceFactory = ({ }, tx ); + await accessApprovalPolicyEnvironmentDAL.insertMany( + envs.map((el) => ({ policyId: doc.id, envId: el.id })), + tx + ); if (approverUserIds.length) { await accessApprovalPolicyApproverDAL.insertMany( @@ -260,7 +279,7 @@ export const accessApprovalPolicyServiceFactory = ({ return doc; }); - return { ...accessApproval, environment: env, projectId: project.id }; + return { ...accessApproval, environments: envs, projectId: project.id, environment: envs[0] }; }; const getAccessApprovalPolicyByProjectSlug: TAccessApprovalPolicyServiceFactory["getAccessApprovalPolicyByProjectSlug"] = @@ -279,7 +298,10 @@ export const accessApprovalPolicyServiceFactory = ({ }); const accessApprovalPolicies = await accessApprovalPolicyDAL.find({ projectId: project.id, deletedAt: null }); - return accessApprovalPolicies; + return accessApprovalPolicies.map((policy) => ({ + ...policy, + environment: policy.environments[0] + })); }; const updateAccessApprovalPolicy: TAccessApprovalPolicyServiceFactory["updateAccessApprovalPolicy"] = async ({ @@ -295,7 +317,8 @@ export const accessApprovalPolicyServiceFactory = ({ approvals, enforcementLevel, allowedSelfApprovals, - approvalsRequired + approvalsRequired, + environments }: TUpdateAccessApprovalPolicy) => { const groupApprovers = approvers.filter((approver) => approver.type === ApproverType.Group); @@ -323,16 +346,27 @@ export const accessApprovalPolicyServiceFactory = ({ throw new BadRequestError({ message: "Approvals cannot be greater than approvers" }); } + let envs = accessApprovalPolicy.environments; if ( - await $policyExists({ - envId: accessApprovalPolicy.envId, - secretPath: secretPath || accessApprovalPolicy.secretPath, - policyId: accessApprovalPolicy.id - }) + environments && + (environments.length !== envs.length || environments.some((env) => !envs.find((el) => el.slug === env))) ) { - throw new BadRequestError({ - message: `A policy for secret path '${secretPath}' already exists in environment '${accessApprovalPolicy.environment.slug}'` - }); + envs = await projectEnvDAL.find({ $in: { slug: environments }, projectId: accessApprovalPolicy.projectId }); + } + + for (const env of envs) { + if ( + // eslint-disable-next-line no-await-in-loop + await $policyExists({ + envId: env.id, + secretPath: secretPath || accessApprovalPolicy.secretPath, + policyId: accessApprovalPolicy.id + }) + ) { + throw new BadRequestError({ + message: `A policy for secret path '${secretPath || accessApprovalPolicy.secretPath}' already exists in environment '${env.slug}'` + }); + } } const { permission } = await permissionService.getProjectPermission({ @@ -488,6 +522,14 @@ export const accessApprovalPolicyServiceFactory = ({ ); } + if (environments) { + await accessApprovalPolicyEnvironmentDAL.delete({ policyId: doc.id }, tx); + await accessApprovalPolicyEnvironmentDAL.insertMany( + envs.map((env) => ({ policyId: doc.id, envId: env.id })), + tx + ); + } + await accessApprovalPolicyBypasserDAL.delete({ policyId: doc.id }, tx); if (bypasserUserIds.length) { @@ -517,7 +559,8 @@ export const accessApprovalPolicyServiceFactory = ({ return { ...updatedPolicy, - environment: accessApprovalPolicy.environment, + environments: accessApprovalPolicy.environments, + environment: accessApprovalPolicy.environments[0], projectId: accessApprovalPolicy.projectId }; }; @@ -568,7 +611,10 @@ export const accessApprovalPolicyServiceFactory = ({ } }); - return policy; + return { + ...policy, + environment: policy.environments[0] + }; }; const getAccessPolicyCountByEnvSlug: TAccessApprovalPolicyServiceFactory["getAccessPolicyCountByEnvSlug"] = async ({ @@ -598,11 +644,13 @@ export const accessApprovalPolicyServiceFactory = ({ const environment = await projectEnvDAL.findOne({ projectId: project.id, slug: envSlug }); if (!environment) throw new NotFoundError({ message: `Environment with slug '${envSlug}' not found` }); - const policies = await accessApprovalPolicyDAL.find({ - envId: environment.id, - projectId: project.id, - deletedAt: null - }); + const policies = await accessApprovalPolicyDAL.find( + { + projectId: project.id, + deletedAt: null + }, + { envId: environment.id } + ); if (!policies) throw new NotFoundError({ message: `No policies found in environment with slug '${envSlug}'` }); return { count: policies.length }; @@ -634,7 +682,10 @@ export const accessApprovalPolicyServiceFactory = ({ ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); - return policy; + return { + ...policy, + environment: policy.environments[0] + }; }; return { diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts index f3f195914..27ec228f7 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts @@ -26,7 +26,8 @@ export enum BypasserType { export type TCreateAccessApprovalPolicy = { approvals: number; secretPath: string; - environment: string; + environment?: string; + environments?: string[]; approvers: ( | { type: ApproverType.Group; id: string; sequence?: number } | { type: ApproverType.User; id?: string; username?: string; sequence?: number } @@ -58,6 +59,7 @@ export type TUpdateAccessApprovalPolicy = { enforcementLevel?: EnforcementLevel; allowedSelfApprovals: boolean; approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[]; + environments?: string[]; } & Omit; export type TDeleteAccessApprovalPolicy = { @@ -113,6 +115,15 @@ export interface TAccessApprovalPolicyServiceFactory { slug: string; position: number; }; + environments: { + name: string; + id: string; + createdAt: Date; + updatedAt: Date; + projectId: string; + slug: string; + position: number; + }[]; projectId: string; name: string; id: string; @@ -153,6 +164,11 @@ export interface TAccessApprovalPolicyServiceFactory { name: string; slug: string; }; + environments: { + id: string; + name: string; + slug: string; + }[]; projectId: string; }>; updateAccessApprovalPolicy: ({ @@ -168,13 +184,19 @@ export interface TAccessApprovalPolicyServiceFactory { approvals, enforcementLevel, allowedSelfApprovals, - approvalsRequired + approvalsRequired, + environments }: TUpdateAccessApprovalPolicy) => Promise<{ environment: { id: string; name: string; slug: string; }; + environments: { + id: string; + name: string; + slug: string; + }[]; projectId: string; name: string; id: string; @@ -225,6 +247,11 @@ export interface TAccessApprovalPolicyServiceFactory { name: string; slug: string; }; + environments: { + id: string; + name: string; + slug: string; + }[]; projectId: string; bypassers: ( | { @@ -276,6 +303,11 @@ export interface TAccessApprovalPolicyServiceFactory { name: string; slug: string; }; + environments: { + id: string; + name: string; + slug: string; + }[]; projectId: string; bypassers: ( | { 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 671d2c1de..9872df067 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 @@ -65,7 +65,7 @@ export interface TAccessApprovalRequestDALFactory extends Omit environment } ] }); diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts index bdf579616..dcbe717da 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts @@ -86,6 +86,25 @@ export const accessApprovalRequestServiceFactory = ({ projectMicrosoftTeamsConfigDAL, projectSlackConfigDAL }: TSecretApprovalRequestServiceFactoryDep): TAccessApprovalRequestServiceFactory => { + const $getEnvironmentFromPermissions = (permissions: unknown): string | null => { + if (!Array.isArray(permissions) || permissions.length === 0) { + return null; + } + + const firstPermission = permissions[0] as unknown[]; + if (!Array.isArray(firstPermission) || firstPermission.length < 3) { + return null; + } + + const metadata = firstPermission[2] as Record; + if (typeof metadata === "object" && metadata !== null && "environment" in metadata) { + const env = metadata.environment; + return typeof env === "string" ? env : null; + } + + return null; + }; + const createAccessApprovalRequest: TAccessApprovalRequestServiceFactory["createAccessApprovalRequest"] = async ({ isTemporary, temporaryRange, @@ -308,6 +327,15 @@ export const accessApprovalRequestServiceFactory = ({ requests = requests.filter((request) => request.environment === envSlug); } + requests = requests.map((request) => { + const permissionEnvironment = $getEnvironmentFromPermissions(request.permissions); + + if (permissionEnvironment) { + request.environmentName = permissionEnvironment; + } + return request; + }); + return { requests }; }; @@ -325,13 +353,27 @@ export const accessApprovalRequestServiceFactory = ({ throw new NotFoundError({ message: `Secret approval request with ID '${requestId}' not found` }); } - const { policy, environment } = accessApprovalRequest; + const { policy, environments, permissions } = accessApprovalRequest; if (policy.deletedAt) { throw new BadRequestError({ message: "The policy associated with this access request has been deleted." }); } + const permissionEnvironment = $getEnvironmentFromPermissions(permissions); + if ( + !permissionEnvironment || + (!environments.includes(permissionEnvironment) && status === ApprovalStatus.APPROVED) + ) { + throw new BadRequestError({ + message: `The original policy ${policy.name} is not attached to environment '${permissionEnvironment}'.` + }); + } + const environment = await projectEnvDAL.findOne({ + projectId: accessApprovalRequest.projectId, + slug: permissionEnvironment + }); + const { membership, hasRole } = await permissionService.getProjectPermission({ actor, actorId, @@ -553,7 +595,7 @@ export const accessApprovalRequestServiceFactory = ({ requesterEmail: actingUser.email, bypassReason: bypassReason || "No reason provided", secretPath: policy.secretPath || "/", - environment, + environment: environment?.name || permissionEnvironment, approvalUrl: `${cfg.SITE_URL}/projects/secret-management/${project.id}/approval`, requestType: "access" }, diff --git a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-dal.ts b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-dal.ts index fd8be93cf..3212fb902 100644 --- a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-dal.ts +++ b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-dal.ts @@ -23,6 +23,7 @@ export const secretApprovalPolicyDALFactory = (db: TDbClient) => { filter: TFindFilter, customFilter?: { sapId?: string; + envId?: string; } ) => tx(TableName.SecretApprovalPolicy) @@ -33,7 +34,17 @@ export const secretApprovalPolicyDALFactory = (db: TDbClient) => { void qb.where(`${TableName.SecretApprovalPolicy}.id`, "=", customFilter.sapId); } }) - .join(TableName.Environment, `${TableName.SecretApprovalPolicy}.envId`, `${TableName.Environment}.id`) + .join( + TableName.SecretApprovalPolicyEnvironment, + `${TableName.SecretApprovalPolicyEnvironment}.policyId`, + `${TableName.SecretApprovalPolicy}.id` + ) + .join(TableName.Environment, `${TableName.SecretApprovalPolicyEnvironment}.envId`, `${TableName.Environment}.id`) + .where((qb) => { + if (customFilter?.envId) { + void qb.where(`${TableName.SecretApprovalPolicyEnvironment}.envId`, "=", customFilter.envId); + } + }) .leftJoin( TableName.SecretApprovalPolicyApprover, `${TableName.SecretApprovalPolicy}.id`, @@ -97,7 +108,7 @@ export const secretApprovalPolicyDALFactory = (db: TDbClient) => { .select( tx.ref("name").withSchema(TableName.Environment).as("envName"), tx.ref("slug").withSchema(TableName.Environment).as("envSlug"), - tx.ref("id").withSchema(TableName.Environment).as("envId"), + tx.ref("id").withSchema(TableName.Environment).as("environmentId"), tx.ref("projectId").withSchema(TableName.Environment) ) .select(selectAllTableCols(TableName.SecretApprovalPolicy)) @@ -146,6 +157,15 @@ export const secretApprovalPolicyDALFactory = (db: TDbClient) => { firstName, lastName }) + }, + { + key: "environmentId", + label: "environments" as const, + mapper: ({ environmentId, envName, envSlug }) => ({ + id: environmentId, + name: envName, + slug: envSlug + }) } ] }); @@ -160,6 +180,7 @@ export const secretApprovalPolicyDALFactory = (db: TDbClient) => { filter: TFindFilter, customFilter?: { sapId?: string; + envId?: string; }, tx?: Knex ) => { @@ -221,6 +242,15 @@ export const secretApprovalPolicyDALFactory = (db: TDbClient) => { mapper: ({ approverGroupUserId: userId }) => ({ userId }) + }, + { + key: "environmentId", + label: "environments" as const, + mapper: ({ environmentId, envName, envSlug }) => ({ + id: environmentId, + name: envName, + slug: envSlug + }) } ] }); @@ -235,5 +265,74 @@ export const secretApprovalPolicyDALFactory = (db: TDbClient) => { return softDeletedPolicy; }; - return { ...secretApprovalPolicyOrm, findById, find, softDeleteById }; + const findPolicyByEnvIdAndSecretPath = async ( + { envIds, secretPath }: { envIds: string[]; secretPath: string }, + tx?: Knex + ) => { + try { + const docs = await (tx || db.replicaNode())(TableName.SecretApprovalPolicy) + .join( + TableName.SecretApprovalPolicyEnvironment, + `${TableName.SecretApprovalPolicyEnvironment}.policyId`, + `${TableName.SecretApprovalPolicy}.id` + ) + .join( + TableName.Environment, + `${TableName.SecretApprovalPolicyEnvironment}.envId`, + `${TableName.Environment}.id` + ) + .where( + // eslint-disable-next-line @typescript-eslint/no-misused-promises + buildFindFilter( + { + $in: { + envId: envIds + } + }, + TableName.SecretApprovalPolicyEnvironment + ) + ) + .where( + // eslint-disable-next-line @typescript-eslint/no-misused-promises + buildFindFilter( + { + secretPath + }, + TableName.SecretApprovalPolicy + ) + ) + .whereNull(`${TableName.SecretApprovalPolicy}.deletedAt`) + .orderBy("deletedAt", "desc") + .orderByRaw(`"deletedAt" IS NULL`) + .select(selectAllTableCols(TableName.SecretApprovalPolicy)) + .select(db.ref("name").withSchema(TableName.Environment).as("envName")) + .select(db.ref("slug").withSchema(TableName.Environment).as("envSlug")) + .select(db.ref("id").withSchema(TableName.Environment).as("environmentId")) + .select(db.ref("projectId").withSchema(TableName.Environment)); + const formattedDocs = sqlNestRelationships({ + data: docs, + key: "id", + parentMapper: (data) => ({ + projectId: data.projectId, + ...SecretApprovalPoliciesSchema.parse(data) + }), + childrenMapper: [ + { + key: "environmentId", + label: "environments" as const, + mapper: ({ environmentId: id, envName, envSlug }) => ({ + id, + name: envName, + slug: envSlug + }) + } + ] + }); + return formattedDocs?.[0]; + } catch (error) { + throw new DatabaseError({ error, name: "findPolicyByEnvIdAndSecretPath" }); + } + }; + + return { ...secretApprovalPolicyOrm, findById, find, softDeleteById, findPolicyByEnvIdAndSecretPath }; }; diff --git a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-environment-dal.ts b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-environment-dal.ts new file mode 100644 index 000000000..d12ace04c --- /dev/null +++ b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-environment-dal.ts @@ -0,0 +1,32 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { buildFindFilter, ormify, selectAllTableCols } from "@app/lib/knex"; + +export type TSecretApprovalPolicyEnvironmentDALFactory = ReturnType; + +export const secretApprovalPolicyEnvironmentDALFactory = (db: TDbClient) => { + const secretApprovalPolicyEnvironmentOrm = ormify(db, TableName.SecretApprovalPolicyEnvironment); + + const findAvailablePoliciesByEnvId = async (envId: string, tx?: Knex) => { + try { + const docs = await (tx || db.replicaNode())(TableName.SecretApprovalPolicyEnvironment) + .join( + TableName.SecretApprovalPolicy, + `${TableName.SecretApprovalPolicyEnvironment}.policyId`, + `${TableName.SecretApprovalPolicy}.id` + ) + // eslint-disable-next-line @typescript-eslint/no-misused-promises + .where(buildFindFilter({ envId }, TableName.SecretApprovalPolicyEnvironment)) + .whereNull(`${TableName.SecretApprovalPolicy}.deletedAt`) + .select(selectAllTableCols(TableName.SecretApprovalPolicyEnvironment)); + return docs; + } catch (error) { + throw new DatabaseError({ error, name: "findAvailablePoliciesByEnvId" }); + } + }; + + return { ...secretApprovalPolicyEnvironmentOrm, findAvailablePoliciesByEnvId }; +}; diff --git a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts index 41a635e2f..96757dc22 100644 --- a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts +++ b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-service.ts @@ -19,6 +19,7 @@ import { TSecretApprovalPolicyBypasserDALFactory } from "./secret-approval-policy-approver-dal"; import { TSecretApprovalPolicyDALFactory } from "./secret-approval-policy-dal"; +import { TSecretApprovalPolicyEnvironmentDALFactory } from "./secret-approval-policy-environment-dal"; import { TCreateSapDTO, TDeleteSapDTO, @@ -36,12 +37,13 @@ const getPolicyScore = (policy: { secretPath?: string | null }) => type TSecretApprovalPolicyServiceFactoryDep = { permissionService: Pick; secretApprovalPolicyDAL: TSecretApprovalPolicyDALFactory; - projectEnvDAL: Pick; + projectEnvDAL: Pick; userDAL: Pick; secretApprovalPolicyApproverDAL: TSecretApprovalPolicyApproverDALFactory; secretApprovalPolicyBypasserDAL: TSecretApprovalPolicyBypasserDALFactory; licenseService: Pick; secretApprovalRequestDAL: Pick; + secretApprovalPolicyEnvironmentDAL: TSecretApprovalPolicyEnvironmentDALFactory; }; export type TSecretApprovalPolicyServiceFactory = ReturnType; @@ -51,27 +53,30 @@ export const secretApprovalPolicyServiceFactory = ({ permissionService, secretApprovalPolicyApproverDAL, secretApprovalPolicyBypasserDAL, + secretApprovalPolicyEnvironmentDAL, projectEnvDAL, userDAL, licenseService, secretApprovalRequestDAL }: TSecretApprovalPolicyServiceFactoryDep) => { const $policyExists = async ({ + envIds, envId, secretPath, policyId }: { - envId: string; + envIds?: string[]; + envId?: string; secretPath: string; policyId?: string; }) => { - const policy = await secretApprovalPolicyDAL - .findOne({ - envId, - secretPath, - deletedAt: null - }) - .catch(() => null); + if (!envIds && !envId) { + throw new BadRequestError({ message: "At least one environment should be provided" }); + } + const policy = await secretApprovalPolicyDAL.findPolicyByEnvIdAndSecretPath({ + envIds: envId ? [envId] : envIds || [], + secretPath + }); return policyId ? policy && policy.id !== policyId : Boolean(policy); }; @@ -88,6 +93,7 @@ export const secretApprovalPolicyServiceFactory = ({ projectId, secretPath, environment, + environments, enforcementLevel, allowedSelfApprovals }: TCreateSapDTO) => { @@ -127,17 +133,23 @@ export const secretApprovalPolicyServiceFactory = ({ }); } - const env = await projectEnvDAL.findOne({ slug: environment, projectId }); - if (!env) { - throw new NotFoundError({ - message: `Environment with slug '${environment}' not found in project with ID ${projectId}` - }); + const mergedEnvs = (environment ? [environment] : environments) || []; + if (mergedEnvs.length === 0) { + throw new BadRequestError({ message: "Must provide either environment or environments" }); + } + const envs = await projectEnvDAL.find({ $in: { slug: mergedEnvs }, projectId }); + if (!envs.length || envs.length !== mergedEnvs.length) { + const notFoundEnvs = mergedEnvs.filter((env) => !envs.find((el) => el.slug === env)); + throw new NotFoundError({ message: `One or more environments not found: ${notFoundEnvs.join(", ")}` }); } - if (await $policyExists({ envId: env.id, secretPath })) { - throw new BadRequestError({ - message: `A policy for secret path '${secretPath}' already exists in environment '${environment}'` - }); + for (const env of envs) { + // eslint-disable-next-line no-await-in-loop + if (await $policyExists({ envId: env.id, secretPath })) { + throw new BadRequestError({ + message: `A policy for secret path '${secretPath}' already exists in environment '${env.slug}'` + }); + } } let groupBypassers: string[] = []; @@ -181,7 +193,7 @@ export const secretApprovalPolicyServiceFactory = ({ const secretApproval = await secretApprovalPolicyDAL.transaction(async (tx) => { const doc = await secretApprovalPolicyDAL.create( { - envId: env.id, + envId: envs[0].id, approvals, secretPath, name, @@ -190,6 +202,13 @@ export const secretApprovalPolicyServiceFactory = ({ }, tx ); + await secretApprovalPolicyEnvironmentDAL.insertMany( + envs.map((env) => ({ + envId: env.id, + policyId: doc.id + })), + tx + ); let userApproverIds = userApprovers; if (userApproverNames.length) { @@ -253,12 +272,13 @@ export const secretApprovalPolicyServiceFactory = ({ return doc; }); - return { ...secretApproval, environment: env, projectId }; + return { ...secretApproval, environments: envs, projectId, environment: envs[0] }; }; const updateSecretApprovalPolicy = async ({ approvers, bypassers, + environments, secretPath, name, actorId, @@ -288,17 +308,26 @@ export const secretApprovalPolicyServiceFactory = ({ message: `Secret approval policy with ID '${secretPolicyId}' not found` }); } - + let envs = secretApprovalPolicy.environments; if ( - await $policyExists({ - envId: secretApprovalPolicy.envId, - secretPath: secretPath || secretApprovalPolicy.secretPath, - policyId: secretApprovalPolicy.id - }) + environments && + (environments.length !== envs.length || environments.some((env) => !envs.find((el) => el.slug === env))) ) { - throw new BadRequestError({ - message: `A policy for secret path '${secretPath}' already exists in environment '${secretApprovalPolicy.environment.slug}'` - }); + envs = await projectEnvDAL.find({ $in: { slug: environments }, projectId: secretApprovalPolicy.projectId }); + } + for (const env of envs) { + if ( + // eslint-disable-next-line no-await-in-loop + await $policyExists({ + envId: env.id, + secretPath: secretPath || secretApprovalPolicy.secretPath, + policyId: secretApprovalPolicy.id + }) + ) { + throw new BadRequestError({ + message: `A policy for secret path '${secretPath || secretApprovalPolicy.secretPath}' already exists in environment '${env.slug}'` + }); + } } const { permission } = await permissionService.getProjectPermission({ @@ -415,6 +444,17 @@ export const secretApprovalPolicyServiceFactory = ({ ); } + if (environments) { + await secretApprovalPolicyEnvironmentDAL.delete({ policyId: doc.id }, tx); + await secretApprovalPolicyEnvironmentDAL.insertMany( + envs.map((env) => ({ + envId: env.id, + policyId: doc.id + })), + tx + ); + } + await secretApprovalPolicyBypasserDAL.delete({ policyId: doc.id }, tx); if (bypasserUserIds.length) { @@ -441,7 +481,8 @@ export const secretApprovalPolicyServiceFactory = ({ }); return { ...updatedSap, - environment: secretApprovalPolicy.environment, + environments: secretApprovalPolicy.environments, + environment: secretApprovalPolicy.environments[0], projectId: secretApprovalPolicy.projectId }; }; @@ -487,7 +528,12 @@ export const secretApprovalPolicyServiceFactory = ({ const updatedPolicy = await secretApprovalPolicyDAL.softDeleteById(secretPolicyId, tx); return updatedPolicy; }); - return { ...deletedPolicy, projectId: sapPolicy.projectId, environment: sapPolicy.environment }; + return { + ...deletedPolicy, + projectId: sapPolicy.projectId, + environments: sapPolicy.environments, + environment: sapPolicy.environments[0] + }; }; const getSecretApprovalPolicyByProjectId = async ({ @@ -520,7 +566,7 @@ export const secretApprovalPolicyServiceFactory = ({ }); } - const policies = await secretApprovalPolicyDAL.find({ envId: env.id, deletedAt: null }); + const policies = await secretApprovalPolicyDAL.find({ deletedAt: null }, { envId: env.id }); if (!policies.length) return; // this will filter policies either without scoped to secret path or the one that matches with secret path const policiesFilteredByPath = policies.filter( diff --git a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-types.ts b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-types.ts index ba5334e5c..80369e638 100644 --- a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-types.ts +++ b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-types.ts @@ -5,7 +5,8 @@ import { ApproverType, BypasserType } from "../access-approval-policy/access-app export type TCreateSapDTO = { approvals: number; secretPath: string; - environment: string; + environment?: string; + environments?: string[]; approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; username?: string })[]; bypassers?: ( | { type: BypasserType.Group; id: string } @@ -29,6 +30,7 @@ export type TUpdateSapDTO = { name?: string; enforcementLevel?: EnforcementLevel; allowedSelfApprovals?: boolean; + environments?: string[]; } & Omit; export type TDeleteSapDTO = { 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 c098d9b31..49f31bdf6 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 @@ -40,6 +40,13 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { `${TableName.SecretApprovalRequest}.policyId`, `${TableName.SecretApprovalPolicy}.id` ) + .leftJoin(TableName.SecretApprovalPolicyEnvironment, (bd) => { + bd.on( + `${TableName.SecretApprovalPolicy}.id`, + "=", + `${TableName.SecretApprovalPolicyEnvironment}.policyId` + ).andOn(`${TableName.SecretApprovalPolicyEnvironment}.envId`, "=", `${TableName.SecretFolder}.envId`); + }) .leftJoin( db(TableName.Users).as("statusChangedByUser"), `${TableName.SecretApprovalRequest}.statusChangedByUserId`, @@ -146,7 +153,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { tx.ref("projectId").withSchema(TableName.Environment), tx.ref("slug").withSchema(TableName.Environment).as("environment"), tx.ref("secretPath").withSchema(TableName.SecretApprovalPolicy).as("policySecretPath"), - tx.ref("envId").withSchema(TableName.SecretApprovalPolicy).as("policyEnvId"), + tx.ref("envId").withSchema(TableName.SecretApprovalPolicyEnvironment).as("policyEnvId"), tx.ref("enforcementLevel").withSchema(TableName.SecretApprovalPolicy).as("policyEnforcementLevel"), tx.ref("allowedSelfApprovals").withSchema(TableName.SecretApprovalPolicy).as("policyAllowedSelfApprovals"), tx.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals"), 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 b5331e897..d1eefe7e3 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 @@ -538,6 +538,11 @@ export const secretApprovalRequestServiceFactory = ({ message: "The policy associated with this secret approval request has been deleted." }); } + if (!policy.envId) { + throw new BadRequestError({ + message: "The policy associated with this secret approval request is not linked to the environment." + }); + } const { hasRole } = await permissionService.getProjectPermission({ actor: ActorType.USER, diff --git a/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-fns.ts b/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-fns.ts index 3e6e5d265..1da1db376 100644 --- a/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-fns.ts +++ b/backend/src/ee/services/secret-rotation-v2/shared/sql-credentials/sql-credentials-rotation-fns.ts @@ -7,12 +7,13 @@ import { TRotationFactoryRevokeCredentials, TRotationFactoryRotateCredentials } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-types"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { executeWithPotentialGateway, SQL_CONNECTION_ALTER_LOGIN_STATEMENT } from "@app/services/app-connection/shared/sql"; -import { generatePassword } from "../utils"; +import { DEFAULT_PASSWORD_REQUIREMENTS, generatePassword } from "../utils"; import { TSqlCredentialsRotationGeneratedCredentials, TSqlCredentialsRotationWithConnection @@ -32,6 +33,11 @@ const redactPasswords = (e: unknown, credentials: TSqlCredentialsRotationGenerat return redactedMessage; }; +const ORACLE_PASSWORD_REQUIREMENTS = { + ...DEFAULT_PASSWORD_REQUIREMENTS, + length: 30 +}; + export const sqlCredentialsRotationFactory: TRotationFactory< TSqlCredentialsRotationWithConnection, TSqlCredentialsRotationGeneratedCredentials @@ -43,6 +49,9 @@ export const sqlCredentialsRotationFactory: TRotationFactory< secretsMapping } = secretRotation; + const passwordRequirement = + connection.app === AppConnection.OracleDB ? ORACLE_PASSWORD_REQUIREMENTS : DEFAULT_PASSWORD_REQUIREMENTS; + const executeOperation = ( operation: (client: Knex) => Promise, credentialsOverride?: TSqlCredentialsRotationGeneratedCredentials[number] @@ -65,7 +74,7 @@ export const sqlCredentialsRotationFactory: TRotationFactory< const $validateCredentials = async (credentials: TSqlCredentialsRotationGeneratedCredentials[number]) => { try { await executeOperation(async (client) => { - await client.raw("SELECT 1"); + await client.raw(connection.app === AppConnection.OracleDB ? `SELECT 1 FROM DUAL` : `Select 1`); }, credentials); } catch (error) { throw new Error(redactPasswords(error, [credentials])); @@ -75,11 +84,13 @@ export const sqlCredentialsRotationFactory: TRotationFactory< const issueCredentials: TRotationFactoryIssueCredentials = async ( callback ) => { + // For SQL, since we get existing users, we change both their passwords + // on issue to invalidate their existing passwords // For SQL, since we get existing users, we change both their passwords // on issue to invalidate their existing passwords const credentialsSet = [ - { username: username1, password: generatePassword() }, - { username: username2, password: generatePassword() } + { username: username1, password: generatePassword(passwordRequirement) }, + { username: username2, password: generatePassword(passwordRequirement) } ]; try { @@ -105,7 +116,10 @@ export const sqlCredentialsRotationFactory: TRotationFactory< credentialsToRevoke, callback ) => { - const revokedCredentials = credentialsToRevoke.map(({ username }) => ({ username, password: generatePassword() })); + const revokedCredentials = credentialsToRevoke.map(({ username }) => ({ + username, + password: generatePassword(passwordRequirement) + })); try { await executeOperation(async (client) => { @@ -128,7 +142,10 @@ export const sqlCredentialsRotationFactory: TRotationFactory< callback ) => { // generate new password for the next active user - const credentials = { username: activeIndex === 0 ? username2 : username1, password: generatePassword() }; + const credentials = { + username: activeIndex === 0 ? username2 : username1, + password: generatePassword(passwordRequirement) + }; try { await executeOperation(async (client) => { diff --git a/backend/src/ee/services/secret-rotation-v2/shared/utils/index.ts b/backend/src/ee/services/secret-rotation-v2/shared/utils/index.ts index ef58687a1..4122abfda 100644 --- a/backend/src/ee/services/secret-rotation-v2/shared/utils/index.ts +++ b/backend/src/ee/services/secret-rotation-v2/shared/utils/index.ts @@ -11,7 +11,7 @@ type TPasswordRequirements = { allowedSymbols?: string; }; -const DEFAULT_PASSWORD_REQUIREMENTS: TPasswordRequirements = { +export const DEFAULT_PASSWORD_REQUIREMENTS: TPasswordRequirements = { length: 48, required: { lowercase: 1, diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index f3b4cbca0..71b6afb6b 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -2246,7 +2246,9 @@ export const AppConnections = { }, AZURE_CLIENT_SECRETS: { code: "The OAuth code to use to connect with Azure Client Secrets.", - tenantId: "The Tenant ID to use to connect with Azure Client Secrets." + tenantId: "The Tenant ID to use to connect with Azure Client Secrets.", + clientId: "The Client ID to use to connect with Azure Client Secrets.", + clientSecret: "The Client Secret to use to connect with Azure Client Secrets." }, AZURE_DEVOPS: { code: "The OAuth code to use to connect with Azure DevOps.", @@ -2374,6 +2376,10 @@ export const SecretSyncs = { keyId: "The AWS KMS key ID or alias to use when encrypting parameters synced by Infisical.", tags: "Optional tags to add to secrets synced by Infisical.", syncSecretMetadataAsTags: `Whether Infisical secret metadata should be added as tags to secrets synced by Infisical.` + }, + RENDER: { + autoRedeployServices: + "Whether Infisical should automatically redeploy the configured Render service upon secret changes." } }, DESTINATION_CONFIG: { diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index bc32d2b05..3fa2c0f82 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -272,10 +272,26 @@ const envSchema = z // gcp app INF_APP_CONNECTION_GCP_SERVICE_ACCOUNT_CREDENTIAL: zpStr(z.string().optional()), - // azure app + // Legacy Single Multi Purpose Azure App Connection INF_APP_CONNECTION_AZURE_CLIENT_ID: zpStr(z.string().optional()), INF_APP_CONNECTION_AZURE_CLIENT_SECRET: zpStr(z.string().optional()), + // Azure App Configuration App Connection + INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID: zpStr(z.string().optional()), + INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_SECRET: zpStr(z.string().optional()), + + // Azure Key Vault App Connection + INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID: zpStr(z.string().optional()), + INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET: zpStr(z.string().optional()), + + // Azure Client Secrets App Connection + INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID: zpStr(z.string().optional()), + INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_SECRET: zpStr(z.string().optional()), + + // Azure DevOps App Connection + INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_ID: zpStr(z.string().optional()), + INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_SECRET: zpStr(z.string().optional()), + // datadog SHOULD_USE_DATADOG_TRACER: zodStrBool.default("false"), DATADOG_PROFILING_ENABLED: zodStrBool.default("false"), @@ -353,7 +369,23 @@ const envSchema = z Boolean(data.HSM_LIB_PATH) && Boolean(data.HSM_PIN) && Boolean(data.HSM_KEY_LABEL) && data.HSM_SLOT !== undefined, samlDefaultOrgSlug: data.DEFAULT_SAML_ORG_SLUG, SECRET_SCANNING_ORG_WHITELIST: data.SECRET_SCANNING_ORG_WHITELIST?.split(","), - PARAMS_FOLDER_SECRET_DETECTION_ENABLED: (data.PARAMS_FOLDER_SECRET_DETECTION_PATHS?.length ?? 0) > 0 + PARAMS_FOLDER_SECRET_DETECTION_ENABLED: (data.PARAMS_FOLDER_SECRET_DETECTION_PATHS?.length ?? 0) > 0, + INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_ID: + data.INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_ID || data.INF_APP_CONNECTION_AZURE_CLIENT_ID, + INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_SECRET: + data.INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_SECRET || data.INF_APP_CONNECTION_AZURE_CLIENT_SECRET, + INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID: + data.INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID || data.INF_APP_CONNECTION_AZURE_CLIENT_ID, + INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_SECRET: + data.INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_SECRET || data.INF_APP_CONNECTION_AZURE_CLIENT_SECRET, + INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID: + data.INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID || data.INF_APP_CONNECTION_AZURE_CLIENT_ID, + INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET: + data.INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET || data.INF_APP_CONNECTION_AZURE_CLIENT_SECRET, + INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID: + data.INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID || data.INF_APP_CONNECTION_AZURE_CLIENT_ID, + INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_SECRET: + data.INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_SECRET || data.INF_APP_CONNECTION_AZURE_CLIENT_SECRET })); export type TEnvConfig = Readonly>; @@ -463,15 +495,54 @@ export const overwriteSchema: { } ] }, - azure: { - name: "Azure", + azureAppConfiguration: { + name: "Azure App Configuration", fields: [ { - key: "INF_APP_CONNECTION_AZURE_CLIENT_ID", + key: "INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID", description: "The Application (Client) ID of your Azure application." }, { - key: "INF_APP_CONNECTION_AZURE_CLIENT_SECRET", + key: "INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_SECRET", + description: "The Client Secret of your Azure application." + } + ] + }, + azureKeyVault: { + name: "Azure Key Vault", + fields: [ + { + key: "INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID", + description: "The Application (Client) ID of your Azure application." + }, + { + key: "INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET", + description: "The Client Secret of your Azure application." + } + ] + }, + azureClientSecrets: { + name: "Azure Client Secrets", + fields: [ + { + key: "INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID", + description: "The Application (Client) ID of your Azure application." + }, + { + key: "INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_SECRET", + description: "The Client Secret of your Azure application." + } + ] + }, + azureDevOps: { + name: "Azure DevOps", + fields: [ + { + key: "INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_ID", + description: "The Application (Client) ID of your Azure application." + }, + { + key: "INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_SECRET", description: "The Client Secret of your Azure application." } ] diff --git a/backend/src/lib/crypto/cryptography/crypto.ts b/backend/src/lib/crypto/cryptography/crypto.ts index 967e7e007..b8fc45645 100644 --- a/backend/src/lib/crypto/cryptography/crypto.ts +++ b/backend/src/lib/crypto/cryptography/crypto.ts @@ -14,7 +14,7 @@ import { TSuperAdminDALFactory } from "@app/services/super-admin/super-admin-dal import { ADMIN_CONFIG_DB_UUID } from "@app/services/super-admin/super-admin-service"; import { isBase64 } from "../../base64"; -import { getConfig } from "../../config/env"; +import { getConfig, TEnvConfig } from "../../config/env"; import { CryptographyError } from "../../errors"; import { logger } from "../../logger"; import { asymmetricFipsValidated } from "./asymmetric-fips"; @@ -106,12 +106,12 @@ const cryptographyFactory = () => { } }; - const $setFipsModeEnabled = (enabled: boolean) => { + const $setFipsModeEnabled = (enabled: boolean, envCfg?: Pick) => { // If FIPS is enabled, we need to validate that the ENCRYPTION_KEY is in a base64 format, and is a 256-bit key. if (enabled) { crypto.setFips(true); - const appCfg = getConfig(); + const appCfg = envCfg || getConfig(); if (appCfg.ENCRYPTION_KEY) { // we need to validate that the ENCRYPTION_KEY is a base64 encoded 256-bit key @@ -141,14 +141,14 @@ const cryptographyFactory = () => { $isInitialized = true; }; - const initialize = async (superAdminDAL: TSuperAdminDALFactory) => { + const initialize = async (superAdminDAL: TSuperAdminDALFactory, envCfg?: Pick) => { if ($isInitialized) { return isFipsModeEnabled(); } if (process.env.FIPS_ENABLED !== "true") { logger.info("Cryptography module initialized in normal operation mode."); - $setFipsModeEnabled(false); + $setFipsModeEnabled(false, envCfg); return false; } @@ -158,11 +158,11 @@ const cryptographyFactory = () => { if (serverCfg) { if (serverCfg.fipsEnabled) { logger.info("[FIPS]: Instance is configured for FIPS mode of operation. Continuing startup with FIPS enabled."); - $setFipsModeEnabled(true); + $setFipsModeEnabled(true, envCfg); return true; } logger.info("[FIPS]: Instance age predates FIPS mode inception date. Continuing without FIPS."); - $setFipsModeEnabled(false); + $setFipsModeEnabled(false, envCfg); return false; } @@ -171,7 +171,7 @@ const cryptographyFactory = () => { // TODO(daniel): check if it's an enterprise deployment // if there is no server cfg, and FIPS_MODE is `true`, its a fresh FIPS deployment. We need to set the fipsEnabled to true. - $setFipsModeEnabled(true); + $setFipsModeEnabled(true, envCfg); return true; }; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 01f58494b..0fb3191f8 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -11,6 +11,7 @@ import { accessApprovalPolicyBypasserDALFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-approver-dal"; import { accessApprovalPolicyDALFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-dal"; +import { accessApprovalPolicyEnvironmentDALFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-environment-dal"; import { accessApprovalPolicyServiceFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-service"; import { accessApprovalRequestDALFactory } from "@app/ee/services/access-approval-request/access-approval-request-dal"; import { accessApprovalRequestReviewerDALFactory } from "@app/ee/services/access-approval-request/access-approval-request-reviewer-dal"; @@ -76,6 +77,7 @@ import { secretApprovalPolicyBypasserDALFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-approver-dal"; import { secretApprovalPolicyDALFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-dal"; +import { secretApprovalPolicyEnvironmentDALFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-environment-dal"; import { secretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service"; import { secretApprovalRequestDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-dal"; import { secretApprovalRequestReviewerDALFactory } from "@app/ee/services/secret-approval-request/secret-approval-request-reviewer-dal"; @@ -425,9 +427,11 @@ export const registerRoutes = async ( const accessApprovalPolicyApproverDAL = accessApprovalPolicyApproverDALFactory(db); const accessApprovalPolicyBypasserDAL = accessApprovalPolicyBypasserDALFactory(db); const accessApprovalRequestReviewerDAL = accessApprovalRequestReviewerDALFactory(db); + const accessApprovalPolicyEnvironmentDAL = accessApprovalPolicyEnvironmentDALFactory(db); const sapApproverDAL = secretApprovalPolicyApproverDALFactory(db); const sapBypasserDAL = secretApprovalPolicyBypasserDALFactory(db); + const sapEnvironmentDAL = secretApprovalPolicyEnvironmentDALFactory(db); const secretApprovalPolicyDAL = secretApprovalPolicyDALFactory(db); const secretApprovalRequestDAL = secretApprovalRequestDALFactory(db); const secretApprovalRequestReviewerDAL = secretApprovalRequestReviewerDALFactory(db); @@ -561,6 +565,7 @@ export const registerRoutes = async ( projectEnvDAL, secretApprovalPolicyApproverDAL: sapApproverDAL, secretApprovalPolicyBypasserDAL: sapBypasserDAL, + secretApprovalPolicyEnvironmentDAL: sapEnvironmentDAL, permissionService, secretApprovalPolicyDAL, licenseService, @@ -1156,7 +1161,9 @@ export const registerRoutes = async ( keyStore, licenseService, projectDAL, - folderDAL + folderDAL, + accessApprovalPolicyEnvironmentDAL, + secretApprovalPolicyEnvironmentDAL: sapEnvironmentDAL }); const projectRoleService = projectRoleServiceFactory({ @@ -1318,6 +1325,7 @@ export const registerRoutes = async ( accessApprovalPolicyDAL, accessApprovalPolicyApproverDAL, accessApprovalPolicyBypasserDAL, + accessApprovalPolicyEnvironmentDAL, groupDAL, permissionService, projectEnvDAL, diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index 1a69f3845..8af4baa8b 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -93,6 +93,13 @@ export const sapPubSchema = SecretApprovalPoliciesSchema.merge( name: z.string(), slug: z.string() }), + environments: z.array( + z.object({ + id: z.string(), + name: z.string(), + slug: z.string() + }) + ), projectId: z.string() }) ); 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 937a8a84f..114794dc5 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 @@ -14,13 +14,13 @@ import { } from "./azure-app-configuration-connection-types"; export const getAzureAppConfigurationConnectionListItem = () => { - const { INF_APP_CONNECTION_AZURE_CLIENT_ID } = getConfig(); + const { INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID } = getConfig(); return { name: "Azure App Configuration" as const, app: AppConnection.AzureAppConfiguration as const, methods: Object.values(AzureAppConfigurationConnectionMethod) as [AzureAppConfigurationConnectionMethod.OAuth], - oauthClientId: INF_APP_CONNECTION_AZURE_CLIENT_ID + oauthClientId: INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID }; }; @@ -29,9 +29,16 @@ export const validateAzureAppConfigurationConnectionCredentials = async ( ) => { const { credentials: inputCredentials, method } = config; - const { INF_APP_CONNECTION_AZURE_CLIENT_ID, INF_APP_CONNECTION_AZURE_CLIENT_SECRET, SITE_URL } = getConfig(); + const { + INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID, + INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_SECRET, + SITE_URL + } = getConfig(); - if (!INF_APP_CONNECTION_AZURE_CLIENT_ID || !INF_APP_CONNECTION_AZURE_CLIENT_SECRET) { + if ( + !INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID || + !INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_SECRET + ) { throw new InternalServerError({ message: `Azure ${getAppConnectionMethodName(method)} environment variables have not been configured` }); @@ -47,8 +54,8 @@ export const validateAzureAppConfigurationConnectionCredentials = async ( grant_type: "authorization_code", code: inputCredentials.code, scope: `openid offline_access https://azconfig.io/.default`, - client_id: INF_APP_CONNECTION_AZURE_CLIENT_ID, - client_secret: INF_APP_CONNECTION_AZURE_CLIENT_SECRET, + client_id: INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID, + client_secret: INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_SECRET, redirect_uri: `${SITE_URL}/organization/app-connections/azure/oauth/callback` }) ); diff --git a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-enums.ts b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-enums.ts index 338126c1e..eb0521c64 100644 --- a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-enums.ts +++ b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-enums.ts @@ -1,3 +1,4 @@ export enum AzureClientSecretsConnectionMethod { - OAuth = "oauth" + OAuth = "oauth", + ClientSecret = "client-secret" } 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 463e40e7c..41dbb4392 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 @@ -1,3 +1,4 @@ +/* eslint-disable no-case-declarations */ import { AxiosError, AxiosResponse } from "axios"; import { getConfig } from "@app/lib/config/env"; @@ -16,18 +17,22 @@ import { AppConnection } from "../app-connection-enums"; import { AzureClientSecretsConnectionMethod } from "./azure-client-secrets-connection-enums"; import { ExchangeCodeAzureResponse, + TAzureClientSecretsConnectionClientSecretCredentials, TAzureClientSecretsConnectionConfig, TAzureClientSecretsConnectionCredentials } from "./azure-client-secrets-connection-types"; export const getAzureClientSecretsConnectionListItem = () => { - const { INF_APP_CONNECTION_AZURE_CLIENT_ID } = getConfig(); + const { INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID } = getConfig(); return { name: "Azure Client Secrets" as const, app: AppConnection.AzureClientSecrets as const, - methods: Object.values(AzureClientSecretsConnectionMethod) as [AzureClientSecretsConnectionMethod.OAuth], - oauthClientId: INF_APP_CONNECTION_AZURE_CLIENT_ID + methods: Object.values(AzureClientSecretsConnectionMethod) as [ + AzureClientSecretsConnectionMethod.OAuth, + AzureClientSecretsConnectionMethod.ClientSecret + ], + oauthClientId: INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID }; }; @@ -37,12 +42,6 @@ export const getAzureConnectionAccessToken = async ( kmsService: Pick ) => { const appCfg = getConfig(); - if (!appCfg.INF_APP_CONNECTION_AZURE_CLIENT_ID || !appCfg.INF_APP_CONNECTION_AZURE_CLIENT_SECRET) { - throw new BadRequestError({ - message: `Azure environment variables have not been configured` - }); - } - const appConnection = await appConnectionDAL.findById(connectionId); if (!appConnection) { @@ -63,104 +62,195 @@ export const getAzureConnectionAccessToken = async ( const { refreshToken } = credentials; const currentTime = Date.now(); + switch (appConnection.method) { + case AzureClientSecretsConnectionMethod.OAuth: + if ( + !appCfg.INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID || + !appCfg.INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_SECRET + ) { + throw new BadRequestError({ + message: `Azure OAuth environment variables have not been configured` + }); + } + const { data } = await request.post( + IntegrationUrls.AZURE_TOKEN_URL.replace("common", credentials.tenantId || "common"), + new URLSearchParams({ + grant_type: "refresh_token", + scope: `openid offline_access https://graph.microsoft.com/.default`, + client_id: appCfg.INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID, + client_secret: appCfg.INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_SECRET, + refresh_token: refreshToken + }) + ); - const { data } = await request.post( - IntegrationUrls.AZURE_TOKEN_URL.replace("common", credentials.tenantId || "common"), - new URLSearchParams({ - grant_type: "refresh_token", - scope: `openid offline_access https://graph.microsoft.com/.default`, - client_id: appCfg.INF_APP_CONNECTION_AZURE_CLIENT_ID, - client_secret: appCfg.INF_APP_CONNECTION_AZURE_CLIENT_SECRET, - refresh_token: refreshToken - }) - ); + const updatedCredentials = { + ...credentials, + accessToken: data.access_token, + expiresAt: currentTime + data.expires_in * 1000, + refreshToken: data.refresh_token + }; - const updatedCredentials = { - ...credentials, - accessToken: data.access_token, - expiresAt: currentTime + data.expires_in * 1000, - refreshToken: data.refresh_token - }; + const encryptedCredentials = await encryptAppConnectionCredentials({ + credentials: updatedCredentials, + orgId: appConnection.orgId, + kmsService + }); - const encryptedCredentials = await encryptAppConnectionCredentials({ - credentials: updatedCredentials, - orgId: appConnection.orgId, - kmsService - }); + await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials }); - await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials }); + return data.access_token; + case AzureClientSecretsConnectionMethod.ClientSecret: + const accessTokenCredentials = (await decryptAppConnectionCredentials({ + orgId: appConnection.orgId, + kmsService, + encryptedCredentials: appConnection.encryptedCredentials + })) as TAzureClientSecretsConnectionClientSecretCredentials; + const { accessToken, expiresAt, clientId, clientSecret, tenantId } = accessTokenCredentials; + if (accessToken && expiresAt && expiresAt > currentTime + 300000) { + return accessToken; + } - return data.access_token; + const { data: clientData } = await request.post( + IntegrationUrls.AZURE_TOKEN_URL.replace("common", tenantId || "common"), + new URLSearchParams({ + grant_type: "client_credentials", + scope: `https://graph.microsoft.com/.default`, + client_id: clientId, + client_secret: clientSecret + }) + ); + + const updatedClientCredentials = { + ...accessTokenCredentials, + accessToken: clientData.access_token, + expiresAt: currentTime + clientData.expires_in * 1000 + }; + + const encryptedClientCredentials = await encryptAppConnectionCredentials({ + credentials: updatedClientCredentials, + orgId: appConnection.orgId, + kmsService + }); + + await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials: encryptedClientCredentials }); + + return clientData.access_token; + default: + throw new InternalServerError({ + message: `Unhandled Azure connection method: ${appConnection.method as AzureClientSecretsConnectionMethod}` + }); + } }; export const validateAzureClientSecretsConnectionCredentials = async (config: TAzureClientSecretsConnectionConfig) => { const { credentials: inputCredentials, method } = config; - const { INF_APP_CONNECTION_AZURE_CLIENT_ID, INF_APP_CONNECTION_AZURE_CLIENT_SECRET, SITE_URL } = getConfig(); - - if (!SITE_URL) { - throw new InternalServerError({ message: "SITE_URL env var is required to complete Azure OAuth flow" }); - } - - if (!INF_APP_CONNECTION_AZURE_CLIENT_ID || !INF_APP_CONNECTION_AZURE_CLIENT_SECRET) { - throw new InternalServerError({ - message: `Azure ${getAppConnectionMethodName(method)} environment variables have not been configured` - }); - } - - let tokenResp: AxiosResponse | null = null; - let tokenError: AxiosError | null = null; - - try { - tokenResp = await request.post( - IntegrationUrls.AZURE_TOKEN_URL.replace("common", inputCredentials.tenantId || "common"), - new URLSearchParams({ - grant_type: "authorization_code", - code: inputCredentials.code, - scope: `openid offline_access https://graph.microsoft.com/.default`, - client_id: INF_APP_CONNECTION_AZURE_CLIENT_ID, - client_secret: INF_APP_CONNECTION_AZURE_CLIENT_SECRET, - redirect_uri: `${SITE_URL}/organization/app-connections/azure/oauth/callback` - }) - ); - } catch (e: unknown) { - if (e instanceof AxiosError) { - tokenError = e; - } else { - throw new BadRequestError({ - message: `Unable to validate connection: verify credentials` - }); - } - } - - if (tokenError) { - if (tokenError instanceof AxiosError) { - throw new BadRequestError({ - message: `Failed to get access token: ${ - (tokenError?.response?.data as { error_description?: string })?.error_description || "Unknown error" - }` - }); - } else { - throw new InternalServerError({ - message: "Failed to get access token" - }); - } - } - - if (!tokenResp) { - throw new InternalServerError({ - message: `Failed to get access token: Token was empty with no error` - }); - } + const { + INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID, + INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_SECRET, + SITE_URL + } = getConfig(); switch (method) { case AzureClientSecretsConnectionMethod.OAuth: + if (!SITE_URL) { + throw new InternalServerError({ message: "SITE_URL env var is required to complete Azure OAuth flow" }); + } + + if ( + !INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID || + !INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_SECRET + ) { + throw new InternalServerError({ + message: `Azure ${getAppConnectionMethodName(method)} environment variables have not been configured` + }); + } + + let tokenResp: AxiosResponse | null = null; + let tokenError: AxiosError | null = null; + + try { + tokenResp = await request.post( + IntegrationUrls.AZURE_TOKEN_URL.replace("common", inputCredentials.tenantId || "common"), + new URLSearchParams({ + grant_type: "authorization_code", + code: inputCredentials.code, + scope: `openid offline_access https://graph.microsoft.com/.default`, + client_id: INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID, + client_secret: INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_SECRET, + redirect_uri: `${SITE_URL}/organization/app-connections/azure/oauth/callback` + }) + ); + } catch (e: unknown) { + if (e instanceof AxiosError) { + tokenError = e; + } else { + throw new BadRequestError({ + message: `Unable to validate connection: verify credentials` + }); + } + } + + if (tokenError) { + if (tokenError instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to get access token: ${ + (tokenError?.response?.data as { error_description?: string })?.error_description || "Unknown error" + }` + }); + } else { + throw new InternalServerError({ + message: "Failed to get access token" + }); + } + } + + if (!tokenResp) { + throw new InternalServerError({ + message: `Failed to get access token: Token was empty with no error` + }); + } + return { tenantId: inputCredentials.tenantId, accessToken: tokenResp.data.access_token, refreshToken: tokenResp.data.refresh_token, expiresAt: Date.now() + tokenResp.data.expires_in * 1000 }; + + case AzureClientSecretsConnectionMethod.ClientSecret: + const { tenantId, clientId, clientSecret } = inputCredentials; + try { + const { data: clientData } = await request.post( + IntegrationUrls.AZURE_TOKEN_URL.replace("common", tenantId || "common"), + new URLSearchParams({ + grant_type: "client_credentials", + scope: `https://graph.microsoft.com/.default`, + client_id: clientId, + client_secret: clientSecret + }) + ); + + return { + tenantId, + accessToken: clientData.access_token, + expiresAt: Date.now() + clientData.expires_in * 1000, + clientId, + clientSecret + }; + } catch (e: unknown) { + if (e instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to get access token: ${ + (e?.response?.data as { error_description?: string })?.error_description || "Unknown error" + }` + }); + } else { + throw new InternalServerError({ + message: "Failed to get access token" + }); + } + } default: throw new InternalServerError({ message: `Unhandled Azure connection method: ${method as AzureClientSecretsConnectionMethod}` diff --git a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-schemas.ts b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-schemas.ts index 2b4e65a13..d9f178a06 100644 --- a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-schemas.ts +++ b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-schemas.ts @@ -26,6 +26,36 @@ export const AzureClientSecretsConnectionOAuthOutputCredentialsSchema = z.object expiresAt: z.number() }); +export const AzureClientSecretsConnectionClientSecretInputCredentialsSchema = z.object({ + clientId: z + .string() + .uuid() + .trim() + .min(1, "Client ID required") + .max(50, "Client ID must be at most 50 characters long") + .describe(AppConnections.CREDENTIALS.AZURE_CLIENT_SECRETS.clientId), + clientSecret: z + .string() + .trim() + .min(1, "Client Secret required") + .max(50, "Client Secret must be at most 50 characters long") + .describe(AppConnections.CREDENTIALS.AZURE_CLIENT_SECRETS.clientSecret), + tenantId: z + .string() + .uuid() + .trim() + .min(1, "Tenant ID required") + .describe(AppConnections.CREDENTIALS.AZURE_CLIENT_SECRETS.tenantId) +}); + +export const AzureClientSecretsConnectionClientSecretOutputCredentialsSchema = z.object({ + clientId: z.string(), + clientSecret: z.string(), + tenantId: z.string(), + accessToken: z.string(), + expiresAt: z.number() +}); + export const ValidateAzureClientSecretsConnectionCredentialsSchema = z.discriminatedUnion("method", [ z.object({ method: z @@ -34,6 +64,14 @@ export const ValidateAzureClientSecretsConnectionCredentialsSchema = z.discrimin credentials: AzureClientSecretsConnectionOAuthInputCredentialsSchema.describe( AppConnections.CREATE(AppConnection.AzureClientSecrets).credentials ) + }), + z.object({ + method: z + .literal(AzureClientSecretsConnectionMethod.ClientSecret) + .describe(AppConnections.CREATE(AppConnection.AzureClientSecrets).method), + credentials: AzureClientSecretsConnectionClientSecretInputCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.AzureClientSecrets).credentials + ) }) ]); @@ -43,9 +81,13 @@ export const CreateAzureClientSecretsConnectionSchema = ValidateAzureClientSecre export const UpdateAzureClientSecretsConnectionSchema = z .object({ - credentials: AzureClientSecretsConnectionOAuthInputCredentialsSchema.optional().describe( - AppConnections.UPDATE(AppConnection.AzureClientSecrets).credentials - ) + credentials: z + .union([ + AzureClientSecretsConnectionOAuthInputCredentialsSchema, + AzureClientSecretsConnectionClientSecretInputCredentialsSchema + ]) + .optional() + .describe(AppConnections.UPDATE(AppConnection.AzureClientSecrets).credentials) }) .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.AzureClientSecrets)); @@ -59,6 +101,10 @@ export const AzureClientSecretsConnectionSchema = z.intersection( z.object({ method: z.literal(AzureClientSecretsConnectionMethod.OAuth), credentials: AzureClientSecretsConnectionOAuthOutputCredentialsSchema + }), + z.object({ + method: z.literal(AzureClientSecretsConnectionMethod.ClientSecret), + credentials: AzureClientSecretsConnectionClientSecretOutputCredentialsSchema }) ]) ); @@ -69,6 +115,13 @@ export const SanitizedAzureClientSecretsConnectionSchema = z.discriminatedUnion( credentials: AzureClientSecretsConnectionOAuthOutputCredentialsSchema.pick({ tenantId: true }) + }), + BaseAzureClientSecretsConnectionSchema.extend({ + method: z.literal(AzureClientSecretsConnectionMethod.ClientSecret), + credentials: AzureClientSecretsConnectionClientSecretOutputCredentialsSchema.pick({ + clientId: true, + tenantId: true + }) }) ]); diff --git a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-types.ts b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-types.ts index fb20fbadd..1ad5a3411 100644 --- a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-types.ts +++ b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-types.ts @@ -4,6 +4,7 @@ import { DiscriminativePick } from "@app/lib/types"; import { AppConnection } from "../app-connection-enums"; import { + AzureClientSecretsConnectionClientSecretOutputCredentialsSchema, AzureClientSecretsConnectionOAuthOutputCredentialsSchema, AzureClientSecretsConnectionSchema, CreateAzureClientSecretsConnectionSchema, @@ -30,6 +31,10 @@ export type TAzureClientSecretsConnectionCredentials = z.infer< typeof AzureClientSecretsConnectionOAuthOutputCredentialsSchema >; +export type TAzureClientSecretsConnectionClientSecretCredentials = z.infer< + typeof AzureClientSecretsConnectionClientSecretOutputCredentialsSchema +>; + export interface ExchangeCodeAzureResponse { token_type: string; scope: string; 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 644747353..e9bb1f6bd 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 @@ -23,7 +23,7 @@ import { } from "./azure-devops-types"; export const getAzureDevopsConnectionListItem = () => { - const { INF_APP_CONNECTION_AZURE_CLIENT_ID } = getConfig(); + const { INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_ID } = getConfig(); return { name: "Azure DevOps" as const, @@ -32,7 +32,7 @@ export const getAzureDevopsConnectionListItem = () => { AzureDevOpsConnectionMethod.OAuth, AzureDevOpsConnectionMethod.AccessToken ], - oauthClientId: INF_APP_CONNECTION_AZURE_CLIENT_ID + oauthClientId: INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_ID }; }; @@ -63,7 +63,7 @@ export const getAzureDevopsConnection = async ( switch (appConnection.method) { case AzureDevOpsConnectionMethod.OAuth: const appCfg = getConfig(); - if (!appCfg.INF_APP_CONNECTION_AZURE_CLIENT_ID || !appCfg.INF_APP_CONNECTION_AZURE_CLIENT_SECRET) { + if (!appCfg.INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_ID || !appCfg.INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_SECRET) { throw new BadRequestError({ message: `Azure environment variables have not been configured` }); @@ -81,8 +81,8 @@ export const getAzureDevopsConnection = async ( new URLSearchParams({ grant_type: "refresh_token", scope: `https://app.vssps.visualstudio.com/.default`, - client_id: appCfg.INF_APP_CONNECTION_AZURE_CLIENT_ID, - client_secret: appCfg.INF_APP_CONNECTION_AZURE_CLIENT_SECRET, + client_id: appCfg.INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_ID, + client_secret: appCfg.INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_SECRET, refresh_token: refreshToken }) ); @@ -119,7 +119,8 @@ export const getAzureDevopsConnection = async ( export const validateAzureDevOpsConnectionCredentials = async (config: TAzureDevOpsConnectionConfig) => { const { credentials: inputCredentials, method } = config; - const { INF_APP_CONNECTION_AZURE_CLIENT_ID, INF_APP_CONNECTION_AZURE_CLIENT_SECRET, SITE_URL } = getConfig(); + const { INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_ID, INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_SECRET, SITE_URL } = + getConfig(); switch (method) { case AzureDevOpsConnectionMethod.OAuth: @@ -127,7 +128,7 @@ export const validateAzureDevOpsConnectionCredentials = async (config: TAzureDev throw new InternalServerError({ message: "SITE_URL env var is required to complete Azure OAuth flow" }); } - if (!INF_APP_CONNECTION_AZURE_CLIENT_ID || !INF_APP_CONNECTION_AZURE_CLIENT_SECRET) { + if (!INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_ID || !INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_SECRET) { throw new InternalServerError({ message: `Azure ${getAppConnectionMethodName(method)} environment variables have not been configured` }); @@ -144,8 +145,8 @@ export const validateAzureDevOpsConnectionCredentials = async (config: TAzureDev grant_type: "authorization_code", code: oauthCredentials.code, scope: `https://app.vssps.visualstudio.com/.default`, - client_id: INF_APP_CONNECTION_AZURE_CLIENT_ID, - client_secret: INF_APP_CONNECTION_AZURE_CLIENT_SECRET, + client_id: INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_ID, + client_secret: INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_SECRET, redirect_uri: `${SITE_URL}/organization/app-connections/azure/oauth/callback` }) ); 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 116597ec4..95102c5d1 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 @@ -26,7 +26,10 @@ export const getAzureConnectionAccessToken = async ( kmsService: Pick ) => { const appCfg = getConfig(); - if (!appCfg.INF_APP_CONNECTION_AZURE_CLIENT_ID || !appCfg.INF_APP_CONNECTION_AZURE_CLIENT_SECRET) { + if ( + !appCfg.INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID || + !appCfg.INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET + ) { throw new BadRequestError({ message: `Azure environment variables have not been configured` }); @@ -57,8 +60,8 @@ export const getAzureConnectionAccessToken = async ( new URLSearchParams({ grant_type: "refresh_token", scope: `openid offline_access`, - client_id: appCfg.INF_APP_CONNECTION_AZURE_CLIENT_ID, - client_secret: appCfg.INF_APP_CONNECTION_AZURE_CLIENT_SECRET, + client_id: appCfg.INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID, + client_secret: appCfg.INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET, refresh_token: credentials.refreshToken }) ); @@ -92,22 +95,23 @@ export const getAzureConnectionAccessToken = async ( }; export const getAzureKeyVaultConnectionListItem = () => { - const { INF_APP_CONNECTION_AZURE_CLIENT_ID } = getConfig(); + const { INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID } = getConfig(); return { name: "Azure Key Vault" as const, app: AppConnection.AzureKeyVault as const, methods: Object.values(AzureKeyVaultConnectionMethod) as [AzureKeyVaultConnectionMethod.OAuth], - oauthClientId: INF_APP_CONNECTION_AZURE_CLIENT_ID + oauthClientId: INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID }; }; export const validateAzureKeyVaultConnectionCredentials = async (config: TAzureKeyVaultConnectionConfig) => { const { credentials: inputCredentials, method } = config; - const { INF_APP_CONNECTION_AZURE_CLIENT_ID, INF_APP_CONNECTION_AZURE_CLIENT_SECRET, SITE_URL } = getConfig(); + const { INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID, INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET, SITE_URL } = + getConfig(); - if (!INF_APP_CONNECTION_AZURE_CLIENT_ID || !INF_APP_CONNECTION_AZURE_CLIENT_SECRET) { + if (!INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID || !INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET) { throw new InternalServerError({ message: `Azure ${getAppConnectionMethodName(method)} environment variables have not been configured` }); @@ -123,8 +127,8 @@ export const validateAzureKeyVaultConnectionCredentials = async (config: TAzureK grant_type: "authorization_code", code: inputCredentials.code, scope: `openid offline_access https://vault.azure.net/.default`, - client_id: INF_APP_CONNECTION_AZURE_CLIENT_ID, - client_secret: INF_APP_CONNECTION_AZURE_CLIENT_SECRET, + client_id: INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID, + client_secret: INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET, redirect_uri: `${SITE_URL}/organization/app-connections/azure/oauth/callback` }) ); diff --git a/backend/src/services/project-env/project-env-service.ts b/backend/src/services/project-env/project-env-service.ts index 9a82a6bbe..7fbe7343e 100644 --- a/backend/src/services/project-env/project-env-service.ts +++ b/backend/src/services/project-env/project-env-service.ts @@ -1,9 +1,11 @@ import { ForbiddenError } from "@casl/ability"; import { ActionProjectType } from "@app/db/schemas"; +import { TAccessApprovalPolicyEnvironmentDALFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-environment-dal"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { TSecretApprovalPolicyEnvironmentDALFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-environment-dal"; import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; @@ -20,6 +22,8 @@ type TProjectEnvServiceFactoryDep = { permissionService: Pick; licenseService: Pick; keyStore: Pick; + accessApprovalPolicyEnvironmentDAL: Pick; + secretApprovalPolicyEnvironmentDAL: Pick; }; export type TProjectEnvServiceFactory = ReturnType; @@ -30,7 +34,9 @@ export const projectEnvServiceFactory = ({ licenseService, keyStore, projectDAL, - folderDAL + folderDAL, + accessApprovalPolicyEnvironmentDAL, + secretApprovalPolicyEnvironmentDAL }: TProjectEnvServiceFactoryDep) => { const createEnvironment = async ({ projectId, @@ -220,6 +226,20 @@ export const projectEnvServiceFactory = ({ } const env = await projectEnvDAL.transaction(async (tx) => { + const secretApprovalPolicies = await secretApprovalPolicyEnvironmentDAL.findAvailablePoliciesByEnvId(id, tx); + if (secretApprovalPolicies.length > 0) { + throw new BadRequestError({ + message: "Environment is in use by a secret approval policy", + name: "DeleteEnvironment" + }); + } + const accessApprovalPolicies = await accessApprovalPolicyEnvironmentDAL.findAvailablePoliciesByEnvId(id, tx); + if (accessApprovalPolicies.length > 0) { + throw new BadRequestError({ + message: "Environment is in use by an access approval policy", + name: "DeleteEnvironment" + }); + } const [doc] = await projectEnvDAL.delete({ id, projectId }, tx); if (!doc) throw new NotFoundError({ diff --git a/backend/src/services/reminder/reminder-queue.ts b/backend/src/services/reminder/reminder-queue.ts index 1487a63f3..c038734bd 100644 --- a/backend/src/services/reminder/reminder-queue.ts +++ b/backend/src/services/reminder/reminder-queue.ts @@ -173,12 +173,6 @@ export const dailyReminderQueueServiceFactory = ({ { pattern: "0 */1 * * *", utc: true }, QueueName.SecretReminderMigration // just a job id ); - - await queueService.queue(QueueName.SecretReminderMigration, QueueJobs.SecretReminderMigration, undefined, { - delay: 5000, - jobId: QueueName.SecretReminderMigration, - repeat: { pattern: "0 */1 * * *", utc: true } - }); }; queueService.listen(QueueName.DailyReminders, "failed", (_, err) => { 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 36e97e620..71347f998 100644 --- a/backend/src/services/secret-sync/render/render-sync-fns.ts +++ b/backend/src/services/secret-sync/render/render-sync-fns.ts @@ -97,6 +97,28 @@ const batchUpdateEnvironmentSecrets = async ( ); }; +const redeployService = async (secretSync: TRenderSyncWithCredentials) => { + const { + destinationConfig, + connection: { + credentials: { apiKey } + } + } = secretSync; + + await makeRequestWithRetry(() => + request.post( + `${IntegrationUrls.RENDER_API_URL}/v1/services/${destinationConfig.serviceId}/deploys`, + {}, + { + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json" + } + } + ) + ); +}; + export const RenderSyncFns = { syncSecrets: async (secretSync: TRenderSyncWithCredentials, secretMap: TSecretMap) => { const renderSecrets = await getRenderEnvironmentSecrets(secretSync); @@ -131,6 +153,10 @@ export const RenderSyncFns = { } await batchUpdateEnvironmentSecrets(secretSync, finalEnvVars); + + if (secretSync.syncOptions.autoRedeployServices) { + await redeployService(secretSync); + } }, getSecrets: async (secretSync: TRenderSyncWithCredentials): Promise => { @@ -151,5 +177,9 @@ export const RenderSyncFns = { } } await batchUpdateEnvironmentSecrets(secretSync, finalEnvVars); + + 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 77414c17c..0d6e93987 100644 --- a/backend/src/services/secret-sync/render/render-sync-schemas.ts +++ b/backend/src/services/secret-sync/render/render-sync-schemas.ts @@ -20,23 +20,33 @@ const RenderSyncDestinationConfigSchema = z.discriminatedUnion("scope", [ }) ]); +const RenderSyncOptionsSchema = z.object({ + autoRedeployServices: z.boolean().optional().describe(SecretSyncs.ADDITIONAL_SYNC_OPTIONS.RENDER.autoRedeployServices) +}); + const RenderSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; -export const RenderSyncSchema = BaseSecretSyncSchema(SecretSync.Render, RenderSyncOptionsConfig).extend({ +export const RenderSyncSchema = BaseSecretSyncSchema( + SecretSync.Render, + RenderSyncOptionsConfig, + RenderSyncOptionsSchema +).extend({ destination: z.literal(SecretSync.Render), destinationConfig: RenderSyncDestinationConfigSchema }); export const CreateRenderSyncSchema = GenericCreateSecretSyncFieldsSchema( SecretSync.Render, - RenderSyncOptionsConfig + RenderSyncOptionsConfig, + RenderSyncOptionsSchema ).extend({ destinationConfig: RenderSyncDestinationConfigSchema }); export const UpdateRenderSyncSchema = GenericUpdateSecretSyncFieldsSchema( SecretSync.Render, - RenderSyncOptionsConfig + RenderSyncOptionsConfig, + RenderSyncOptionsSchema ).extend({ destinationConfig: RenderSyncDestinationConfigSchema.optional() }); diff --git a/docs/images/app-connections/azure/client-secrets/create-client-secrets-method.png b/docs/images/app-connections/azure/client-secrets/create-client-secrets-method.png new file mode 100644 index 000000000..63717c547 Binary files /dev/null and b/docs/images/app-connections/azure/client-secrets/create-client-secrets-method.png differ diff --git a/docs/integrations/app-connections/azure-app-configuration.mdx b/docs/integrations/app-connections/azure-app-configuration.mdx index 959a1812a..679839878 100644 --- a/docs/integrations/app-connections/azure-app-configuration.mdx +++ b/docs/integrations/app-connections/azure-app-configuration.mdx @@ -50,8 +50,8 @@ Infisical currently only supports one method for connecting to Azure, which is O Back in your Infisical instance, add two new environment variables for the credentials of your Azure application. - - `INF_APP_CONNECTION_AZURE_CLIENT_ID`: The **Application (Client) ID** of your Azure application. - - `INF_APP_CONNECTION_AZURE_CLIENT_SECRET`: The **Client Secret** of your Azure application. + - `INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID`: The **Application (Client) ID** of your Azure application. + - `INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_SECRET`: The **Client Secret** of your Azure application. Once added, restart your Infisical instance and use the Azure App Configuration connection. diff --git a/docs/integrations/app-connections/azure-client-secrets.mdx b/docs/integrations/app-connections/azure-client-secrets.mdx index 55416303a..1fb1c753f 100644 --- a/docs/integrations/app-connections/azure-client-secrets.mdx +++ b/docs/integrations/app-connections/azure-client-secrets.mdx @@ -43,12 +43,6 @@ Infisical currently only supports one method for connecting to Azure, which is O - `Application.ReadWrite.All` (Delegated) - `Directory.ReadWrite.All` (Delegated) - `User.Read` (Delegated) - - Azure App Configuration - - `KeyValue.Delete` (Delegated) - - `KeyValue.Read` (Delegated) - - `KeyValue.Write` (Delegated) - - Access Key Vault - - `user_impersonation` (Delegated) ![Azure client secrets](/images/integrations/azure-client-secrets/app-api-permissions.png) @@ -63,8 +57,8 @@ Infisical currently only supports one method for connecting to Azure, which is O Back in your Infisical instance, add two new environment variables for the credentials of your Azure application. - - `INF_APP_CONNECTION_AZURE_CLIENT_ID`: The **Application (Client) ID** of your Azure application. - - `INF_APP_CONNECTION_AZURE_CLIENT_SECRET`: The **Client Secret** of your Azure application. + - `INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID`: The **Application (Client) ID** of your Azure application. + - `INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_SECRET`: The **Client Secret** of your Azure application. Once added, restart your Infisical instance and use the Azure Client Secrets connection. @@ -72,6 +66,30 @@ Infisical currently only supports one method for connecting to Azure, which is O + + Ensure your Azure application has the required permissions that Infisical needs for the Azure Client Secrets connection to work. + + **Prerequisites:** + - An active Azure setup. + + + + For the Azure Client Secrets connection to work, assign the following permissions to your Azure application: + + #### Required API Permissions + + **Microsoft Graph** + - `Application.ReadWrite.All` + - `Application.ReadWrite.OwnedBy` + - `Application.ReadWrite.All` (Delegated) + - `Directory.ReadWrite.All` (Delegated) + - `User.Read` (Delegated) + + ![Azure client secrets](/images/integrations/azure-client-secrets/app-api-permissions.png) + + + + ## Setup Azure Connection in Infisical @@ -82,21 +100,31 @@ Infisical currently only supports one method for connecting to Azure, which is O Select the **Azure Connection** option from the connection options modal. ![Select Azure Connection](/images/app-connections/azure/client-secrets/select-connection.png) - - Fill in the **Tenant ID** field with the Directory (Tenant) ID you obtained in the previous step. + + + + + Fill in the **Tenant ID** field with the Directory (Tenant) ID you obtained in the previous step. - Now select the **OAuth** method and click **Connect to Azure**. + Now select the **OAuth** method and click **Connect to Azure**. - ![Connect via Azure OAUth](/images/app-connections/azure/client-secrets/create-oauth-method.png) + ![Connect via Azure OAUth](/images/app-connections/azure/client-secrets/create-oauth-method.png) + + + You will then be redirected to Azure to grant Infisical access to your Azure account. Once granted, + you will be redirected back to Infisical's App Connections page. ![Azure Client Secrets + Authorization](/images/app-connections/azure/grant-access.png) + + + + + Fill in the **Tenant ID**, **Client ID** and **Client Secret** fields with the Directory (Tenant) ID, Application (Client) ID and Client Secret you obtained in the previous step. - - - - - You will then be redirected to Azure to grant Infisical access to your Azure account. Once granted, - you will be redirected back to Infisical's App Connections page. ![Azure Client Secrets - Authorization](/images/app-connections/azure/grant-access.png) - + ![Connect via Azure OAUth](/images/app-connections/azure/client-secrets/create-client-secrets-method.png) + + + + Your **Azure Client Secrets Connection** is now available for use. ![Azure Client Secrets](/images/app-connections/azure/client-secrets/oauth-connection.png) diff --git a/docs/integrations/app-connections/azure-devops.mdx b/docs/integrations/app-connections/azure-devops.mdx index 8fcc25427..6a9e71430 100644 --- a/docs/integrations/app-connections/azure-devops.mdx +++ b/docs/integrations/app-connections/azure-devops.mdx @@ -56,8 +56,8 @@ Infisical currently supports two methods for connecting to Azure DevOps, which a Back in your Infisical instance, add two new environment variables for the credentials of your Azure application. - - `INF_APP_CONNECTION_AZURE_CLIENT_ID`: The **Application (Client) ID** of your Azure application. - - `INF_APP_CONNECTION_AZURE_CLIENT_SECRET`: The **Client Secret** of your Azure application. + - `INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_ID`: The **Application (Client) ID** of your Azure application. + - `INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_SECRET`: The **Client Secret** of your Azure application. Once added, restart your Infisical instance and use the Azure Client Secrets connection. diff --git a/docs/integrations/app-connections/azure-key-vault.mdx b/docs/integrations/app-connections/azure-key-vault.mdx index f73dab834..22cdcf637 100644 --- a/docs/integrations/app-connections/azure-key-vault.mdx +++ b/docs/integrations/app-connections/azure-key-vault.mdx @@ -49,8 +49,8 @@ Infisical currently only supports one method for connecting to Azure, which is O Back in your Infisical instance, add two new environment variables for the credentials of your Azure application. - - `INF_APP_CONNECTION_AZURE_CLIENT_ID`: The **Application (Client) ID** of your Azure application. - - `INF_APP_CONNECTION_AZURE_CLIENT_SECRET`: The **Client Secret** of your Azure application. + - `INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_ID`: The **Application (Client) ID** of your Azure application. + - `INF_APP_CONNECTION_AZURE_KEY_VAULT_CLIENT_SECRET`: The **Client Secret** of your Azure application. Once added, restart your Infisical instance and use the Azure Key Vault connection. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index edbf5673f..3c73d9fe3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -55,7 +55,7 @@ "@ucast/mongo2js": "^1.3.4", "@xyflow/react": "^12.4.4", "argon2-browser": "^1.18.0", - "axios": "^1.7.9", + "axios": "^1.11.0", "classnames": "^2.5.1", "cva": "npm:class-variance-authority@^0.7.1", "date-fns": "^4.1.0", @@ -5282,13 +5282,13 @@ } }, "node_modules/axios": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.3.tgz", - "integrity": "sha512-iP4DebzoNlP/YN2dpwCgb8zoCmhtkajzS48JvwmkSkXvPI3DHc7m+XYL5tGnSlJtR6nImXZmdCuN5aP8dh1d8A==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", + "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", + "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, @@ -5700,7 +5700,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz", "integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -6665,7 +6664,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.0.tgz", "integrity": "sha512-9+Sj30DIu+4KvHqMfLUGLFYL2PkURSYMVXJyXe92nFRvlYq5hBjLEhblKB+vkd/WVlUYMWigiY07T91Fkk0+4A==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.0", @@ -6819,7 +6817,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6829,7 +6826,6 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6867,7 +6863,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -6877,15 +6872,15 @@ } }, "node_modules/es-set-tostringtag": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", - "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", - "dev": true, + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "license": "MIT", "dependencies": { - "get-intrinsic": "^1.2.4", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", - "hasown": "^2.0.1" + "hasown": "^2.0.2" }, "engines": { "node": ">= 0.4" @@ -7855,13 +7850,15 @@ } }, "node_modules/form-data": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", - "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" }, "engines": { @@ -7992,7 +7989,6 @@ "version": "1.2.6", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.6.tgz", "integrity": "sha512-qxsEs+9A+u85HhllWJJFicJfPDhRmjzoYdl64aMWW9yRIJmSyxdn8IEkuIM530/7T+lv0TIHd8L6Q/ra0tEoeA==", - "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -8139,7 +8135,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -8215,7 +8210,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -8228,7 +8222,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -9545,7 +9538,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.0.0.tgz", "integrity": "sha512-4MqMiKP90ybymYvsut0CH2g4XWbfLtmlCkXmtmdcDCxNB+mQcu1w/1+L/VD7vi/PSv7X2JYV7SCcR+jiPXnQtA==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" diff --git a/frontend/package.json b/frontend/package.json index 9a2cc2ba4..1bc55c1c7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -59,7 +59,7 @@ "@ucast/mongo2js": "^1.3.4", "@xyflow/react": "^12.4.4", "argon2-browser": "^1.18.0", - "axios": "^1.7.9", + "axios": "^1.11.0", "classnames": "^2.5.1", "cva": "npm:class-variance-authority@^0.7.1", "date-fns": "^4.1.0", diff --git a/frontend/src/components/permissions/OrgPermissionCan.tsx b/frontend/src/components/permissions/OrgPermissionCan.tsx index bbe5bf986..d2e698e88 100644 --- a/frontend/src/components/permissions/OrgPermissionCan.tsx +++ b/frontend/src/components/permissions/OrgPermissionCan.tsx @@ -1,26 +1,14 @@ import { FunctionComponent, ReactNode } from "react"; import { BoundCanProps, Can } from "@casl/react"; -import { faLock } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { TOrgPermission, useOrgPermission } from "@app/context/OrgPermissionContext"; -import { Tooltip } from "../v2"; +import { AccessRestrictedBanner, Tooltip } from "../v2"; export const OrgPermissionGuardBanner = () => { return (
-
-
- -
-
-
Access Restricted
-
- Your role has limited permissions, please
contact your admin to gain access -
-
-
+
); }; diff --git a/frontend/src/components/permissions/PermissionDeniedBanner.tsx b/frontend/src/components/permissions/PermissionDeniedBanner.tsx index b3c7a4f53..3f86a7257 100644 --- a/frontend/src/components/permissions/PermissionDeniedBanner.tsx +++ b/frontend/src/components/permissions/PermissionDeniedBanner.tsx @@ -1,15 +1,12 @@ -import { ReactNode } from "react"; -import { faLock } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; +import { AccessRestrictedBanner } from "@app/components/v2"; + type Props = { containerClassName?: string; - className?: string; - children?: ReactNode; }; -export const PermissionDeniedBanner = ({ containerClassName, className, children }: Props) => { +export const PermissionDeniedBanner = ({ containerClassName }: Props) => { return (
-
-
-
- -
-
-
Access Restricted
- {children || ( -
- Your role has limited permissions, please
contact your administrator to gain - access -
- )} -
-
-
+
); }; diff --git a/frontend/src/components/permissions/ProjectPermissionCan.tsx b/frontend/src/components/permissions/ProjectPermissionCan.tsx index f49709ff3..88a8c6ad9 100644 --- a/frontend/src/components/permissions/ProjectPermissionCan.tsx +++ b/frontend/src/components/permissions/ProjectPermissionCan.tsx @@ -1,9 +1,8 @@ import { FunctionComponent, ReactNode } from "react"; import { AbilityTuple, MongoAbility } from "@casl/ability"; import { Can } from "@casl/react"; -import { faLock } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { AccessRestrictedBanner } from "@app/components/v2"; import { ProjectPermissionSet, useProjectPermission } from "@app/context/ProjectPermissionContext"; import { Tooltip } from "../v2/Tooltip"; @@ -11,17 +10,7 @@ import { Tooltip } from "../v2/Tooltip"; export const ProjectPermissionGuardBanner = () => { return (
-
-
- -
-
-
Access Restricted
-
- Your role has limited permissions, please
contact your admin to gain access -
-
-
+
); }; diff --git a/frontend/src/components/secret-rotations-v2/CreateSecretRotationV2Modal.tsx b/frontend/src/components/secret-rotations-v2/CreateSecretRotationV2Modal.tsx index 210257d7d..f5b9a39b3 100644 --- a/frontend/src/components/secret-rotations-v2/CreateSecretRotationV2Modal.tsx +++ b/frontend/src/components/secret-rotations-v2/CreateSecretRotationV2Modal.tsx @@ -76,7 +76,6 @@ export const CreateSecretRotationV2Modal = ({ onOpenChange, isOpen, ...props }: ) } - onPointerDownOutside={(e) => e.preventDefault()} className={selectedRotation ? "max-w-2xl" : "max-w-3xl"} subTitle={ selectedRotation ? undefined : "Select a provider to create a secret rotation for." diff --git a/frontend/src/components/secret-scanning/CreateSecretScanningDataSourceModal.tsx b/frontend/src/components/secret-scanning/CreateSecretScanningDataSourceModal.tsx index a3943cf35..c95baaae5 100644 --- a/frontend/src/components/secret-scanning/CreateSecretScanningDataSourceModal.tsx +++ b/frontend/src/components/secret-scanning/CreateSecretScanningDataSourceModal.tsx @@ -75,7 +75,6 @@ export const CreateSecretScanningDataSourceModal = ({ onOpenChange, isOpen, ...p ) } - onPointerDownOutside={(e) => e.preventDefault()} className={selectedDataSource ? "max-w-2xl" : "max-w-3xl"} subTitle={ selectedDataSource ? undefined : "Select a data source to configure secret scanning for." diff --git a/frontend/src/components/secret-syncs/CreateSecretSyncModal.tsx b/frontend/src/components/secret-syncs/CreateSecretSyncModal.tsx index 7bae0479f..5ff72e810 100644 --- a/frontend/src/components/secret-syncs/CreateSecretSyncModal.tsx +++ b/frontend/src/components/secret-syncs/CreateSecretSyncModal.tsx @@ -56,7 +56,6 @@ export const CreateSecretSyncModal = ({ onOpenChange, selectSync = null, ...prop "Add Sync" ) } - onPointerDownOutside={(e) => e.preventDefault()} className="max-w-2xl" bodyClassName="overflow-visible" subTitle={selectedSync ? undefined : "Select a third-party service to sync secrets to."} diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/RenderSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/RenderSyncFields.tsx index b5cb407cb..3d3f8df93 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/RenderSyncFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/RenderSyncFields.tsx @@ -9,7 +9,7 @@ import { useRenderConnectionListServices } from "@app/hooks/api/appConnections/render"; import { SecretSync } from "@app/hooks/api/secretSyncs"; -import { RenderSyncScope, RenderSyncType } from "@app/hooks/api/secretSyncs/render-sync"; +import { RenderSyncScope, RenderSyncType } from "@app/hooks/api/secretSyncs/types/render-sync"; import { TSecretSyncForm } from "../schemas"; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/RenderSyncOptionsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/RenderSyncOptionsFields.tsx new file mode 100644 index 000000000..6d4173bd0 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/RenderSyncOptionsFields.tsx @@ -0,0 +1,40 @@ +import { Controller, useFormContext } from "react-hook-form"; +import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { FormControl, Switch, Tooltip } from "@app/components/v2"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +import { TSecretSyncForm } from "../schemas"; + +export const RenderSyncOptionsFields = () => { + const { control } = useFormContext(); + + return ( + ( + + + Auto Redeploy Services On Sync + If enabled, services will be automatically redeployed upon secret changes.

+ } + > + +
+
+
+ )} + /> + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx index 50ea46ac0..cb7a40559 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx @@ -14,6 +14,7 @@ import { SecretSync, useSecretSyncOption } from "@app/hooks/api/secretSyncs"; import { TSecretSyncForm } from "../schemas"; import { AwsParameterStoreSyncOptionsFields } from "./AwsParameterStoreSyncOptionsFields"; import { AwsSecretsManagerSyncOptionsFields } from "./AwsSecretsManagerSyncOptionsFields"; +import { RenderSyncOptionsFields } from "./RenderSyncOptionsFields"; type Props = { hideInitialSync?: boolean; @@ -38,6 +39,9 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { case SecretSync.AWSSecretsManager: AdditionalSyncOptionsFieldsComponent = ; break; + case SecretSync.Render: + AdditionalSyncOptionsFieldsComponent = ; + break; case SecretSync.GitHub: case SecretSync.GCPSecretManager: case SecretSync.AzureKeyVault: @@ -54,7 +58,6 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { case SecretSync.OnePass: case SecretSync.OCIVault: case SecretSync.Heroku: - case SecretSync.Render: case SecretSync.Flyio: case SecretSync.GitLab: case SecretSync.CloudflarePages: diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/RenderSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/RenderSyncReviewFields.tsx index becc46c1d..15f5b6625 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/RenderSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/RenderSyncReviewFields.tsx @@ -2,8 +2,29 @@ import { useFormContext } from "react-hook-form"; 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"; +export const RenderSyncOptionsReviewFields = () => { + const { watch } = useFormContext(); + + const [{ autoRedeployServices }] = watch(["syncOptions"]); + + return ( +
+ {autoRedeployServices ? ( + + Enabled + + ) : ( + + Disabled + + )} +
+ ); +}; + export const RenderSyncReviewFields = () => { const { watch } = useFormContext(); const serviceName = watch("destinationConfig.serviceName"); diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index c1194a4c1..5ee53f2e3 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -35,7 +35,7 @@ import { HumanitecSyncReviewFields } from "./HumanitecSyncReviewFields"; import { OCIVaultSyncReviewFields } from "./OCIVaultSyncReviewFields"; import { OnePassSyncReviewFields } from "./OnePassSyncReviewFields"; import { RailwaySyncReviewFields } from "./RailwaySyncReviewFields"; -import { RenderSyncReviewFields } from "./RenderSyncReviewFields"; +import { RenderSyncOptionsReviewFields, RenderSyncReviewFields } from "./RenderSyncReviewFields"; import { SupabaseSyncReviewFields } from "./SupabaseSyncReviewFields"; import { TeamCitySyncReviewFields } from "./TeamCitySyncReviewFields"; import { TerraformCloudSyncReviewFields } from "./TerraformCloudSyncReviewFields"; @@ -121,6 +121,7 @@ export const SecretSyncReviewFields = () => { break; case SecretSync.Render: DestinationFieldsComponent = ; + AdditionalSyncOptionsFieldsComponent = ; break; case SecretSync.Flyio: DestinationFieldsComponent = ; 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 16b213421..83e5347eb 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 @@ -2,9 +2,13 @@ import { z } from "zod"; import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema"; import { SecretSync } from "@app/hooks/api/secretSyncs"; -import { RenderSyncScope, RenderSyncType } from "@app/hooks/api/secretSyncs/render-sync"; +import { RenderSyncScope, RenderSyncType } from "@app/hooks/api/secretSyncs/types/render-sync"; -export const RenderSyncDestinationSchema = BaseSecretSyncSchema().merge( +export const RenderSyncDestinationSchema = BaseSecretSyncSchema( + z.object({ + autoRedeployServices: z.boolean().optional() + }) +).merge( z.object({ destination: z.literal(SecretSync.Render), destinationConfig: z.discriminatedUnion("scope", [ diff --git a/frontend/src/components/v2/AccessRestrictedBanner/AccessRestrictedBanner.tsx b/frontend/src/components/v2/AccessRestrictedBanner/AccessRestrictedBanner.tsx new file mode 100644 index 000000000..1979039dd --- /dev/null +++ b/frontend/src/components/v2/AccessRestrictedBanner/AccessRestrictedBanner.tsx @@ -0,0 +1,26 @@ +import { ReactNode } from "react"; + +type Props = { + title?: string; + body?: ReactNode; +}; + +export const AccessRestrictedBanner = ({ + title = "Access Restricted", + + body = ( + <> + Your current role doesn't provide access to this feature. +
Contact your administrator to request access. + + ) +}: Props) => { + return ( +
+
+
{title}
+
{body}
+
+
+ ); +}; diff --git a/frontend/src/components/v2/AccessRestrictedBanner/index.ts b/frontend/src/components/v2/AccessRestrictedBanner/index.ts new file mode 100644 index 000000000..d60468572 --- /dev/null +++ b/frontend/src/components/v2/AccessRestrictedBanner/index.ts @@ -0,0 +1 @@ +export * from "./AccessRestrictedBanner"; diff --git a/frontend/src/components/v2/index.tsx b/frontend/src/components/v2/index.tsx index 1256cf005..8019c3589 100644 --- a/frontend/src/components/v2/index.tsx +++ b/frontend/src/components/v2/index.tsx @@ -1,4 +1,5 @@ /* eslint-disable react-refresh/only-export-components */ +export * from "./AccessRestrictedBanner"; export * from "./Accordion"; export * from "./Alert"; export * from "./Badge"; diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index 1345cb493..8c8475767 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -171,7 +171,8 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) case RenderConnectionMethod.ApiKey: case ChecklyConnectionMethod.ApiKey: return { name: "API Key", icon: faKey }; - + case AzureClientSecretsConnectionMethod.ClientSecret: + return { name: "Client Secret", icon: faKey }; default: throw new Error(`Unhandled App Connection Method: ${method}`); } diff --git a/frontend/src/helpers/secretSyncs.ts b/frontend/src/helpers/secretSyncs.ts index 0eb41e119..b4c0df352 100644 --- a/frontend/src/helpers/secretSyncs.ts +++ b/frontend/src/helpers/secretSyncs.ts @@ -4,9 +4,9 @@ import { SecretSyncImportBehavior, SecretSyncInitialSyncBehavior } from "@app/hooks/api/secretSyncs"; -import { RenderSyncScope } from "@app/hooks/api/secretSyncs/render-sync"; import { GcpSyncScope } from "@app/hooks/api/secretSyncs/types/gcp-sync"; import { HumanitecSyncScope } from "@app/hooks/api/secretSyncs/types/humanitec-sync"; +import { RenderSyncScope } from "@app/hooks/api/secretSyncs/types/render-sync"; export const SECRET_SYNC_MAP: Record = { [SecretSync.AWSParameterStore]: { name: "AWS Parameter Store", image: "Amazon Web Services.png" }, diff --git a/frontend/src/hoc/withPermission/withPermission.tsx b/frontend/src/hoc/withPermission/withPermission.tsx index ef814d958..529a74930 100644 --- a/frontend/src/hoc/withPermission/withPermission.tsx +++ b/frontend/src/hoc/withPermission/withPermission.tsx @@ -1,9 +1,8 @@ import { ComponentType } from "react"; import { Abilities, AbilityTuple, Generics, SubjectType } from "@casl/ability"; -import { faLock } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; +import { AccessRestrictedBanner } from "@app/components/v2"; import { TOrgPermission, useOrgPermission } from "@app/context"; type Props = (T extends AbilityTuple @@ -14,11 +13,11 @@ type Props = (T extends AbilityTuple : { action: string; subject: string; - }) & { className?: string; containerClassName?: string }; + }) & { containerClassName?: string }; export const withPermission = ( Component: ComponentType, - { action, subject, className, containerClassName }: Props["abilities"]> + { action, subject, containerClassName }: Props["abilities"]> ) => { const HOC = (hocProps: T) => { const { permission } = useOrgPermission(); @@ -33,22 +32,7 @@ export const withPermission = ( containerClassName )} > -
-
- -
-
-
Access Restricted
-
- Your role has limited permissions, please
contact your admin to gain access -
-
-
+ ); } diff --git a/frontend/src/hoc/withProjectPermission/withProjectPermission.tsx b/frontend/src/hoc/withProjectPermission/withProjectPermission.tsx index 564d44adc..7ba6efc57 100644 --- a/frontend/src/hoc/withProjectPermission/withProjectPermission.tsx +++ b/frontend/src/hoc/withProjectPermission/withProjectPermission.tsx @@ -1,14 +1,12 @@ import { ComponentType } from "react"; import { AbilityTuple } from "@casl/ability"; -import { faLock } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { twMerge } from "tailwind-merge"; +import { AccessRestrictedBanner } from "@app/components/v2"; import { useProjectPermission } from "@app/context"; import { ProjectPermissionSet } from "@app/context/ProjectPermissionContext"; type Props = { - className?: string; containerClassName?: string; action: T[0]; subject: T[1]; @@ -16,7 +14,7 @@ type Props = { export const withProjectPermission = ( Component: ComponentType, "action" | "subject"> & T>, - { action, subject, className, containerClassName }: Props + { action, subject, containerClassName }: Props ) => { const HOC = (hocProps: Omit, "action" | "subject"> & T) => { const { permission } = useProjectPermission(); @@ -31,23 +29,7 @@ export const withProjectPermission = ( containerClassName )} > -
-
- -
-
-
Permission Denied
-
- You do not have permission to this page.
Kindly contact your organization - administrator -
-
-
+ ); } diff --git a/frontend/src/hooks/api/accessApproval/mutation.tsx b/frontend/src/hooks/api/accessApproval/mutation.tsx index 3b05ff61b..ad7917a8f 100644 --- a/frontend/src/hooks/api/accessApproval/mutation.tsx +++ b/frontend/src/hooks/api/accessApproval/mutation.tsx @@ -17,7 +17,7 @@ export const useCreateAccessApprovalPolicy = () => { return useMutation({ mutationFn: async ({ - environment, + environments, projectSlug, approvals, approvers, @@ -29,7 +29,7 @@ export const useCreateAccessApprovalPolicy = () => { approvalsRequired }) => { const { data } = await apiRequest.post("/api/v1/access-approvals/policies", { - environment, + environments, projectSlug, approvals, bypassers, @@ -63,7 +63,8 @@ export const useUpdateAccessApprovalPolicy = () => { secretPath, enforcementLevel, allowedSelfApprovals, - approvalsRequired + approvalsRequired, + environments }) => { const { data } = await apiRequest.patch(`/api/v1/access-approvals/policies/${id}`, { approvals, @@ -73,7 +74,8 @@ export const useUpdateAccessApprovalPolicy = () => { name, enforcementLevel, allowedSelfApprovals, - approvalsRequired + approvalsRequired, + environments }); return data; }, diff --git a/frontend/src/hooks/api/accessApproval/types.ts b/frontend/src/hooks/api/accessApproval/types.ts index 70e9b883e..bc569165b 100644 --- a/frontend/src/hooks/api/accessApproval/types.ts +++ b/frontend/src/hooks/api/accessApproval/types.ts @@ -8,9 +8,8 @@ export type TAccessApprovalPolicy = { name: string; approvals: number; secretPath: string; - envId: string; workspace: string; - environment: WorkspaceEnv; + environments: WorkspaceEnv[]; projectId: string; policyType: PolicyType; approversRequired: boolean; @@ -166,7 +165,7 @@ export type TGetSecretApprovalPolicyOfBoardDTO = { export type TCreateAccessPolicyDTO = { projectSlug: string; name?: string; - environment: string; + environments: string[]; approvers?: Approver[]; bypassers?: Bypasser[]; approvals?: number; @@ -182,7 +181,7 @@ export type TUpdateAccessPolicyDTO = { approvers?: Approver[]; bypassers?: Bypasser[]; secretPath?: string; - environment?: string; + environments?: string[]; approvals?: number; enforcementLevel?: EnforcementLevel; allowedSelfApprovals: boolean; diff --git a/frontend/src/hooks/api/appConnections/types/azure-client-secrets-connection.ts b/frontend/src/hooks/api/appConnections/types/azure-client-secrets-connection.ts index 04ad167d2..220e3cdb2 100644 --- a/frontend/src/hooks/api/appConnections/types/azure-client-secrets-connection.ts +++ b/frontend/src/hooks/api/appConnections/types/azure-client-secrets-connection.ts @@ -2,15 +2,26 @@ import { AppConnection } from "@app/hooks/api/appConnections/enums"; import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; export enum AzureClientSecretsConnectionMethod { - OAuth = "oauth" + OAuth = "oauth", + ClientSecret = "client-secret" } export type TAzureClientSecretsConnection = TRootAppConnection & { app: AppConnection.AzureClientSecrets; -} & { - method: AzureClientSecretsConnectionMethod.OAuth; - credentials: { - code: string; - tenantId: string; - }; -}; +} & ( + | { + method: AzureClientSecretsConnectionMethod.OAuth; + credentials: { + code: string; + tenantId: string; + }; + } + | { + method: AzureClientSecretsConnectionMethod.ClientSecret; + credentials: { + clientSecret: string; + clientId: string; + tenantId: string; + }; + } + ); diff --git a/frontend/src/hooks/api/secretApproval/mutation.tsx b/frontend/src/hooks/api/secretApproval/mutation.tsx index e2d566e25..8370d0367 100644 --- a/frontend/src/hooks/api/secretApproval/mutation.tsx +++ b/frontend/src/hooks/api/secretApproval/mutation.tsx @@ -10,7 +10,7 @@ export const useCreateSecretApprovalPolicy = () => { return useMutation({ mutationFn: async ({ - environment, + environments, workspaceId, approvals, approvers, @@ -21,7 +21,7 @@ export const useCreateSecretApprovalPolicy = () => { allowedSelfApprovals }) => { const { data } = await apiRequest.post("/api/v1/secret-approvals", { - environment, + environments, workspaceId, approvals, approvers, @@ -53,7 +53,8 @@ export const useUpdateSecretApprovalPolicy = () => { secretPath, name, enforcementLevel, - allowedSelfApprovals + allowedSelfApprovals, + environments }) => { const { data } = await apiRequest.patch(`/api/v1/secret-approvals/${id}`, { approvals, @@ -62,7 +63,8 @@ export const useUpdateSecretApprovalPolicy = () => { secretPath, name, enforcementLevel, - allowedSelfApprovals + allowedSelfApprovals, + environments }); return data; }, diff --git a/frontend/src/hooks/api/secretApproval/types.ts b/frontend/src/hooks/api/secretApproval/types.ts index eeb734115..8fd86624d 100644 --- a/frontend/src/hooks/api/secretApproval/types.ts +++ b/frontend/src/hooks/api/secretApproval/types.ts @@ -5,8 +5,7 @@ export type TSecretApprovalPolicy = { id: string; workspace: string; name: string; - envId: string; - environment: WorkspaceEnv; + environments: WorkspaceEnv[]; secretPath?: string; approvals: number; approvers: Approver[]; @@ -48,7 +47,7 @@ export type TGetSecretApprovalPolicyOfBoardDTO = { export type TCreateSecretPolicyDTO = { workspaceId: string; name?: string; - environment: string; + environments: string[]; secretPath: string; approvers?: Approver[]; bypassers?: Bypasser[]; @@ -68,6 +67,7 @@ export type TUpdateSecretPolicyDTO = { enforcementLevel?: EnforcementLevel; // for invalidating list workspaceId: string; + environments?: string[]; }; export type TDeleteSecretPolicyDTO = { diff --git a/frontend/src/hooks/api/secretSyncs/types/index.ts b/frontend/src/hooks/api/secretSyncs/types/index.ts index 7af01765e..2ab76341b 100644 --- a/frontend/src/hooks/api/secretSyncs/types/index.ts +++ b/frontend/src/hooks/api/secretSyncs/types/index.ts @@ -1,7 +1,6 @@ import { SecretSync, SecretSyncImportBehavior } from "@app/hooks/api/secretSyncs"; import { DiscriminativePick } from "@app/types"; -import { TRenderSync } from "../render-sync"; import { TOnePassSync } from "./1password-sync"; import { TAwsParameterStoreSync } from "./aws-parameter-store-sync"; import { TAwsSecretsManagerSync } from "./aws-secrets-manager-sync"; @@ -24,6 +23,7 @@ import { THerokuSync } from "./heroku-sync"; import { THumanitecSync } from "./humanitec-sync"; import { TOCIVaultSync } from "./oci-vault-sync"; import { TRailwaySync } from "./railway-sync"; +import { TRenderSync } from "./render-sync"; import { TSupabaseSync } from "./supabase"; import { TTeamCitySync } from "./teamcity-sync"; import { TTerraformCloudSync } from "./terraform-cloud-sync"; diff --git a/frontend/src/hooks/api/secretSyncs/render-sync.ts b/frontend/src/hooks/api/secretSyncs/types/render-sync.ts similarity index 76% rename from frontend/src/hooks/api/secretSyncs/render-sync.ts rename to frontend/src/hooks/api/secretSyncs/types/render-sync.ts index ecac7d077..3d66de623 100644 --- a/frontend/src/hooks/api/secretSyncs/render-sync.ts +++ b/frontend/src/hooks/api/secretSyncs/types/render-sync.ts @@ -1,6 +1,6 @@ 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"; +import { RootSyncOptions, TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; export type TRenderSync = TRootSecretSync & { destination: SecretSync.Render; @@ -16,6 +16,10 @@ export type TRenderSync = TRootSecretSync & { name: string; id: string; }; + + syncOptions: RootSyncOptions & { + autoRedeployServices?: boolean; + }; }; export enum RenderSyncScope { diff --git a/frontend/src/hooks/usePathAccessPolicies.tsx b/frontend/src/hooks/usePathAccessPolicies.tsx index 463e9638d..1fbc5fd52 100644 --- a/frontend/src/hooks/usePathAccessPolicies.tsx +++ b/frontend/src/hooks/usePathAccessPolicies.tsx @@ -49,7 +49,8 @@ export const usePathAccessPolicies = ({ secretPath, environment }: Params) => { return useMemo(() => { const pathPolicies = policies?.filter( (policy) => - policy.environment.slug === environment && matchesPath(secretPath, policy.secretPath) + policy.environments?.some((env) => env.slug === environment) && + matchesPath(secretPath, policy.secretPath) ); return { diff --git a/frontend/src/pages/kms/KmipPage/KmipPage.tsx b/frontend/src/pages/kms/KmipPage/KmipPage.tsx index 6297c5eef..9c337bba3 100644 --- a/frontend/src/pages/kms/KmipPage/KmipPage.tsx +++ b/frontend/src/pages/kms/KmipPage/KmipPage.tsx @@ -22,7 +22,6 @@ export const KmipPage = () => { description="Integrate with Infisical KMS via Key Management Interoperability Protocol." /> { description="Manage keys and perform cryptographic operations." /> { case AppConnection.Camunda: return ; case AppConnection.AzureClientSecrets: - return ; + return ; case AppConnection.AzureDevOps: return ; case AppConnection.Windmill: @@ -222,7 +222,7 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { case AppConnection.Camunda: return ; case AppConnection.AzureClientSecrets: - return ; + return ; case AppConnection.AzureDevOps: return ; case AppConnection.Windmill: diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AzureClientSecretsConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AzureClientSecretsConnectionForm.tsx index 9076c95ce..f6c5788f4 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AzureClientSecretsConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AzureClientSecretsConnectionForm.tsx @@ -1,3 +1,4 @@ +/* eslint-disable no-case-declarations */ import crypto from "crypto"; import { useState } from "react"; @@ -20,19 +21,83 @@ import { GenericAppConnectionsFields } from "./GenericAppConnectionFields"; +type ClientSecretForm = z.infer; + type Props = { appConnection?: TAzureClientSecretsConnection; + onSubmit: (formData: ClientSecretForm) => Promise; }; -const formSchema = genericAppConnectionFieldsSchema.extend({ +const baseSchema = genericAppConnectionFieldsSchema.extend({ app: z.literal(AppConnection.AzureClientSecrets), - method: z.nativeEnum(AzureClientSecretsConnectionMethod), - tenantId: z.string().trim().min(1, "Tenant ID is required") + method: z.nativeEnum(AzureClientSecretsConnectionMethod) }); +const oauthSchema = baseSchema.extend({ + tenantId: z.string().trim().min(1, "Tenant ID is required"), + method: z.literal(AzureClientSecretsConnectionMethod.OAuth) +}); + +const clientSecretSchema = baseSchema.extend({ + method: z.literal(AzureClientSecretsConnectionMethod.ClientSecret), + credentials: z.object({ + clientSecret: z.string().trim().min(1, "Client Secret is required"), + clientId: z.string().trim().min(1, "Client ID is required"), + tenantId: z.string().trim().min(1, "Tenant ID is required") + }) +}); + +const formSchema = z.discriminatedUnion("method", [oauthSchema, clientSecretSchema]); + type FormData = z.infer; -export const AzureClientSecretsConnectionForm = ({ appConnection }: Props) => { +const getDefaultValues = (appConnection?: TAzureClientSecretsConnection): Partial => { + if (!appConnection) { + return { + app: AppConnection.AzureClientSecrets, + method: AzureClientSecretsConnectionMethod.OAuth + }; + } + + const base = { + name: appConnection.name, + description: appConnection.description, + app: appConnection.app, + method: appConnection.method + }; + const { credentials } = appConnection; + + switch (appConnection.method) { + case AzureClientSecretsConnectionMethod.OAuth: + if ("tenantId" in credentials) { + return { + ...base, + method: AzureClientSecretsConnectionMethod.OAuth, + tenantId: credentials.tenantId + }; + } + break; + case AzureClientSecretsConnectionMethod.ClientSecret: + if ("clientSecret" in credentials && "clientId" in credentials) { + return { + ...base, + method: AzureClientSecretsConnectionMethod.ClientSecret, + credentials: { + clientSecret: credentials.clientSecret, + clientId: credentials.clientId, + tenantId: credentials.tenantId + } + }; + } + break; + default: + return base; + } + + return base; +}; + +export const AzureClientSecretsConnectionForm = ({ appConnection, onSubmit }: Props) => { const isUpdate = Boolean(appConnection); const [isRedirecting, setIsRedirecting] = useState(false); @@ -43,70 +108,51 @@ export const AzureClientSecretsConnectionForm = ({ appConnection }: Props) => { const form = useForm({ resolver: zodResolver(formSchema), - defaultValues: appConnection - ? { - ...appConnection, - tenantId: appConnection.credentials.tenantId - } - : { - app: AppConnection.AzureClientSecrets, - method: AzureClientSecretsConnectionMethod.OAuth - } + defaultValues: getDefaultValues(appConnection) }); const { handleSubmit, control, watch, + setValue, formState: { isSubmitting, isDirty } } = form; const selectedMethod = watch("method"); - const onSubmit = (formData: FormData) => { - setIsRedirecting(true); + const onSubmitHandler = (formData: FormData) => { const state = crypto.randomBytes(16).toString("hex"); - localStorage.setItem("latestCSRFToken", state); - localStorage.setItem( - "azureClientSecretsConnectionFormData", - JSON.stringify({ ...formData, connectionId: appConnection?.id }) - ); - switch (formData.method) { case AzureClientSecretsConnectionMethod.OAuth: - window.location.assign( - `https://login.microsoftonline.com/${formData.tenantId || "common"}/oauth2/v2.0/authorize?client_id=${oauthClientId}&response_type=code&redirect_uri=${window.location.origin}/organization/app-connections/azure/oauth/callback&response_mode=query&scope=https://azconfig.io/.default%20openid%20offline_access&state=${state}<:>azure-client-secrets` + setIsRedirecting(true); + localStorage.setItem("latestCSRFToken", state); + localStorage.setItem( + "azureClientSecretsConnectionFormData", + JSON.stringify({ ...formData, connectionId: appConnection?.id }) ); + window.location.assign( + `https://login.microsoftonline.com/${formData.tenantId || "common"}/oauth2/v2.0/authorize?client_id=${oauthClientId}&response_type=code&redirect_uri=${window.location.origin}/organization/app-connections/azure/oauth/callback&response_mode=query&scope=https://graph.microsoft.com/.default%20openid%20offline_access&state=${state}<:>azure-client-secrets` + ); + break; + + case AzureClientSecretsConnectionMethod.ClientSecret: + onSubmit(formData); break; default: throw new Error(`Unhandled Azure Connection method: ${(formData as FormData).method}`); } }; - const isMissingConfig = !oauthClientId; - + const isMissingConfig = + selectedMethod === AzureClientSecretsConnectionMethod.OAuth && !oauthClientId; const methodDetails = getAppConnectionMethodDetails(selectedMethod); return ( -
+ {!isUpdate && } - ( - - - - )} - /> - { )} /> + + ( + + { + field.onChange(e.target.value); + setValue("credentials.tenantId", e.target.value); + }} + /> + + )} + /> + + {/* Access Token-specific fields */} + {selectedMethod === AzureClientSecretsConnectionMethod.ClientSecret && ( + <> + ( + + + + )} + /> + ( + + + + )} + /> + + )} +
+ {!canReadSecret && + !canReadDynamicSecret && + !canReadSecretImports && + folders?.length === 0 && } {!isDetailsLoading && (totalCount > 0 || pendingChanges.secrets.length > 0 || diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/RenderSyncDestinationSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/RenderSyncDestinationSection.tsx index b661caf00..eda37a9cf 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/RenderSyncDestinationSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/RenderSyncDestinationSection.tsx @@ -1,6 +1,6 @@ import { GenericFieldLabel } from "@app/components/secret-syncs"; import { useRenderConnectionListServices } from "@app/hooks/api/appConnections/render"; -import { TRenderSync } from "@app/hooks/api/secretSyncs/render-sync"; +import { TRenderSync } from "@app/hooks/api/secretSyncs/types/render-sync"; type Props = { secretSync: TRenderSync; diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/RenderSyncOptionsSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/RenderSyncOptionsSection.tsx new file mode 100644 index 000000000..b23b3298b --- /dev/null +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/RenderSyncOptionsSection.tsx @@ -0,0 +1,21 @@ +import { GenericFieldLabel } from "@app/components/secret-syncs"; +import { Badge } from "@app/components/v2"; +import { TRenderSync } from "@app/hooks/api/secretSyncs/types/render-sync"; + +type Props = { + secretSync: TRenderSync; +}; + +export const RenderSyncOptionsSection = ({ secretSync }: Props) => { + const { + syncOptions: { autoRedeployServices } + } = secretSync; + + return ( + + + {autoRedeployServices ? "Enabled" : "Disabled"} + + + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx index 843e02f63..32c46fca2 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx @@ -13,6 +13,7 @@ import { SecretSync, TSecretSync } from "@app/hooks/api/secretSyncs"; import { AwsParameterStoreSyncOptionsSection } from "./AwsParameterStoreSyncOptionsSection"; import { AwsSecretsManagerSyncOptionsSection } from "./AwsSecretsManagerSyncOptionsSection"; +import { RenderSyncOptionsSection } from "./RenderSyncOptionsSection"; type Props = { secretSync: TSecretSync; @@ -40,6 +41,9 @@ export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) = ); break; + case SecretSync.Render: + AdditionalSyncOptionsComponent = ; + break; case SecretSync.GitHub: case SecretSync.GCPSecretManager: case SecretSync.AzureKeyVault: @@ -56,7 +60,6 @@ export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) = case SecretSync.OCIVault: case SecretSync.OnePass: case SecretSync.Heroku: - case SecretSync.Render: case SecretSync.Flyio: case SecretSync.GitLab: case SecretSync.CloudflarePages: diff --git a/frontend/src/pages/secret-scanning/SettingsPage/components/ProjectScanningConfigTab/ProjectScanningConfigTab.tsx b/frontend/src/pages/secret-scanning/SettingsPage/components/ProjectScanningConfigTab/ProjectScanningConfigTab.tsx index 73fd26c8b..e8d3206d8 100644 --- a/frontend/src/pages/secret-scanning/SettingsPage/components/ProjectScanningConfigTab/ProjectScanningConfigTab.tsx +++ b/frontend/src/pages/secret-scanning/SettingsPage/components/ProjectScanningConfigTab/ProjectScanningConfigTab.tsx @@ -1,6 +1,6 @@ import { faBan } from "@fortawesome/free-solid-svg-icons"; -import { ContentLoader, EmptyState } from "@app/components/v2"; +import { AccessRestrictedBanner, ContentLoader, EmptyState } from "@app/components/v2"; import { useSubscription, useWorkspace } from "@app/context"; import { useGetSecretScanningConfig } from "@app/hooks/api/secretScanningV2"; @@ -16,16 +16,14 @@ export const ProjectScanningConfigTab = () => { if (!subscription.secretScanning) { return ( -
- +
+ Your current plan doesn't support Secret Scanning.
Please contact Infisical Support or reach out through our Slack channel for assistance. - + } />
diff --git a/sink/oidc-server/package-lock.json b/sink/oidc-server/package-lock.json index 2be29633b..f1732704d 100644 --- a/sink/oidc-server/package-lock.json +++ b/sink/oidc-server/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "axios": "^1.8.3", + "axios": "^1.11.0", "dotenv": "^16.4.7", "express": "^4.21.2", "form-data": "^4.0.2", @@ -105,13 +105,13 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.3.tgz", - "integrity": "sha512-iP4DebzoNlP/YN2dpwCgb8zoCmhtkajzS48JvwmkSkXvPI3DHc7m+XYL5tGnSlJtR6nImXZmdCuN5aP8dh1d8A==", + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.11.0.tgz", + "integrity": "sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", + "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, @@ -571,14 +571,15 @@ } }, "node_modules/form-data": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", - "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" }, "engines": { diff --git a/sink/oidc-server/package.json b/sink/oidc-server/package.json index 514629d46..3dfa41224 100644 --- a/sink/oidc-server/package.json +++ b/sink/oidc-server/package.json @@ -11,7 +11,7 @@ "license": "ISC", "description": "", "dependencies": { - "axios": "^1.8.3", + "axios": "^1.11.0", "dotenv": "^16.4.7", "express": "^4.21.2", "form-data": "^4.0.2",