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/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 2be0361e0..8e366fefc 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 @@ -537,6 +537,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/server/routes/index.ts b/backend/src/server/routes/index.ts index ea4cf9676..b2b0c9924 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({ @@ -1317,6 +1324,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 beef663b9..aba8663a3 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/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/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/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/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/project/AccessControlPage/components/MembersTab/components/MemberRoleForm/SpecificPrivilegeSection.tsx b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MemberRoleForm/SpecificPrivilegeSection.tsx index 51a83c6e0..0256d3219 100644 --- a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MemberRoleForm/SpecificPrivilegeSection.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MemberRoleForm/SpecificPrivilegeSection.tsx @@ -155,8 +155,8 @@ export const SpecificPrivilegeSecretForm = ({ const selectablePaths = useMemo(() => { if (!policies) return []; - const environmentPolicies = policies.filter( - (policy) => policy.environment.slug === selectedEnvironment + const environmentPolicies = policies.filter((policy) => + policy.environments.find((env) => env.slug === selectedEnvironment) ); privilegeForm.setValue("secretPath", "", { diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx index e84d228cd..3d292f9a2 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx @@ -166,17 +166,20 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => { const filteredPolicies = useMemo( () => policies - .filter(({ policyType, environment, name, secretPath }) => { + .filter(({ policyType, environments, name, secretPath }) => { if (filters.type && policyType !== filters.type) return false; - if (filters.environmentIds.length && !filters.environmentIds.includes(environment.id)) + if ( + filters.environmentIds.length && + !environments.some((env) => filters.environmentIds.includes(env.id)) + ) return false; const searchValue = search.trim().toLowerCase(); return ( name.toLowerCase().includes(searchValue) || - environment.name.toLowerCase().includes(searchValue) || + environments.some((env) => env.name.toLowerCase().includes(searchValue)) || (secretPath ?? "*").toLowerCase().includes(searchValue) ); }) @@ -189,9 +192,18 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => { .toLowerCase() .localeCompare(policyTwo.policyType.toLowerCase()); case PolicyOrderBy.Environment: - return policyOne.environment.name - .toLowerCase() - .localeCompare(policyTwo.environment.name.toLowerCase()); + // eslint-disable-next-line no-case-declarations + const getFirstEnvName = (policy: { environments: { name: string }[] }) => { + if (!policy.environments?.length) return ""; + return ( + policy.environments + .map((env) => env.name?.toLowerCase() || "") + .filter((name) => name) + .sort()[0] || "" + ); + }; + + return getFirstEnvName(policyOne).localeCompare(getFirstEnvName(policyTwo)); case PolicyOrderBy.SecretPath: return (policyOne.secretPath ?? "*") .toLowerCase() diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx index 183d8ab1c..b254b8ac7 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx @@ -54,7 +54,7 @@ type Props = { const formSchema = z .object({ - environment: z.object({ slug: z.string(), name: z.string() }), + environments: z.array(z.object({ slug: z.string(), name: z.string() })).min(1), name: z.string().optional(), secretPath: z.string().trim().min(1), approvals: z.number().min(1).default(1), @@ -134,7 +134,7 @@ const Form = ({ values: editValues ? ({ ...editValues, - environment: editValues.environment, + environments: editValues.environments, userApprovers: editValues?.approvers ?.filter((approver) => approver.type === ApproverType.User) @@ -191,7 +191,7 @@ const Form = ({ const { currentWorkspace } = useWorkspace(); const { data: groups } = useListWorkspaceGroups(projectId); - const environments = currentWorkspace?.environments || []; + const availableEnvironments = currentWorkspace?.environments || []; const isAccessPolicyType = watch("policyType") === PolicyType.AccessPolicy; const { mutateAsync: createAccessApprovalPolicy } = useCreateAccessApprovalPolicy(); @@ -204,11 +204,11 @@ const Form = ({ const formUserBypassers = watch("userBypassers"); const formGroupBypassers = watch("groupBypassers"); - const formEnvironment = watch("environment")?.slug; + const formEnvironments = watch("environments"); const bypasserCount = (formUserBypassers || []).length + (formGroupBypassers || []).length; const handleCreatePolicy = async ({ - environment, + environments, groupApprovers, userApprovers, groupBypassers, @@ -226,7 +226,7 @@ const Form = ({ ...data, approvers: [...userApprovers, ...groupApprovers], bypassers: bypassers.length > 0 ? bypassers : undefined, - environment: environment.slug, + environments: environments.map((env) => env.slug), workspaceId: currentWorkspace?.id || "" }); } else { @@ -242,7 +242,7 @@ const Form = ({ numberOfApprovals: el.approvals })), bypassers: bypassers.length > 0 ? bypassers : undefined, - environment: environment.slug, + environments: environments.map((env) => env.slug), projectSlug }); } @@ -261,7 +261,7 @@ const Form = ({ }; const handleUpdatePolicy = async ({ - environment, + environments, userApprovers, groupApprovers, userBypassers, @@ -281,7 +281,8 @@ const Form = ({ ...data, approvers: [...userApprovers, ...groupApprovers], bypassers: bypassers.length > 0 ? bypassers : undefined, - workspaceId: currentWorkspace?.id || "" + workspaceId: currentWorkspace?.id || "", + environments: environments.map((env) => env.slug) }); } else { await updateAccessApprovalPolicy({ @@ -297,7 +298,7 @@ const Form = ({ numberOfApprovals: el.approvals })), bypassers: bypassers.length > 0 ? bypassers : undefined, - environment: environment.slug, + environments: environments.map((env) => env.slug), projectSlug }); } @@ -479,28 +480,28 @@ const Form = ({ )} /> ( option.slug} getOptionLabel={(option) => option.name} /> diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/ApprovalPolicyRow.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/ApprovalPolicyRow.tsx index 408d718fa..b19ace7ea 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/ApprovalPolicyRow.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/ApprovalPolicyRow.tsx @@ -37,7 +37,7 @@ import { TWorkspaceUser } from "@app/hooks/api/users/types"; interface IPolicy { id: string; name: string; - environment: WorkspaceEnv; + environments: WorkspaceEnv[]; projectId?: string; secretPath?: string; approvals: number; @@ -112,7 +112,7 @@ export const ApprovalPolicyRow = ({ onClick={() => setIsExpanded.toggle()} > {policy.name || Unnamed Policy} - {policy.environment.name} + {policy.environments.map((env) => env.name).join(", ")} {policy.secretPath || "*"}