diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 22b13f923..22999d916 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -6,6 +6,9 @@ import { TAccessApprovalPoliciesApprovers, TAccessApprovalPoliciesApproversInsert, TAccessApprovalPoliciesApproversUpdate, + TAccessApprovalPoliciesBypassers, + TAccessApprovalPoliciesBypassersInsert, + TAccessApprovalPoliciesBypassersUpdate, TAccessApprovalPoliciesInsert, TAccessApprovalPoliciesUpdate, TAccessApprovalRequests, @@ -276,6 +279,9 @@ import { TSecretApprovalPoliciesApprovers, TSecretApprovalPoliciesApproversInsert, TSecretApprovalPoliciesApproversUpdate, + TSecretApprovalPoliciesBypassers, + TSecretApprovalPoliciesBypassersInsert, + TSecretApprovalPoliciesBypassersUpdate, TSecretApprovalPoliciesInsert, TSecretApprovalPoliciesUpdate, TSecretApprovalRequests, @@ -820,6 +826,12 @@ declare module "knex/types/tables" { TAccessApprovalPoliciesApproversUpdate >; + [TableName.AccessApprovalPolicyBypasser]: KnexOriginal.CompositeTableType< + TAccessApprovalPoliciesBypassers, + TAccessApprovalPoliciesBypassersInsert, + TAccessApprovalPoliciesBypassersUpdate + >; + [TableName.AccessApprovalRequest]: KnexOriginal.CompositeTableType< TAccessApprovalRequests, TAccessApprovalRequestsInsert, @@ -843,6 +855,11 @@ declare module "knex/types/tables" { TSecretApprovalPoliciesApproversInsert, TSecretApprovalPoliciesApproversUpdate >; + [TableName.SecretApprovalPolicyBypasser]: KnexOriginal.CompositeTableType< + TSecretApprovalPoliciesBypassers, + TSecretApprovalPoliciesBypassersInsert, + TSecretApprovalPoliciesBypassersUpdate + >; [TableName.SecretApprovalRequest]: KnexOriginal.CompositeTableType< TSecretApprovalRequests, TSecretApprovalRequestsInsert, diff --git a/backend/src/db/migrations/20250527030702_policy-bypassers.ts b/backend/src/db/migrations/20250527030702_policy-bypassers.ts new file mode 100644 index 000000000..98b1f4be1 --- /dev/null +++ b/backend/src/db/migrations/20250527030702_policy-bypassers.ts @@ -0,0 +1,48 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.AccessApprovalPolicyBypasser))) { + await knex.schema.createTable(TableName.AccessApprovalPolicyBypasser, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + t.uuid("bypasserGroupId").nullable(); + t.foreign("bypasserGroupId").references("id").inTable(TableName.Groups).onDelete("CASCADE"); + + t.uuid("bypasserUserId").nullable(); + t.foreign("bypasserUserId").references("id").inTable(TableName.Users).onDelete("CASCADE"); + + t.uuid("policyId").notNullable(); + t.foreign("policyId").references("id").inTable(TableName.AccessApprovalPolicy).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + await createOnUpdateTrigger(knex, TableName.AccessApprovalPolicyBypasser); + } + + if (!(await knex.schema.hasTable(TableName.SecretApprovalPolicyBypasser))) { + await knex.schema.createTable(TableName.SecretApprovalPolicyBypasser, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + t.uuid("bypasserGroupId").nullable(); + t.foreign("bypasserGroupId").references("id").inTable(TableName.Groups).onDelete("CASCADE"); + + t.uuid("bypasserUserId").nullable(); + t.foreign("bypasserUserId").references("id").inTable(TableName.Users).onDelete("CASCADE"); + + t.uuid("policyId").notNullable(); + t.foreign("policyId").references("id").inTable(TableName.SecretApprovalPolicy).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + await createOnUpdateTrigger(knex, TableName.SecretApprovalPolicyBypasser); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.SecretApprovalPolicyBypasser); + await knex.schema.dropTableIfExists(TableName.AccessApprovalPolicyBypasser); + + await dropOnUpdateTrigger(knex, TableName.SecretApprovalPolicyBypasser); + await dropOnUpdateTrigger(knex, TableName.AccessApprovalPolicyBypasser); +} diff --git a/backend/src/db/schemas/access-approval-policies-bypassers.ts b/backend/src/db/schemas/access-approval-policies-bypassers.ts new file mode 100644 index 000000000..278e4b416 --- /dev/null +++ b/backend/src/db/schemas/access-approval-policies-bypassers.ts @@ -0,0 +1,26 @@ +// 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 AccessApprovalPoliciesBypassersSchema = z.object({ + id: z.string().uuid(), + bypasserGroupId: z.string().uuid().nullable().optional(), + bypasserUserId: z.string().uuid().nullable().optional(), + policyId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TAccessApprovalPoliciesBypassers = z.infer; +export type TAccessApprovalPoliciesBypassersInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TAccessApprovalPoliciesBypassersUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 59eca6af8..8e098c2ec 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -1,5 +1,6 @@ export * from "./access-approval-policies"; export * from "./access-approval-policies-approvers"; +export * from "./access-approval-policies-bypassers"; export * from "./access-approval-requests"; export * from "./access-approval-requests-reviewers"; export * from "./api-keys"; @@ -92,6 +93,7 @@ export * from "./saml-configs"; export * from "./scim-tokens"; export * from "./secret-approval-policies"; export * from "./secret-approval-policies-approvers"; +export * from "./secret-approval-policies-bypassers"; export * from "./secret-approval-request-secret-tags"; export * from "./secret-approval-request-secret-tags-v2"; export * from "./secret-approval-requests"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 0485a04ff..6887975fa 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -95,10 +95,12 @@ export enum TableName { ScimToken = "scim_tokens", AccessApprovalPolicy = "access_approval_policies", AccessApprovalPolicyApprover = "access_approval_policies_approvers", + AccessApprovalPolicyBypasser = "access_approval_policies_bypassers", AccessApprovalRequest = "access_approval_requests", AccessApprovalRequestReviewer = "access_approval_requests_reviewers", SecretApprovalPolicy = "secret_approval_policies", SecretApprovalPolicyApprover = "secret_approval_policies_approvers", + SecretApprovalPolicyBypasser = "secret_approval_policies_bypassers", SecretApprovalRequest = "secret_approval_requests", SecretApprovalRequestReviewer = "secret_approval_requests_reviewers", SecretApprovalRequestSecret = "secret_approval_requests_secrets", diff --git a/backend/src/db/schemas/secret-approval-policies-bypassers.ts b/backend/src/db/schemas/secret-approval-policies-bypassers.ts new file mode 100644 index 000000000..86eea45d3 --- /dev/null +++ b/backend/src/db/schemas/secret-approval-policies-bypassers.ts @@ -0,0 +1,26 @@ +// 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 SecretApprovalPoliciesBypassersSchema = z.object({ + id: z.string().uuid(), + bypasserGroupId: z.string().uuid().nullable().optional(), + bypasserUserId: z.string().uuid().nullable().optional(), + policyId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TSecretApprovalPoliciesBypassers = z.infer; +export type TSecretApprovalPoliciesBypassersInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TSecretApprovalPoliciesBypassersUpdate = 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 97a819234..7cab45c6f 100644 --- a/backend/src/ee/routes/v1/access-approval-policy-router.ts +++ b/backend/src/ee/routes/v1/access-approval-policy-router.ts @@ -1,7 +1,7 @@ import { nanoid } from "nanoid"; import { z } from "zod"; -import { ApproverType } from "@app/ee/services/access-approval-policy/access-approval-policy-types"; +import { ApproverType, BypasserType } from "@app/ee/services/access-approval-policy/access-approval-policy-types"; import { EnforcementLevel } from "@app/lib/types"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; @@ -28,6 +28,13 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi ]) .array() .min(1, { message: "At least one approver should be provided" }), + 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(), name: z.string().optional() }) + ]) + .array() + .optional(), approvals: z.number().min(1).default(1), enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard), allowedSelfApprovals: z.boolean().default(true) @@ -72,7 +79,8 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi .object({ type: z.nativeEnum(ApproverType), id: z.string().nullable().optional() }) .array() .nullable() - .optional() + .optional(), + bypassers: z.object({ type: z.nativeEnum(BypasserType), id: z.string().nullable().optional() }).array() }) .array() .nullable() @@ -147,6 +155,13 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi ]) .array() .min(1, { message: "At least one approver should be provided" }), + 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(), name: z.string().optional() }) + ]) + .array() + .optional(), approvals: z.number().min(1).optional(), enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard), allowedSelfApprovals: z.boolean().default(true) @@ -220,6 +235,15 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi }) .array() .nullable() + .optional(), + bypassers: z + .object({ + type: z.nativeEnum(BypasserType), + id: z.string().nullable().optional(), + name: z.string().nullable().optional() + }) + .array() + .nullable() .optional() }) }) diff --git a/backend/src/ee/routes/v1/access-approval-request-router.ts b/backend/src/ee/routes/v1/access-approval-request-router.ts index d90f28184..5b3a08b4b 100644 --- a/backend/src/ee/routes/v1/access-approval-request-router.ts +++ b/backend/src/ee/routes/v1/access-approval-request-router.ts @@ -113,6 +113,7 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv name: z.string(), approvals: z.number(), approvers: z.string().array(), + bypassers: z.string().array(), secretPath: z.string().nullish(), envId: z.string(), enforcementLevel: z.string(), 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 846b60923..5bc45636b 100644 --- a/backend/src/ee/routes/v1/secret-approval-policy-router.ts +++ b/backend/src/ee/routes/v1/secret-approval-policy-router.ts @@ -1,7 +1,7 @@ import { nanoid } from "nanoid"; import { z } from "zod"; -import { ApproverType } from "@app/ee/services/access-approval-policy/access-approval-policy-types"; +import { ApproverType, BypasserType } from "@app/ee/services/access-approval-policy/access-approval-policy-types"; import { removeTrailingSlash } from "@app/lib/fn"; import { EnforcementLevel } from "@app/lib/types"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; @@ -34,6 +34,13 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi ]) .array() .min(1, { message: "At least one approver should be provided" }), + 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(), name: z.string().optional() }) + ]) + .array() + .optional(), approvals: z.number().min(1).default(1), enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard), allowedSelfApprovals: z.boolean().default(true) @@ -79,6 +86,13 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi ]) .array() .min(1, { message: "At least one approver should be provided" }), + 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(), name: z.string().optional() }) + ]) + .array() + .optional(), approvals: z.number().min(1).default(1), secretPath: z .string() @@ -157,6 +171,12 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi id: z.string().nullable().optional(), type: z.nativeEnum(ApproverType) }) + .array(), + bypassers: z + .object({ + id: z.string().nullable().optional(), + type: z.nativeEnum(BypasserType) + }) .array() }) .array() @@ -195,6 +215,13 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi type: z.nativeEnum(ApproverType), name: z.string().nullable().optional() }) + .array(), + bypassers: z + .object({ + id: z.string().nullable().optional(), + type: z.nativeEnum(BypasserType), + name: z.string().nullable().optional() + }) .array() }) }) diff --git a/backend/src/ee/routes/v1/secret-approval-request-router.ts b/backend/src/ee/routes/v1/secret-approval-request-router.ts index 7d2cdcc0c..eed5cd34a 100644 --- a/backend/src/ee/routes/v1/secret-approval-request-router.ts +++ b/backend/src/ee/routes/v1/secret-approval-request-router.ts @@ -47,6 +47,11 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv userId: z.string().nullable().optional() }) .array(), + bypassers: z + .object({ + userId: z.string().nullable().optional() + }) + .array(), secretPath: z.string().optional().nullable(), enforcementLevel: z.string(), deletedAt: z.date().nullish(), @@ -266,6 +271,7 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv name: z.string(), approvals: z.number(), approvers: approvalRequestUser.array(), + bypassers: approvalRequestUser.array(), secretPath: z.string().optional().nullable(), enforcementLevel: z.string(), deletedAt: z.date().nullish(), diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-approver-dal.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-approver-dal.ts index e14854d8f..c141c762b 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-approver-dal.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-approver-dal.ts @@ -8,3 +8,10 @@ export const accessApprovalPolicyApproverDALFactory = (db: TDbClient) => { const accessApprovalPolicyApproverOrm = ormify(db, TableName.AccessApprovalPolicyApprover); return { ...accessApprovalPolicyApproverOrm }; }; + +export type TAccessApprovalPolicyBypasserDALFactory = ReturnType; + +export const accessApprovalPolicyBypasserDALFactory = (db: TDbClient) => { + const accessApprovalPolicyBypasserOrm = ormify(db, TableName.AccessApprovalPolicyBypasser); + return { ...accessApprovalPolicyBypasserOrm }; +}; 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 e14451498..cb6fd1ce8 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 @@ -1,11 +1,11 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { AccessApprovalPoliciesSchema, TableName, TAccessApprovalPolicies } from "@app/db/schemas"; +import { AccessApprovalPoliciesSchema, TableName, TAccessApprovalPolicies, TUsers } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { buildFindFilter, ormify, selectAllTableCols, sqlNestRelationships, TFindFilter } from "@app/lib/knex"; -import { ApproverType } from "./access-approval-policy-types"; +import { ApproverType, BypasserType } from "./access-approval-policy-types"; export type TAccessApprovalPolicyDALFactory = ReturnType; @@ -34,9 +34,22 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => { `${TableName.AccessApprovalPolicyApprover}.policyId` ) .leftJoin(TableName.Users, `${TableName.AccessApprovalPolicyApprover}.approverUserId`, `${TableName.Users}.id`) + .leftJoin( + TableName.AccessApprovalPolicyBypasser, + `${TableName.AccessApprovalPolicy}.id`, + `${TableName.AccessApprovalPolicyBypasser}.policyId` + ) + .leftJoin( + db(TableName.Users).as("bypasserUsers"), + `${TableName.AccessApprovalPolicyBypasser}.bypasserUserId`, + `bypasserUsers.id` + ) .select(tx.ref("username").withSchema(TableName.Users).as("approverUsername")) + .select(tx.ref("username").withSchema("bypasserUsers").as("bypasserUsername")) .select(tx.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover)) .select(tx.ref("approverGroupId").withSchema(TableName.AccessApprovalPolicyApprover)) + .select(tx.ref("bypasserUserId").withSchema(TableName.AccessApprovalPolicyBypasser)) + .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")) @@ -129,6 +142,23 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => { id, type: ApproverType.Group }) + }, + { + key: "bypasserUserId", + label: "bypassers" as const, + mapper: ({ bypasserUserId: id, bypasserUsername }) => ({ + id, + type: BypasserType.User, + name: bypasserUsername + }) + }, + { + key: "bypasserGroupId", + label: "bypassers" as const, + mapper: ({ bypasserGroupId: id }) => ({ + id, + type: BypasserType.Group + }) } ] }); 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 17176162b..cc4c2f785 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 @@ -2,7 +2,7 @@ import { ForbiddenError } from "@casl/ability"; import { ActionProjectType } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { ProjectPermissionApprovalActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal"; @@ -14,10 +14,14 @@ import { TAccessApprovalRequestReviewerDALFactory } from "../access-approval-req import { ApprovalStatus } from "../access-approval-request/access-approval-request-types"; import { TGroupDALFactory } from "../group/group-dal"; import { TProjectUserAdditionalPrivilegeDALFactory } from "../project-user-additional-privilege/project-user-additional-privilege-dal"; -import { TAccessApprovalPolicyApproverDALFactory } from "./access-approval-policy-approver-dal"; +import { + TAccessApprovalPolicyApproverDALFactory, + TAccessApprovalPolicyBypasserDALFactory +} from "./access-approval-policy-approver-dal"; import { TAccessApprovalPolicyDALFactory } from "./access-approval-policy-dal"; import { ApproverType, + BypasserType, TCreateAccessApprovalPolicy, TDeleteAccessApprovalPolicy, TGetAccessApprovalPolicyByIdDTO, @@ -32,6 +36,7 @@ type TAccessApprovalPolicyServiceFactoryDep = { accessApprovalPolicyDAL: TAccessApprovalPolicyDALFactory; projectEnvDAL: Pick; accessApprovalPolicyApproverDAL: TAccessApprovalPolicyApproverDALFactory; + accessApprovalPolicyBypasserDAL: TAccessApprovalPolicyBypasserDALFactory; projectMembershipDAL: Pick; groupDAL: TGroupDALFactory; userDAL: Pick; @@ -45,6 +50,7 @@ export type TAccessApprovalPolicyServiceFactory = ReturnType user.id); verifyAllApprovers.push(...verifyGroupApprovers); + let groupBypassers: string[] = []; + let bypasserUserIds: string[] = []; + + if (bypassers && bypassers.length) { + groupBypassers = bypassers + .filter((bypasser) => bypasser.type === BypasserType.Group) + .map((bypasser) => bypasser.id) as string[]; + + const userBypassers = bypassers + .filter((bypasser) => bypasser.type === BypasserType.User) + .map((bypasser) => bypasser.id) + .filter(Boolean) as string[]; + + const userBypasserNames = bypassers + .map((bypasser) => (bypasser.type === BypasserType.User ? bypasser.name : undefined)) + .filter(Boolean) as string[]; + + bypasserUserIds = userBypassers; + if (userBypasserNames.length) { + const bypasserUsers = await userDAL.find({ + $in: { + username: userBypasserNames + } + }); + + const bypasserNamesFromDb = bypasserUsers.map((user) => user.username); + const invalidUsernames = userBypasserNames.filter((username) => !bypasserNamesFromDb.includes(username)); + + if (invalidUsernames.length) { + throw new BadRequestError({ + message: `Invalid bypasser user: ${invalidUsernames.join(", ")}` + }); + } + + bypasserUserIds = bypasserUserIds.concat(bypasserUsers.map((user) => user.id)); + } + } + const accessApproval = await accessApprovalPolicyDAL.transaction(async (tx) => { const doc = await accessApprovalPolicyDAL.create( { @@ -159,6 +204,7 @@ export const accessApprovalPolicyServiceFactory = ({ }, tx ); + if (approverUserIds.length) { await accessApprovalPolicyApproverDAL.insertMany( approverUserIds.map((userId) => ({ @@ -179,8 +225,29 @@ export const accessApprovalPolicyServiceFactory = ({ ); } + if (bypasserUserIds.length) { + await accessApprovalPolicyBypasserDAL.insertMany( + bypasserUserIds.map((userId) => ({ + bypasserUserId: userId, + policyId: doc.id + })), + tx + ); + } + + if (groupBypassers.length) { + await accessApprovalPolicyBypasserDAL.insertMany( + groupBypassers.map((groupId) => ({ + bypasserGroupId: groupId, + policyId: doc.id + })), + tx + ); + } + return doc; }); + return { ...accessApproval, environment: env, projectId: project.id }; }; @@ -211,6 +278,7 @@ export const accessApprovalPolicyServiceFactory = ({ const updateAccessApprovalPolicy = async ({ policyId, approvers, + bypassers, secretPath, name, actorId, @@ -256,10 +324,45 @@ export const accessApprovalPolicyServiceFactory = ({ actionProjectType: ActionProjectType.SecretManager }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionApprovalActions.Edit, - ProjectPermissionSub.SecretApproval - ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval); + + let groupBypassers: string[] = []; + let bypasserUserIds: string[] = []; + + if (bypassers && bypassers.length) { + groupBypassers = bypassers + .filter((bypasser) => bypasser.type === BypasserType.Group) + .map((bypasser) => bypasser.id) as string[]; + + const userBypassers = bypassers + .filter((bypasser) => bypasser.type === BypasserType.User) + .map((bypasser) => bypasser.id) + .filter(Boolean) as string[]; + + const userBypasserNames = bypassers + .map((bypasser) => (bypasser.type === BypasserType.User ? bypasser.name : undefined)) + .filter(Boolean) as string[]; + + bypasserUserIds = userBypassers; + if (userBypasserNames.length) { + const bypasserUsers = await userDAL.find({ + $in: { + username: userBypasserNames + } + }); + + const bypasserNamesFromDb = bypasserUsers.map((user) => user.username); + const invalidUsernames = userBypasserNames.filter((username) => !bypasserNamesFromDb.includes(username)); + + if (invalidUsernames.length) { + throw new BadRequestError({ + message: `Invalid bypasser user: ${invalidUsernames.join(", ")}` + }); + } + + bypasserUserIds = bypasserUserIds.concat(bypasserUsers.map((user) => user.id)); + } + } const updatedPolicy = await accessApprovalPolicyDAL.transaction(async (tx) => { const doc = await accessApprovalPolicyDAL.updateById( @@ -316,6 +419,28 @@ export const accessApprovalPolicyServiceFactory = ({ ); } + await accessApprovalPolicyBypasserDAL.delete({ policyId: doc.id }, tx); + + if (bypasserUserIds.length) { + await accessApprovalPolicyBypasserDAL.insertMany( + bypasserUserIds.map((userId) => ({ + bypasserUserId: userId, + policyId: doc.id + })), + tx + ); + } + + if (groupBypassers.length) { + await accessApprovalPolicyBypasserDAL.insertMany( + groupBypassers.map((groupId) => ({ + bypasserGroupId: groupId, + policyId: doc.id + })), + tx + ); + } + return doc; }); return { @@ -344,7 +469,7 @@ export const accessApprovalPolicyServiceFactory = ({ actionProjectType: ActionProjectType.SecretManager }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionApprovalActions.Delete, + ProjectPermissionActions.Delete, ProjectPermissionSub.SecretApproval ); @@ -435,10 +560,7 @@ export const accessApprovalPolicyServiceFactory = ({ actionProjectType: ActionProjectType.SecretManager }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionApprovalActions.Read, - ProjectPermissionSub.SecretApproval - ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); return policy; }; 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 dde8ffbea..1d702f5e4 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 @@ -18,11 +18,17 @@ export enum ApproverType { User = "user" } +export enum BypasserType { + Group = "group", + User = "user" +} + export type TCreateAccessApprovalPolicy = { approvals: number; secretPath: string; environment: string; approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; name?: string })[]; + bypassers?: ({ type: BypasserType.Group; id: string } | { type: BypasserType.User; id?: string; name?: string })[]; projectSlug: string; name: string; enforcementLevel: EnforcementLevel; @@ -33,6 +39,7 @@ export type TUpdateAccessApprovalPolicy = { policyId: string; approvals?: number; approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; name?: string })[]; + bypassers?: ({ type: BypasserType.Group; id: string } | { type: BypasserType.User; id?: string; name?: string })[]; secretPath?: string; name?: string; enforcementLevel?: EnforcementLevel; 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 e2075af0a..527d69cfe 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 @@ -1,7 +1,13 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { AccessApprovalRequestsSchema, TableName, TAccessApprovalRequests, TUsers } from "@app/db/schemas"; +import { + AccessApprovalRequestsSchema, + TableName, + TAccessApprovalRequests, + TUserGroupMembership, + TUsers +} from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { ormify, selectAllTableCols, sqlNestRelationships, TFindFilter } from "@app/lib/knex"; @@ -28,12 +34,12 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { `${TableName.AccessApprovalRequest}.policyId`, `${TableName.AccessApprovalPolicy}.id` ) - .leftJoin( TableName.AccessApprovalRequestReviewer, `${TableName.AccessApprovalRequest}.id`, `${TableName.AccessApprovalRequestReviewer}.requestId` ) + .leftJoin( TableName.AccessApprovalPolicyApprover, `${TableName.AccessApprovalPolicy}.id`, @@ -46,6 +52,17 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { ) .leftJoin(TableName.Users, `${TableName.UserGroupMembership}.userId`, `${TableName.Users}.id`) + .leftJoin( + TableName.AccessApprovalPolicyBypasser, + `${TableName.AccessApprovalPolicy}.id`, + `${TableName.AccessApprovalPolicyBypasser}.policyId` + ) + .leftJoin( + db(TableName.UserGroupMembership).as("bypasserUserGroupMembership"), + `${TableName.AccessApprovalPolicyBypasser}.bypasserGroupId`, + `bypasserUserGroupMembership.groupId` + ) + .join( db(TableName.Users).as("requestedByUser"), `${TableName.AccessApprovalRequest}.requestedByUserId`, @@ -69,6 +86,9 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { .select(db.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover)) .select(db.ref("userId").withSchema(TableName.UserGroupMembership).as("approverGroupUserId")) + .select(db.ref("bypasserUserId").withSchema(TableName.AccessApprovalPolicyBypasser)) + .select(db.ref("userId").withSchema("bypasserUserGroupMembership").as("bypasserGroupUserId")) + .select( db.ref("projectId").withSchema(TableName.Environment), db.ref("slug").withSchema(TableName.Environment).as("envSlug"), @@ -158,6 +178,12 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { key: "approverGroupUserId", label: "approvers" as const, mapper: ({ approverGroupUserId }) => approverGroupUserId + }, + { key: "bypasserUserId", label: "bypassers" as const, mapper: ({ bypasserUserId }) => bypasserUserId }, + { + key: "bypasserGroupUserId", + label: "bypassers" as const, + mapper: ({ bypasserGroupUserId }) => bypasserGroupUserId } ] }); @@ -166,7 +192,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { return formattedDocs.map((doc) => ({ ...doc, - policy: { ...doc.policy, approvers: doc.approvers } + policy: { ...doc.policy, approvers: doc.approvers, bypassers: doc.bypassers } })); } catch (error) { throw new DatabaseError({ error, name: "FindRequestsWithPrivilege" }); @@ -193,7 +219,6 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { `${TableName.AccessApprovalPolicy}.id`, `${TableName.AccessApprovalPolicyApprover}.policyId` ) - .leftJoin( db(TableName.Users).as("accessApprovalPolicyApproverUser"), `${TableName.AccessApprovalPolicyApprover}.approverUserId`, @@ -204,13 +229,33 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { `${TableName.AccessApprovalPolicyApprover}.approverGroupId`, `${TableName.UserGroupMembership}.groupId` ) - .leftJoin( db(TableName.Users).as("accessApprovalPolicyGroupApproverUser"), `${TableName.UserGroupMembership}.userId`, "accessApprovalPolicyGroupApproverUser.id" ) + .leftJoin( + TableName.AccessApprovalPolicyBypasser, + `${TableName.AccessApprovalPolicy}.id`, + `${TableName.AccessApprovalPolicyBypasser}.policyId` + ) + .leftJoin( + db(TableName.Users).as("accessApprovalPolicyBypasserUser"), + `${TableName.AccessApprovalPolicyBypasser}.bypasserUserId`, + "accessApprovalPolicyBypasserUser.id" + ) + .leftJoin( + db(TableName.UserGroupMembership).as("bypasserUserGroupMembership"), + `${TableName.AccessApprovalPolicyBypasser}.bypasserGroupId`, + `bypasserUserGroupMembership.groupId` + ) + .leftJoin( + db(TableName.Users).as("accessApprovalPolicyGroupBypasserUser"), + `bypasserUserGroupMembership.userId`, + "accessApprovalPolicyGroupBypasserUser.id" + ) + .leftJoin( TableName.AccessApprovalRequestReviewer, `${TableName.AccessApprovalRequest}.id`, @@ -241,6 +286,18 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { tx.ref("firstName").withSchema("requestedByUser").as("requestedByUserFirstName"), tx.ref("lastName").withSchema("requestedByUser").as("requestedByUserLastName"), + // Bypassers + tx.ref("bypasserUserId").withSchema(TableName.AccessApprovalPolicyBypasser), + tx.ref("userId").withSchema("bypasserUserGroupMembership").as("bypasserGroupUserId"), + tx.ref("email").withSchema("accessApprovalPolicyBypasserUser").as("bypasserEmail"), + tx.ref("email").withSchema("accessApprovalPolicyGroupBypasserUser").as("bypasserGroupEmail"), + tx.ref("username").withSchema("accessApprovalPolicyBypasserUser").as("bypasserUsername"), + tx.ref("username").withSchema("accessApprovalPolicyGroupBypasserUser").as("bypasserGroupUsername"), + tx.ref("firstName").withSchema("accessApprovalPolicyBypasserUser").as("bypasserFirstName"), + tx.ref("firstName").withSchema("accessApprovalPolicyGroupBypasserUser").as("bypasserGroupFirstName"), + tx.ref("lastName").withSchema("accessApprovalPolicyBypasserUser").as("bypasserLastName"), + tx.ref("lastName").withSchema("accessApprovalPolicyGroupBypasserUser").as("bypasserGroupLastName"), + tx.ref("reviewerUserId").withSchema(TableName.AccessApprovalRequestReviewer), tx.ref("status").withSchema(TableName.AccessApprovalRequestReviewer).as("reviewerStatus"), @@ -265,7 +322,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { try { const sql = findQuery({ [`${TableName.AccessApprovalRequest}.id` as "id"]: id }, tx || db.replicaNode()); const docs = await sql; - const formatedDoc = sqlNestRelationships({ + const formattedDoc = sqlNestRelationships({ data: docs, key: "id", parentMapper: (el) => ({ @@ -335,13 +392,51 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { lastName, username }) + }, + { + key: "bypasserUserId", + label: "bypassers" as const, + mapper: ({ + bypasserUserId, + bypasserEmail: email, + bypasserUsername: username, + bypasserLastName: lastName, + bypasserFirstName: firstName + }) => ({ + userId: bypasserUserId, + email, + firstName, + lastName, + username + }) + }, + { + key: "bypasserGroupUserId", + label: "bypassers" as const, + mapper: ({ + userId, + bypasserGroupEmail: email, + bypasserGroupUsername: username, + bypasserGroupLastName: lastName, + bypasserFirstName: firstName + }) => ({ + userId, + email, + firstName, + lastName, + username + }) } ] }); - if (!formatedDoc?.[0]) return; + if (!formattedDoc?.[0]) return; return { - ...formatedDoc[0], - policy: { ...formatedDoc[0].policy, approvers: formatedDoc[0].approvers } + ...formattedDoc[0], + policy: { + ...formattedDoc[0].policy, + approvers: formattedDoc[0].approvers, + bypassers: formattedDoc[0].bypassers + } }; } catch (error) { throw new DatabaseError({ error, name: "FindByIdAccessApprovalRequest" }); 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 017356a5d..c06a489e9 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 @@ -23,7 +23,6 @@ import { TAccessApprovalPolicyApproverDALFactory } from "../access-approval-poli import { TAccessApprovalPolicyDALFactory } from "../access-approval-policy/access-approval-policy-dal"; import { TGroupDALFactory } from "../group/group-dal"; import { TPermissionServiceFactory } from "../permission/permission-service"; -import { ProjectPermissionApprovalActions, ProjectPermissionSub } from "../permission/project-permission"; import { TProjectUserAdditionalPrivilegeDALFactory } from "../project-user-additional-privilege/project-user-additional-privilege-dal"; import { ProjectUserAdditionalPrivilegeTemporaryMode } from "../project-user-additional-privilege/project-user-additional-privilege-types"; import { TAccessApprovalRequestDALFactory } from "./access-approval-request-dal"; @@ -340,7 +339,7 @@ export const accessApprovalRequestServiceFactory = ({ }); } - const { membership, hasRole, permission } = await permissionService.getProjectPermission({ + const { membership, hasRole } = await permissionService.getProjectPermission({ actor, actorId, projectId: accessApprovalRequest.projectId, @@ -355,13 +354,12 @@ export const accessApprovalRequestServiceFactory = ({ const isSelfApproval = actorId === accessApprovalRequest.requestedByUserId; const isSoftEnforcement = policy.enforcementLevel === EnforcementLevel.Soft; - const canBypassApproval = permission.can( - ProjectPermissionApprovalActions.AllowAccessBypass, - ProjectPermissionSub.SecretApproval - ); - const cannotBypassUnderSoftEnforcement = !(isSoftEnforcement && canBypassApproval); + const canBypass = !policy.bypassers.length || policy.bypassers.some((bypasser) => bypasser.userId === actorId); + const cannotBypassUnderSoftEnforcement = !(isSoftEnforcement && canBypass); - if (!policy.allowedSelfApprovals && isSelfApproval && cannotBypassUnderSoftEnforcement) { + const isApprover = policy.approvers.find((approver) => approver.userId === actorId); + + if ((!isApprover || (!policy.allowedSelfApprovals && isSelfApproval)) && cannotBypassUnderSoftEnforcement) { throw new BadRequestError({ message: "Failed to review access approval request. Users are not authorized to review their own request." }); @@ -370,7 +368,7 @@ export const accessApprovalRequestServiceFactory = ({ if ( !hasRole(ProjectMembershipRole.Admin) && accessApprovalRequest.requestedByUserId !== actorId && // The request wasn't made by the current user - !policy.approvers.find((approver) => approver.userId === actorId) // The request isn't performed by an assigned approver + !isApprover // The request isn't performed by an assigned approver ) { throw new ForbiddenRequestError({ message: "You are not authorized to approve this request" }); } diff --git a/backend/src/ee/services/permission/default-roles.ts b/backend/src/ee/services/permission/default-roles.ts index 2a422d55f..a3f444a11 100644 --- a/backend/src/ee/services/permission/default-roles.ts +++ b/backend/src/ee/services/permission/default-roles.ts @@ -2,7 +2,6 @@ import { AbilityBuilder, createMongoAbility, MongoAbility } from "@casl/ability" import { ProjectPermissionActions, - ProjectPermissionApprovalActions, ProjectPermissionCertificateActions, ProjectPermissionCmekActions, ProjectPermissionDynamicSecretActions, @@ -57,12 +56,10 @@ const buildAdminPermissionRules = () => { can( [ - ProjectPermissionApprovalActions.Read, - ProjectPermissionApprovalActions.Edit, - ProjectPermissionApprovalActions.Create, - ProjectPermissionApprovalActions.Delete, - ProjectPermissionApprovalActions.AllowChangeBypass, - ProjectPermissionApprovalActions.AllowAccessBypass + ProjectPermissionActions.Read, + ProjectPermissionActions.Edit, + ProjectPermissionActions.Create, + ProjectPermissionActions.Delete ], ProjectPermissionSub.SecretApproval ); @@ -255,7 +252,7 @@ const buildMemberPermissionRules = () => { ProjectPermissionSub.SecretImports ); - can([ProjectPermissionApprovalActions.Read], ProjectPermissionSub.SecretApproval); + can([ProjectPermissionActions.Read], ProjectPermissionSub.SecretApproval); can([ProjectPermissionSecretRotationActions.Read], ProjectPermissionSub.SecretRotation); can([ProjectPermissionActions.Read, ProjectPermissionActions.Create], ProjectPermissionSub.SecretRollback); @@ -403,7 +400,7 @@ const buildViewerPermissionRules = () => { can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretFolders); can(ProjectPermissionDynamicSecretActions.ReadRootCredential, ProjectPermissionSub.DynamicSecrets); can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretImports); - can(ProjectPermissionApprovalActions.Read, ProjectPermissionSub.SecretApproval); + can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretRollback); can(ProjectPermissionSecretRotationActions.Read, ProjectPermissionSub.SecretRotation); can(ProjectPermissionMemberActions.Read, ProjectPermissionSub.Member); diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 5d06e085d..14408e8a0 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -34,15 +34,6 @@ export enum ProjectPermissionSecretActions { Delete = "delete" } -export enum ProjectPermissionApprovalActions { - Read = "read", - Create = "create", - Edit = "edit", - Delete = "delete", - AllowChangeBypass = "allow-change-bypass", - AllowAccessBypass = "allow-access-bypass" -} - export enum ProjectPermissionCmekActions { Read = "read", Create = "create", @@ -251,7 +242,7 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.IpAllowList] | [ProjectPermissionActions, ProjectPermissionSub.Settings] | [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens] - | [ProjectPermissionApprovalActions, ProjectPermissionSub.SecretApproval] + | [ProjectPermissionActions, ProjectPermissionSub.SecretApproval] | [ ProjectPermissionSecretRotationActions, ( @@ -448,7 +439,7 @@ const PkiSubscriberConditionSchema = z const GeneralPermissionSchema = [ z.object({ subject: z.literal(ProjectPermissionSub.SecretApproval).describe("The entity this permission pertains to."), - action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionApprovalActions).describe( + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionActions).describe( "Describe what action an entity can take." ) }), diff --git a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-approver-dal.ts b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-approver-dal.ts index f32439499..1d8ae24a2 100644 --- a/backend/src/ee/services/secret-approval-policy/secret-approval-policy-approver-dal.ts +++ b/backend/src/ee/services/secret-approval-policy/secret-approval-policy-approver-dal.ts @@ -8,3 +8,10 @@ export const secretApprovalPolicyApproverDALFactory = (db: TDbClient) => { const sapApproverOrm = ormify(db, TableName.SecretApprovalPolicyApprover); return sapApproverOrm; }; + +export type TSecretApprovalPolicyBypasserDALFactory = ReturnType; + +export const secretApprovalPolicyBypasserDALFactory = (db: TDbClient) => { + const sapBypasserOrm = ormify(db, TableName.SecretApprovalPolicyBypasser); + return sapBypasserOrm; +}; 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 6644b14b8..690fef26f 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 @@ -1,11 +1,17 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { SecretApprovalPoliciesSchema, TableName, TSecretApprovalPolicies, TUsers } from "@app/db/schemas"; +import { + SecretApprovalPoliciesSchema, + TableName, + TSecretApprovalPolicies, + TUserGroupMembership, + TUsers +} from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { buildFindFilter, ormify, selectAllTableCols, sqlNestRelationships, TFindFilter } from "@app/lib/knex"; -import { ApproverType } from "../access-approval-policy/access-approval-policy-types"; +import { ApproverType, BypasserType } from "../access-approval-policy/access-approval-policy-types"; export type TSecretApprovalPolicyDALFactory = ReturnType; @@ -43,6 +49,22 @@ export const secretApprovalPolicyDALFactory = (db: TDbClient) => { `${TableName.SecretApprovalPolicyApprover}.approverUserId`, "secretApprovalPolicyApproverUser.id" ) + // Bypasser + .leftJoin( + TableName.SecretApprovalPolicyBypasser, + `${TableName.SecretApprovalPolicy}.id`, + `${TableName.SecretApprovalPolicyBypasser}.policyId` + ) + .leftJoin( + db(TableName.UserGroupMembership).as("bypasserUserGroupMembership"), + `${TableName.SecretApprovalPolicyBypasser}.bypasserGroupId`, + `bypasserUserGroupMembership.groupId` + ) + .leftJoin( + db(TableName.Users).as("secretApprovalPolicyBypasserUser"), + `${TableName.SecretApprovalPolicyBypasser}.bypasserUserId`, + "secretApprovalPolicyBypasserUser.id" + ) .leftJoin(TableName.Users, `${TableName.UserGroupMembership}.userId`, `${TableName.Users}.id`) .select( tx.ref("id").withSchema("secretApprovalPolicyApproverUser").as("approverUserId"), @@ -58,6 +80,20 @@ export const secretApprovalPolicyDALFactory = (db: TDbClient) => { tx.ref("firstName").withSchema(TableName.Users).as("approverGroupFirstName"), tx.ref("lastName").withSchema(TableName.Users).as("approverGroupLastName") ) + .select( + tx.ref("id").withSchema("secretApprovalPolicyBypasserUser").as("bypasserUserId"), + tx.ref("email").withSchema("secretApprovalPolicyBypasserUser").as("bypasserEmail"), + tx.ref("firstName").withSchema("secretApprovalPolicyBypasserUser").as("bypasserFirstName"), + tx.ref("username").withSchema("secretApprovalPolicyBypasserUser").as("bypasserUsername"), + tx.ref("lastName").withSchema("secretApprovalPolicyBypasserUser").as("bypasserLastName") + ) + .select( + tx.ref("bypasserGroupId").withSchema(TableName.SecretApprovalPolicyBypasser), + tx.ref("userId").withSchema("bypasserUserGroupMembership").as("bypasserGroupUserId"), + tx.ref("email").withSchema(TableName.Users).as("bypasserGroupEmail"), + tx.ref("firstName").withSchema(TableName.Users).as("bypasserGroupFirstName"), + tx.ref("lastName").withSchema(TableName.Users).as("bypasserGroupLastName") + ) .select( tx.ref("name").withSchema(TableName.Environment).as("envName"), tx.ref("slug").withSchema(TableName.Environment).as("envSlug"), @@ -155,6 +191,23 @@ export const secretApprovalPolicyDALFactory = (db: TDbClient) => { id }) }, + { + key: "bypasserUserId", + label: "bypassers" as const, + mapper: ({ bypasserUserId: id, bypasserUsername }) => ({ + type: BypasserType.User, + name: bypasserUsername, + id + }) + }, + { + key: "bypasserGroupId", + label: "bypassers" as const, + mapper: ({ bypasserGroupId: id }) => ({ + type: BypasserType.Group, + id + }) + }, { key: "approverUserId", label: "userApprovers" as const, 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 bc2877ef2..0db3e3bc3 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 @@ -3,18 +3,21 @@ import picomatch from "picomatch"; import { ActionProjectType } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; -import { ProjectPermissionApprovalActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { removeTrailingSlash } from "@app/lib/fn"; import { containsGlobPatterns } from "@app/lib/picomatch"; import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal"; import { TUserDALFactory } from "@app/services/user/user-dal"; -import { ApproverType } from "../access-approval-policy/access-approval-policy-types"; +import { ApproverType, BypasserType } from "../access-approval-policy/access-approval-policy-types"; import { TLicenseServiceFactory } from "../license/license-service"; import { TSecretApprovalRequestDALFactory } from "../secret-approval-request/secret-approval-request-dal"; import { RequestState } from "../secret-approval-request/secret-approval-request-types"; -import { TSecretApprovalPolicyApproverDALFactory } from "./secret-approval-policy-approver-dal"; +import { + TSecretApprovalPolicyApproverDALFactory, + TSecretApprovalPolicyBypasserDALFactory +} from "./secret-approval-policy-approver-dal"; import { TSecretApprovalPolicyDALFactory } from "./secret-approval-policy-dal"; import { TCreateSapDTO, @@ -36,6 +39,7 @@ type TSecretApprovalPolicyServiceFactoryDep = { projectEnvDAL: Pick; userDAL: Pick; secretApprovalPolicyApproverDAL: TSecretApprovalPolicyApproverDALFactory; + secretApprovalPolicyBypasserDAL: TSecretApprovalPolicyBypasserDALFactory; licenseService: Pick; secretApprovalRequestDAL: Pick; }; @@ -46,6 +50,7 @@ export const secretApprovalPolicyServiceFactory = ({ secretApprovalPolicyDAL, permissionService, secretApprovalPolicyApproverDAL, + secretApprovalPolicyBypasserDAL, projectEnvDAL, userDAL, licenseService, @@ -59,6 +64,7 @@ export const secretApprovalPolicyServiceFactory = ({ actorAuthMethod, approvals, approvers, + bypassers, projectId, secretPath, environment, @@ -89,7 +95,7 @@ export const secretApprovalPolicyServiceFactory = ({ actionProjectType: ActionProjectType.SecretManager }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionApprovalActions.Create, + ProjectPermissionActions.Create, ProjectPermissionSub.SecretApproval ); @@ -107,6 +113,44 @@ export const secretApprovalPolicyServiceFactory = ({ message: `Environment with slug '${environment}' not found in project with ID ${projectId}` }); + let groupBypassers: string[] = []; + let bypasserUserIds: string[] = []; + + if (bypassers && bypassers.length) { + groupBypassers = bypassers + .filter((bypasser) => bypasser.type === BypasserType.Group) + .map((bypasser) => bypasser.id) as string[]; + + const userBypassers = bypassers + .filter((bypasser) => bypasser.type === BypasserType.User) + .map((bypasser) => bypasser.id) + .filter(Boolean) as string[]; + + const userBypasserNames = bypassers + .map((bypasser) => (bypasser.type === BypasserType.User ? bypasser.name : undefined)) + .filter(Boolean) as string[]; + + bypasserUserIds = userBypassers; + if (userBypasserNames.length) { + const bypasserUsers = await userDAL.find({ + $in: { + username: userBypasserNames + } + }); + + const bypasserNamesFromDb = bypasserUsers.map((user) => user.username); + const invalidUsernames = userBypasserNames.filter((username) => !bypasserNamesFromDb.includes(username)); + + if (invalidUsernames.length) { + throw new BadRequestError({ + message: `Invalid bypasser user: ${invalidUsernames.join(", ")}` + }); + } + + bypasserUserIds = bypasserUserIds.concat(bypasserUsers.map((user) => user.id)); + } + } + const secretApproval = await secretApprovalPolicyDAL.transaction(async (tx) => { const doc = await secretApprovalPolicyDAL.create( { @@ -158,6 +202,27 @@ export const secretApprovalPolicyServiceFactory = ({ })), tx ); + + if (bypasserUserIds.length) { + await secretApprovalPolicyBypasserDAL.insertMany( + bypasserUserIds.map((userId) => ({ + bypasserUserId: userId, + policyId: doc.id + })), + tx + ); + } + + if (groupBypassers.length) { + await secretApprovalPolicyBypasserDAL.insertMany( + groupBypassers.map((groupId) => ({ + bypasserGroupId: groupId, + policyId: doc.id + })), + tx + ); + } + return doc; }); @@ -166,6 +231,7 @@ export const secretApprovalPolicyServiceFactory = ({ const updateSecretApprovalPolicy = async ({ approvers, + bypassers, secretPath, name, actorId, @@ -204,10 +270,7 @@ export const secretApprovalPolicyServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.SecretManager }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionApprovalActions.Edit, - ProjectPermissionSub.SecretApproval - ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.SecretApproval); const plan = await licenseService.getPlan(actorOrgId); if (!plan.secretApproval) { @@ -217,6 +280,44 @@ export const secretApprovalPolicyServiceFactory = ({ }); } + let groupBypassers: string[] = []; + let bypasserUserIds: string[] = []; + + if (bypassers && bypassers.length) { + groupBypassers = bypassers + .filter((bypasser) => bypasser.type === BypasserType.Group) + .map((bypasser) => bypasser.id) as string[]; + + const userBypassers = bypassers + .filter((bypasser) => bypasser.type === BypasserType.User) + .map((bypasser) => bypasser.id) + .filter(Boolean) as string[]; + + const userBypasserNames = bypassers + .map((bypasser) => (bypasser.type === BypasserType.User ? bypasser.name : undefined)) + .filter(Boolean) as string[]; + + bypasserUserIds = userBypassers; + if (userBypasserNames.length) { + const bypasserUsers = await userDAL.find({ + $in: { + username: userBypasserNames + } + }); + + const bypasserNamesFromDb = bypasserUsers.map((user) => user.username); + const invalidUsernames = userBypasserNames.filter((username) => !bypasserNamesFromDb.includes(username)); + + if (invalidUsernames.length) { + throw new BadRequestError({ + message: `Invalid bypasser user: ${invalidUsernames.join(", ")}` + }); + } + + bypasserUserIds = bypasserUserIds.concat(bypasserUsers.map((user) => user.id)); + } + } + const updatedSap = await secretApprovalPolicyDAL.transaction(async (tx) => { const doc = await secretApprovalPolicyDAL.updateById( secretApprovalPolicy.id, @@ -275,6 +376,28 @@ export const secretApprovalPolicyServiceFactory = ({ ); } + await secretApprovalPolicyBypasserDAL.delete({ policyId: doc.id }, tx); + + if (bypasserUserIds.length) { + await secretApprovalPolicyBypasserDAL.insertMany( + bypasserUserIds.map((userId) => ({ + bypasserUserId: userId, + policyId: doc.id + })), + tx + ); + } + + if (groupBypassers.length) { + await secretApprovalPolicyBypasserDAL.insertMany( + groupBypassers.map((groupId) => ({ + bypasserGroupId: groupId, + policyId: doc.id + })), + tx + ); + } + return doc; }); return { @@ -304,7 +427,7 @@ export const secretApprovalPolicyServiceFactory = ({ actionProjectType: ActionProjectType.SecretManager }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionApprovalActions.Delete, + ProjectPermissionActions.Delete, ProjectPermissionSub.SecretApproval ); @@ -343,10 +466,7 @@ export const secretApprovalPolicyServiceFactory = ({ actorOrgId, actionProjectType: ActionProjectType.SecretManager }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionApprovalActions.Read, - ProjectPermissionSub.SecretApproval - ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); const sapPolicies = await secretApprovalPolicyDAL.find({ projectId, deletedAt: null }); return sapPolicies; @@ -419,10 +539,7 @@ export const secretApprovalPolicyServiceFactory = ({ actionProjectType: ActionProjectType.SecretManager }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionApprovalActions.Read, - ProjectPermissionSub.SecretApproval - ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); return sapPolicy; }; 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 a6fea6956..679f42349 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 @@ -1,12 +1,13 @@ import { EnforcementLevel, TProjectPermission } from "@app/lib/types"; -import { ApproverType } from "../access-approval-policy/access-approval-policy-types"; +import { ApproverType, BypasserType } from "../access-approval-policy/access-approval-policy-types"; export type TCreateSapDTO = { approvals: number; secretPath?: string | null; environment: string; approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; name?: string })[]; + bypassers?: ({ type: BypasserType.Group; id: string } | { type: BypasserType.User; id?: string; name?: string })[]; projectId: string; name: string; enforcementLevel: EnforcementLevel; @@ -18,6 +19,7 @@ export type TUpdateSapDTO = { approvals?: number; secretPath?: string | null; approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; name?: string })[]; + bypassers?: ({ type: BypasserType.Group; id: string } | { type: BypasserType.User; id?: string; name?: string })[]; name?: string; enforcementLevel?: EnforcementLevel; allowedSelfApprovals?: boolean; 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 3877cbaf8..3bd35c3c8 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 @@ -6,6 +6,7 @@ import { TableName, TSecretApprovalRequests, TSecretApprovalRequestsSecrets, + TUserGroupMembership, TUsers } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; @@ -58,16 +59,36 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { `${TableName.SecretApprovalPolicyApprover}.approverUserId`, "secretApprovalPolicyApproverUser.id" ) - .leftJoin( - TableName.UserGroupMembership, + .leftJoin( + db(TableName.UserGroupMembership).as("approverUserGroupMembership"), `${TableName.SecretApprovalPolicyApprover}.approverGroupId`, - `${TableName.UserGroupMembership}.groupId` + `approverUserGroupMembership.groupId` ) .leftJoin( db(TableName.Users).as("secretApprovalPolicyGroupApproverUser"), - `${TableName.UserGroupMembership}.userId`, + `approverUserGroupMembership.userId`, `secretApprovalPolicyGroupApproverUser.id` ) + .leftJoin( + TableName.SecretApprovalPolicyBypasser, + `${TableName.SecretApprovalPolicy}.id`, + `${TableName.SecretApprovalPolicyBypasser}.policyId` + ) + .leftJoin( + db(TableName.Users).as("secretApprovalPolicyBypasserUser"), + `${TableName.SecretApprovalPolicyBypasser}.bypasserUserId`, + "secretApprovalPolicyBypasserUser.id" + ) + .leftJoin( + db(TableName.UserGroupMembership).as("bypasserUserGroupMembership"), + `${TableName.SecretApprovalPolicyBypasser}.bypasserGroupId`, + `bypasserUserGroupMembership.groupId` + ) + .leftJoin( + db(TableName.Users).as("secretApprovalPolicyGroupBypasserUser"), + `bypasserUserGroupMembership.userId`, + `secretApprovalPolicyGroupBypasserUser.id` + ) .leftJoin( TableName.SecretApprovalRequestReviewer, `${TableName.SecretApprovalRequest}.id`, @@ -81,7 +102,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { .select(selectAllTableCols(TableName.SecretApprovalRequest)) .select( tx.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover), - tx.ref("userId").withSchema(TableName.UserGroupMembership).as("approverGroupUserId"), + tx.ref("userId").withSchema("approverUserGroupMembership").as("approverGroupUserId"), tx.ref("email").withSchema("secretApprovalPolicyApproverUser").as("approverEmail"), tx.ref("email").withSchema("secretApprovalPolicyGroupApproverUser").as("approverGroupEmail"), tx.ref("username").withSchema("secretApprovalPolicyApproverUser").as("approverUsername"), @@ -90,6 +111,20 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { tx.ref("firstName").withSchema("secretApprovalPolicyGroupApproverUser").as("approverGroupFirstName"), tx.ref("lastName").withSchema("secretApprovalPolicyApproverUser").as("approverLastName"), tx.ref("lastName").withSchema("secretApprovalPolicyGroupApproverUser").as("approverGroupLastName"), + + // Bypasser fields + tx.ref("bypasserUserId").withSchema(TableName.SecretApprovalPolicyBypasser), + tx.ref("bypasserGroupId").withSchema(TableName.SecretApprovalPolicyBypasser), + tx.ref("userId").withSchema("bypasserUserGroupMembership").as("bypasserGroupUserId"), + tx.ref("email").withSchema("secretApprovalPolicyBypasserUser").as("bypasserEmail"), + tx.ref("email").withSchema("secretApprovalPolicyGroupBypasserUser").as("bypasserGroupEmail"), + tx.ref("username").withSchema("secretApprovalPolicyBypasserUser").as("bypasserUsername"), + tx.ref("username").withSchema("secretApprovalPolicyGroupBypasserUser").as("bypasserGroupUsername"), + tx.ref("firstName").withSchema("secretApprovalPolicyBypasserUser").as("bypasserFirstName"), + tx.ref("firstName").withSchema("secretApprovalPolicyGroupBypasserUser").as("bypasserGroupFirstName"), + tx.ref("lastName").withSchema("secretApprovalPolicyBypasserUser").as("bypasserLastName"), + tx.ref("lastName").withSchema("secretApprovalPolicyGroupBypasserUser").as("bypasserGroupLastName"), + tx.ref("email").withSchema("statusChangedByUser").as("statusChangedByUserEmail"), tx.ref("username").withSchema("statusChangedByUser").as("statusChangedByUserUsername"), tx.ref("firstName").withSchema("statusChangedByUser").as("statusChangedByUserFirstName"), @@ -121,7 +156,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { try { const sql = findQuery({ [`${TableName.SecretApprovalRequest}.id` as "id"]: id }, tx || db.replicaNode()); const docs = await sql; - const formatedDoc = sqlNestRelationships({ + const formattedDoc = sqlNestRelationships({ data: docs, key: "id", parentMapper: (el) => ({ @@ -203,13 +238,51 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { lastName, username }) + }, + { + key: "bypasserUserId", + label: "bypassers" as const, + mapper: ({ + bypasserUserId: userId, + bypasserEmail: email, + bypasserUsername: username, + bypasserLastName: lastName, + bypasserFirstName: firstName + }) => ({ + userId, + email, + firstName, + lastName, + username + }) + }, + { + key: "bypasserGroupUserId", + label: "bypassers" as const, + mapper: ({ + bypasserGroupUserId: userId, + bypasserGroupEmail: email, + bypasserGroupUsername: username, + bypasserGroupLastName: lastName, + bypasserGroupFirstName: firstName + }) => ({ + userId, + email, + firstName, + lastName, + username + }) } ] }); - if (!formatedDoc?.[0]) return; + if (!formattedDoc?.[0]) return; return { - ...formatedDoc[0], - policy: { ...formatedDoc[0].policy, approvers: formatedDoc[0].approvers } + ...formattedDoc[0], + policy: { + ...formattedDoc[0].policy, + approvers: formattedDoc[0].approvers, + bypassers: formattedDoc[0].bypassers + } }; } catch (error) { throw new DatabaseError({ error, name: "FindByIdSAR" }); @@ -291,6 +364,16 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { `${TableName.SecretApprovalPolicyApprover}.approverGroupId`, `${TableName.UserGroupMembership}.groupId` ) + .leftJoin( + TableName.SecretApprovalPolicyBypasser, + `${TableName.SecretApprovalPolicy}.id`, + `${TableName.SecretApprovalPolicyBypasser}.policyId` + ) + .leftJoin( + db(TableName.UserGroupMembership).as("bypasserUserGroupMembership"), + `${TableName.SecretApprovalPolicyBypasser}.bypasserGroupId`, + `bypasserUserGroupMembership.groupId` + ) .join( db(TableName.Users).as("committerUser"), `${TableName.SecretApprovalRequest}.committerUserId`, @@ -342,6 +425,11 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { db.ref("approvals").withSchema(TableName.SecretApprovalPolicy).as("policyApprovals"), db.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover), db.ref("userId").withSchema(TableName.UserGroupMembership).as("approverGroupUserId"), + + // Bypasser fields + db.ref("bypasserUserId").withSchema(TableName.SecretApprovalPolicyBypasser), + db.ref("userId").withSchema("bypasserUserGroupMembership").as("bypasserGroupUserId"), + db.ref("email").withSchema("committerUser").as("committerUserEmail"), db.ref("username").withSchema("committerUser").as("committerUserUsername"), db.ref("firstName").withSchema("committerUser").as("committerUserFirstName"), @@ -355,7 +443,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { .from[number]>("w") .where("w.rank", ">=", offset) .andWhere("w.rank", "<", offset + limit); - const formatedDoc = sqlNestRelationships({ + const formattedDoc = sqlNestRelationships({ data: docs, key: "id", parentMapper: (el) => ({ @@ -403,12 +491,22 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { key: "approverGroupUserId", label: "approvers" as const, mapper: ({ approverGroupUserId }) => ({ userId: approverGroupUserId }) + }, + { + key: "bypasserUserId", + label: "bypassers" as const, + mapper: ({ bypasserUserId }) => ({ userId: bypasserUserId }) + }, + { + key: "bypasserGroupUserId", + label: "bypassers" as const, + mapper: ({ bypasserGroupUserId }) => ({ userId: bypasserGroupUserId }) } ] }); - return formatedDoc.map((el) => ({ + return formattedDoc.map((el) => ({ ...el, - policy: { ...el.policy, approvers: el.approvers } + policy: { ...el.policy, approvers: el.approvers, bypassers: el.bypassers } })); } catch (error) { throw new DatabaseError({ error, name: "FindSAR" }); @@ -440,6 +538,16 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { `${TableName.SecretApprovalPolicyApprover}.approverGroupId`, `${TableName.UserGroupMembership}.groupId` ) + .leftJoin( + TableName.SecretApprovalPolicyBypasser, + `${TableName.SecretApprovalPolicy}.id`, + `${TableName.SecretApprovalPolicyBypasser}.policyId` + ) + .leftJoin( + db(TableName.UserGroupMembership).as("bypasserUserGroupMembership"), + `${TableName.SecretApprovalPolicyBypasser}.bypasserGroupId`, + `bypasserUserGroupMembership.groupId` + ) .join( db(TableName.Users).as("committerUser"), `${TableName.SecretApprovalRequest}.committerUserId`, @@ -491,6 +599,11 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { db.ref("enforcementLevel").withSchema(TableName.SecretApprovalPolicy).as("policyEnforcementLevel"), db.ref("approverUserId").withSchema(TableName.SecretApprovalPolicyApprover), db.ref("userId").withSchema(TableName.UserGroupMembership).as("approverGroupUserId"), + + // Bypasser + db.ref("bypasserUserId").withSchema(TableName.SecretApprovalPolicyBypasser), + db.ref("userId").withSchema("bypasserUserGroupMembership").as("bypasserGroupUserId"), + db.ref("email").withSchema("committerUser").as("committerUserEmail"), db.ref("username").withSchema("committerUser").as("committerUserUsername"), db.ref("firstName").withSchema("committerUser").as("committerUserFirstName"), @@ -504,7 +617,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { .from[number]>("w") .where("w.rank", ">=", offset) .andWhere("w.rank", "<", offset + limit); - const formatedDoc = sqlNestRelationships({ + const formattedDoc = sqlNestRelationships({ data: docs, key: "id", parentMapper: (el) => ({ @@ -554,12 +667,24 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { mapper: ({ approverGroupUserId }) => ({ userId: approverGroupUserId }) + }, + { + key: "bypasserUserId", + label: "bypassers" as const, + mapper: ({ bypasserUserId }) => ({ userId: bypasserUserId }) + }, + { + key: "bypasserGroupUserId", + label: "bypassers" as const, + mapper: ({ bypasserGroupUserId }) => ({ + userId: bypasserGroupUserId + }) } ] }); - return formatedDoc.map((el) => ({ + return formattedDoc.map((el) => ({ ...el, - policy: { ...el.policy, approvers: el.approvers } + policy: { ...el.policy, approvers: el.approvers, bypassers: el.bypassers } })); } catch (error) { throw new DatabaseError({ error, name: "FindSAR" }); 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 500312a7a..217181281 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 @@ -62,11 +62,7 @@ import { TUserDALFactory } from "@app/services/user/user-dal"; import { TLicenseServiceFactory } from "../license/license-service"; import { throwIfMissingSecretReadValueOrDescribePermission } from "../permission/permission-fns"; import { TPermissionServiceFactory } from "../permission/permission-service"; -import { - ProjectPermissionApprovalActions, - ProjectPermissionSecretActions, - ProjectPermissionSub -} from "../permission/project-permission"; +import { ProjectPermissionSecretActions, ProjectPermissionSub } from "../permission/project-permission"; import { TSecretApprovalPolicyDALFactory } from "../secret-approval-policy/secret-approval-policy-dal"; import { TSecretSnapshotServiceFactory } from "../secret-snapshot/secret-snapshot-service"; import { TSecretApprovalRequestDALFactory } from "./secret-approval-request-dal"; @@ -501,14 +497,14 @@ export const secretApprovalRequestServiceFactory = ({ }); } - const { policy, folderId, projectId } = secretApprovalRequest; + const { policy, folderId, projectId, bypassers } = secretApprovalRequest; if (policy.deletedAt) { throw new BadRequestError({ message: "The policy associated with this secret approval request has been deleted." }); } - const { hasRole, permission } = await permissionService.getProjectPermission({ + const { hasRole } = await permissionService.getProjectPermission({ actor: ActorType.USER, actorId, projectId, @@ -534,14 +530,9 @@ export const secretApprovalRequestServiceFactory = ({ approverId ? reviewers[approverId] === ApprovalStatus.APPROVED : false ).length; const isSoftEnforcement = secretApprovalRequest.policy.enforcementLevel === EnforcementLevel.Soft; + const canBypass = !bypassers.length || bypassers.some((bypasser) => bypasser.userId === actorId); - if ( - !hasMinApproval && - !( - isSoftEnforcement && - permission.can(ProjectPermissionApprovalActions.AllowChangeBypass, ProjectPermissionSub.SecretApproval) - ) - ) + if (!hasMinApproval && !(isSoftEnforcement && canBypass)) throw new BadRequestError({ message: "Doesn't have minimum approvals needed" }); const { botKey, shouldUseSecretV2Bridge, project } = await projectBotService.getBotKey(projectId); diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 0402c1a32..99f1a0150 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -6,7 +6,10 @@ import { z } from "zod"; import { registerCertificateEstRouter } from "@app/ee/routes/est/certificate-est-router"; import { registerV1EERoutes } from "@app/ee/routes/v1"; import { registerV2EERoutes } from "@app/ee/routes/v2"; -import { accessApprovalPolicyApproverDALFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-approver-dal"; +import { + accessApprovalPolicyApproverDALFactory, + 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 { 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"; @@ -67,7 +70,10 @@ import { samlConfigDALFactory } from "@app/ee/services/saml-config/saml-config-d import { samlConfigServiceFactory } from "@app/ee/services/saml-config/saml-config-service"; import { scimDALFactory } from "@app/ee/services/scim/scim-dal"; import { scimServiceFactory } from "@app/ee/services/scim/scim-service"; -import { secretApprovalPolicyApproverDALFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-approver-dal"; +import { + secretApprovalPolicyApproverDALFactory, + 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 { 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"; @@ -385,9 +391,11 @@ export const registerRoutes = async ( const accessApprovalPolicyDAL = accessApprovalPolicyDALFactory(db); const accessApprovalRequestDAL = accessApprovalRequestDALFactory(db); const accessApprovalPolicyApproverDAL = accessApprovalPolicyApproverDALFactory(db); + const accessApprovalPolicyBypasserDAL = accessApprovalPolicyBypasserDALFactory(db); const accessApprovalRequestReviewerDAL = accessApprovalRequestReviewerDALFactory(db); const sapApproverDAL = secretApprovalPolicyApproverDALFactory(db); + const sapBypasserDAL = secretApprovalPolicyBypasserDALFactory(db); const secretApprovalPolicyDAL = secretApprovalPolicyDALFactory(db); const secretApprovalRequestDAL = secretApprovalRequestDALFactory(db); const secretApprovalRequestReviewerDAL = secretApprovalRequestReviewerDALFactory(db); @@ -519,6 +527,7 @@ export const registerRoutes = async ( const secretApprovalPolicyService = secretApprovalPolicyServiceFactory({ projectEnvDAL, secretApprovalPolicyApproverDAL: sapApproverDAL, + secretApprovalPolicyBypasserDAL: sapBypasserDAL, permissionService, secretApprovalPolicyDAL, licenseService, @@ -1218,6 +1227,7 @@ export const registerRoutes = async ( const accessApprovalPolicyService = accessApprovalPolicyServiceFactory({ accessApprovalPolicyDAL, accessApprovalPolicyApproverDAL, + accessApprovalPolicyBypasserDAL, groupDAL, permissionService, projectEnvDAL, diff --git a/frontend/src/context/ProjectPermissionContext/index.tsx b/frontend/src/context/ProjectPermissionContext/index.tsx index e8efffd6d..d7b7334ea 100644 --- a/frontend/src/context/ProjectPermissionContext/index.tsx +++ b/frontend/src/context/ProjectPermissionContext/index.tsx @@ -2,7 +2,6 @@ export { useProjectPermission } from "./ProjectPermissionContext"; export type { ProjectPermissionSet, TProjectPermission } from "./types"; export { ProjectPermissionActions, - ProjectPermissionApprovalActions, ProjectPermissionCertificateActions, ProjectPermissionCmekActions, ProjectPermissionDynamicSecretActions, diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index d49b8bc8e..a640f6eaa 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -24,15 +24,6 @@ export enum ProjectPermissionSecretActions { Delete = "delete" } -export enum ProjectPermissionApprovalActions { - Read = "read", - Create = "create", - Edit = "edit", - Delete = "delete", - AllowChangeBypass = "allow-change-bypass", - AllowAccessBypass = "allow-access-bypass" -} - export enum ProjectPermissionDynamicSecretActions { ReadRootCredential = "read-root-credential", CreateRootCredential = "create-root-credential", @@ -294,7 +285,7 @@ export type ProjectPermissionSet = | [ProjectPermissionActions, ProjectPermissionSub.IpAllowList] | [ProjectPermissionActions, ProjectPermissionSub.Settings] | [ProjectPermissionActions, ProjectPermissionSub.ServiceTokens] - | [ProjectPermissionApprovalActions, ProjectPermissionSub.SecretApproval] + | [ProjectPermissionActions, ProjectPermissionSub.SecretApproval] | [ ProjectPermissionIdentityActions, ( diff --git a/frontend/src/context/index.tsx b/frontend/src/context/index.tsx index 8b64ee58a..91fcd9055 100644 --- a/frontend/src/context/index.tsx +++ b/frontend/src/context/index.tsx @@ -10,7 +10,6 @@ export { export type { TProjectPermission } from "./ProjectPermissionContext"; export { ProjectPermissionActions, - ProjectPermissionApprovalActions, ProjectPermissionCertificateActions, ProjectPermissionCmekActions, ProjectPermissionDynamicSecretActions, diff --git a/frontend/src/hooks/api/accessApproval/mutation.tsx b/frontend/src/hooks/api/accessApproval/mutation.tsx index c0da7af23..e4224e110 100644 --- a/frontend/src/hooks/api/accessApproval/mutation.tsx +++ b/frontend/src/hooks/api/accessApproval/mutation.tsx @@ -21,6 +21,7 @@ export const useCreateAccessApprovalPolicy = () => { projectSlug, approvals, approvers, + bypassers, name, secretPath, enforcementLevel, @@ -30,6 +31,7 @@ export const useCreateAccessApprovalPolicy = () => { environment, projectSlug, approvals, + bypassers, approvers, secretPath, name, @@ -53,6 +55,7 @@ export const useUpdateAccessApprovalPolicy = () => { mutationFn: async ({ id, approvers, + bypassers, approvals, name, secretPath, @@ -62,6 +65,7 @@ export const useUpdateAccessApprovalPolicy = () => { const { data } = await apiRequest.patch(`/api/v1/access-approvals/policies/${id}`, { approvals, approvers, + bypassers, secretPath, name, enforcementLevel, diff --git a/frontend/src/hooks/api/accessApproval/types.ts b/frontend/src/hooks/api/accessApproval/types.ts index e2d620fe2..54a53b2ab 100644 --- a/frontend/src/hooks/api/accessApproval/types.ts +++ b/frontend/src/hooks/api/accessApproval/types.ts @@ -16,6 +16,7 @@ export type TAccessApprovalPolicy = { enforcementLevel: EnforcementLevel; updatedAt: Date; approvers?: Approver[]; + bypassers?: Bypasser[]; allowedSelfApprovals: boolean; }; @@ -24,11 +25,21 @@ export enum ApproverType { Group = "group" } +export enum BypasserType { + User = "user", + Group = "group" +} + export type Approver = { id: string; type: ApproverType; }; +export type Bypasser = { + id: string; + type: BypasserType; +}; + export type TAccessApprovalRequest = { id: string; policyId: string; @@ -68,6 +79,7 @@ export type TAccessApprovalRequest = { name: string; approvals: number; approvers: string[]; + bypassers: string[]; secretPath?: string | null; envId: string; enforcementLevel: EnforcementLevel; @@ -146,6 +158,7 @@ export type TCreateAccessPolicyDTO = { name?: string; environment: string; approvers?: Approver[]; + bypassers?: Bypasser[]; approvals?: number; secretPath?: string; enforcementLevel?: EnforcementLevel; @@ -156,6 +169,7 @@ export type TUpdateAccessPolicyDTO = { id: string; name?: string; approvers?: Approver[]; + bypassers?: Bypasser[]; secretPath?: string; environment?: string; approvals?: number; diff --git a/frontend/src/hooks/api/secretApproval/mutation.tsx b/frontend/src/hooks/api/secretApproval/mutation.tsx index f9aca5bee..e2d566e25 100644 --- a/frontend/src/hooks/api/secretApproval/mutation.tsx +++ b/frontend/src/hooks/api/secretApproval/mutation.tsx @@ -14,6 +14,7 @@ export const useCreateSecretApprovalPolicy = () => { workspaceId, approvals, approvers, + bypassers, secretPath, name, enforcementLevel, @@ -24,6 +25,7 @@ export const useCreateSecretApprovalPolicy = () => { workspaceId, approvals, approvers, + bypassers, secretPath, name, enforcementLevel, @@ -46,6 +48,7 @@ export const useUpdateSecretApprovalPolicy = () => { mutationFn: async ({ id, approvers, + bypassers, approvals, secretPath, name, @@ -55,6 +58,7 @@ export const useUpdateSecretApprovalPolicy = () => { const { data } = await apiRequest.patch(`/api/v1/secret-approvals/${id}`, { approvals, approvers, + bypassers, secretPath, name, enforcementLevel, diff --git a/frontend/src/hooks/api/secretApproval/types.ts b/frontend/src/hooks/api/secretApproval/types.ts index 0a4b0d495..15afcf119 100644 --- a/frontend/src/hooks/api/secretApproval/types.ts +++ b/frontend/src/hooks/api/secretApproval/types.ts @@ -25,6 +25,16 @@ export type Approver = { type: ApproverType; }; +export enum BypasserType { + User = "user", + Group = "group" +} + +export type Bypasser = { + id: string; + type: BypasserType; +}; + export type TGetSecretApprovalPoliciesDTO = { workspaceId: string; }; @@ -41,6 +51,7 @@ export type TCreateSecretPolicyDTO = { environment: string; secretPath?: string | null; approvers?: Approver[]; + bypassers?: Bypasser[]; approvals?: number; enforcementLevel: EnforcementLevel; allowedSelfApprovals: boolean; @@ -50,6 +61,7 @@ export type TUpdateSecretPolicyDTO = { id: string; name?: string; approvers?: Approver[]; + bypassers?: Bypasser[]; secretPath?: string | null; approvals?: number; allowedSelfApprovals?: boolean; diff --git a/frontend/src/hooks/api/secretApprovalRequest/types.ts b/frontend/src/hooks/api/secretApprovalRequest/types.ts index f9684ee09..3ac2574b5 100644 --- a/frontend/src/hooks/api/secretApprovalRequest/types.ts +++ b/frontend/src/hooks/api/secretApprovalRequest/types.ts @@ -57,7 +57,7 @@ export type TSecretApprovalRequest = { secretPath: string; hasMerged: boolean; status: "open" | "close"; - policy: Omit & { + policy: Omit & { approvers: { userId: string; email: string; @@ -65,6 +65,13 @@ export type TSecretApprovalRequest = { lastName: string; username: string; }[]; + bypassers: { + userId: string; + email: string; + firstName: string; + lastName: string; + username: string; + }[]; }; statusChangedByUserId: string; statusChangedByUser?: { diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx index b2429fdfc..a54c844ad 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx @@ -12,7 +12,6 @@ import { } from "@app/context"; import { PermissionConditionOperators, - ProjectPermissionApprovalActions, ProjectPermissionDynamicSecretActions, ProjectPermissionGroupActions, ProjectPermissionIdentityActions, @@ -54,12 +53,10 @@ const SecretPolicyActionSchema = z.object({ }); const ApprovalPolicyActionSchema = z.object({ - [ProjectPermissionApprovalActions.Read]: z.boolean().optional(), - [ProjectPermissionApprovalActions.Edit]: z.boolean().optional(), - [ProjectPermissionApprovalActions.Delete]: z.boolean().optional(), - [ProjectPermissionApprovalActions.Create]: z.boolean().optional(), - [ProjectPermissionApprovalActions.AllowChangeBypass]: z.boolean().optional(), - [ProjectPermissionApprovalActions.AllowAccessBypass]: z.boolean().optional() + [ProjectPermissionActions.Read]: z.boolean().optional(), + [ProjectPermissionActions.Edit]: z.boolean().optional(), + [ProjectPermissionActions.Delete]: z.boolean().optional(), + [ProjectPermissionActions.Create]: z.boolean().optional() }); const CmekPolicyActionSchema = z.object({ @@ -574,24 +571,18 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { } if (subject === ProjectPermissionSub.SecretApproval) { - const canCreate = action.includes(ProjectPermissionApprovalActions.Create); - const canDelete = action.includes(ProjectPermissionApprovalActions.Delete); - const canEdit = action.includes(ProjectPermissionApprovalActions.Edit); - const canRead = action.includes(ProjectPermissionApprovalActions.Read); - const canChangeBypass = action.includes(ProjectPermissionApprovalActions.AllowChangeBypass); - const canAccessBypass = action.includes(ProjectPermissionApprovalActions.AllowAccessBypass); + const canCreate = action.includes(ProjectPermissionActions.Create); + const canDelete = action.includes(ProjectPermissionActions.Delete); + const canEdit = action.includes(ProjectPermissionActions.Edit); + const canRead = action.includes(ProjectPermissionActions.Read); if (!formVal[subject]) formVal[subject] = [{}]; // Map actions to the keys defined in ApprovalPolicyActionSchema - if (canCreate) formVal[subject]![0][ProjectPermissionApprovalActions.Create] = true; - if (canDelete) formVal[subject]![0][ProjectPermissionApprovalActions.Delete] = true; - if (canEdit) formVal[subject]![0][ProjectPermissionApprovalActions.Edit] = true; - if (canRead) formVal[subject]![0][ProjectPermissionApprovalActions.Read] = true; - if (canChangeBypass) - formVal[subject]![0][ProjectPermissionApprovalActions.AllowChangeBypass] = true; - if (canAccessBypass) - formVal[subject]![0][ProjectPermissionApprovalActions.AllowAccessBypass] = true; + if (canCreate) formVal[subject]![0][ProjectPermissionActions.Create] = true; + if (canDelete) formVal[subject]![0][ProjectPermissionActions.Delete] = true; + if (canEdit) formVal[subject]![0][ProjectPermissionActions.Edit] = true; + if (canRead) formVal[subject]![0][ProjectPermissionActions.Read] = true; return; } @@ -1212,12 +1203,10 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = { [ProjectPermissionSub.SecretApproval]: { title: "Secret Approval Policies", actions: [ - { label: "Read", value: ProjectPermissionApprovalActions.Read }, - { label: "Create", value: ProjectPermissionApprovalActions.Create }, - { label: "Modify", value: ProjectPermissionApprovalActions.Edit }, - { label: "Remove", value: ProjectPermissionApprovalActions.Delete }, - { label: "Allow Change Bypass", value: ProjectPermissionApprovalActions.AllowChangeBypass }, - { label: "Allow Access Bypass", value: ProjectPermissionApprovalActions.AllowAccessBypass } + { label: "Read", value: ProjectPermissionActions.Read }, + { label: "Create", value: ProjectPermissionActions.Create }, + { label: "Modify", value: ProjectPermissionActions.Edit }, + { label: "Remove", value: ProjectPermissionActions.Delete } ] }, [ProjectPermissionSub.SecretRotation]: { @@ -1694,7 +1683,7 @@ export const RoleTemplates: Record = { }, { subject: ProjectPermissionSub.SecretApproval, - actions: Object.values(ProjectPermissionApprovalActions) + actions: Object.values(ProjectPermissionActions) }, { subject: ProjectPermissionSub.ServiceTokens, diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx index 7d5e1540a..ee7f93d87 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx @@ -25,7 +25,6 @@ import { } from "@app/components/v2"; import { Badge } from "@app/components/v2/Badge"; import { - ProjectPermissionApprovalActions, ProjectPermissionMemberActions, ProjectPermissionSub, useProjectPermission, @@ -102,11 +101,6 @@ export const AccessApprovalRequest = ({ const { subscription } = useSubscription(); const { currentWorkspace } = useWorkspace(); - const canBypassApprovalPermission = permission.can( - ProjectPermissionApprovalActions.AllowAccessBypass, - ProjectPermissionSub.SecretApproval - ); - const { data: members } = useGetWorkspaceUsers(projectId, true); const membersGroupById = members?.reduce>( (prev, curr) => ({ ...prev, [curr.user.id]: curr }), @@ -163,6 +157,8 @@ export const AccessApprovalRequest = ({ const isRequestedByCurrentUser = request.requestedByUserId === user.id; const isSelfApproveAllowed = request.policy.allowedSelfApprovals; const userReviewStatus = request.reviewers.find(({ member }) => member === user.id)?.status; + const canBypass = + !request.policy.bypassers.length || request.policy.bypassers.includes(user.id); let displayData: { label: string; type: "primary" | "danger" | "success" } = { label: "", @@ -198,6 +194,7 @@ export const AccessApprovalRequest = ({ userReviewStatus, isAccepted, isSoftEnforcement, + canBypass, isRequestedByCurrentUser, isSelfApproveAllowed }; @@ -215,9 +212,7 @@ export const AccessApprovalRequest = ({ // Whether the current user can bypass policy const canBypass = - details.isSoftEnforcement && - details.isRequestedByCurrentUser && - canBypassApprovalPermission; + details.isSoftEnforcement && details.isRequestedByCurrentUser && details.canBypass; // Whether the current user can approve const canApprove = @@ -240,14 +235,7 @@ export const AccessApprovalRequest = ({ handlePopUpOpen("reviewRequest"); }, - [ - generateRequestDetails, - canBypassApprovalPermission, - membersGroupById, - user, - setSelectedRequest, - handlePopUpOpen - ] + [generateRequestDetails, membersGroupById, user, setSelectedRequest, handlePopUpOpen] ); return ( @@ -471,7 +459,7 @@ export const AccessApprovalRequest = ({ setSelectedRequest(null); refetchRequests(); }} - canBypassApprovalPermission={canBypassApprovalPermission} + canBypass={generateRequestDetails(selectedRequest).canBypass} /> )} diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx index 0b9f84008..0d15e016f 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx @@ -19,7 +19,7 @@ export const ReviewAccessRequestModal = ({ projectSlug, selectedRequester, selectedEnvSlug, - canBypassApprovalPermission + canBypass }: { isOpen: boolean; onOpenChange: (isOpen: boolean) => void; @@ -32,7 +32,7 @@ export const ReviewAccessRequestModal = ({ projectSlug: string; selectedRequester: string | undefined; selectedEnvSlug: string | undefined; - canBypassApprovalPermission: boolean; + canBypass: boolean; }) => { const [isLoading, setIsLoading] = useState<"approved" | "rejected" | null>(null); const [bypassApproval, setBypassApproval] = useState(false); @@ -208,7 +208,7 @@ export const ReviewAccessRequestModal = ({ {isSoftEnforcement && request.isRequestedByCurrentUser && !(request.isApprover && request.isSelfApproveAllowed) && - canBypassApprovalPermission && ( + canBypass && (
setBypassApproval(checked === true)} 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 05576eb59..ee837adbb 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx @@ -35,7 +35,7 @@ import { useSubscription, useWorkspace } from "@app/context"; -import { ProjectPermissionApprovalActions } from "@app/context/ProjectPermissionContext/types"; +import { ProjectPermissionActions } from "@app/context/ProjectPermissionContext/types"; import { usePopUp } from "@app/hooks"; import { useDeleteAccessApprovalPolicy, @@ -61,10 +61,8 @@ const useApprovalPolicies = (permission: TProjectPermission, currentWorkspace?: projectSlug: currentWorkspace?.slug as string, options: { enabled: - permission.can( - ProjectPermissionApprovalActions.Read, - ProjectPermissionSub.SecretApproval - ) && !!currentWorkspace?.slug + permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval) && + !!currentWorkspace?.slug } } ); @@ -73,10 +71,8 @@ const useApprovalPolicies = (permission: TProjectPermission, currentWorkspace?: workspaceId: currentWorkspace?.id as string, options: { enabled: - permission.can( - ProjectPermissionApprovalActions.Read, - ProjectPermissionSub.SecretApproval - ) && !!currentWorkspace?.id + permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval) && + !!currentWorkspace?.id } } ); @@ -164,7 +160,7 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
{(isAllowed) => ( 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 a48c158f2..c83a13a1b 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 @@ -27,7 +27,11 @@ import { useCreateAccessApprovalPolicy, useUpdateAccessApprovalPolicy } from "@app/hooks/api/accessApproval"; -import { ApproverType, TAccessApprovalPolicy } from "@app/hooks/api/accessApproval/types"; +import { + ApproverType, + BypasserType, + TAccessApprovalPolicy +} from "@app/hooks/api/accessApproval/types"; import { EnforcementLevel, PolicyType } from "@app/hooks/api/policies/enums"; import { TWorkspaceUser } from "@app/hooks/api/users/types"; @@ -54,6 +58,14 @@ const formSchema = z .object({ type: z.literal(ApproverType.Group), id: z.string() }) .array() .default([]), + userBypassers: z + .object({ type: z.literal(BypasserType.User), id: z.string() }) + .array() + .default([]), + groupBypassers: z + .object({ type: z.literal(BypasserType.Group), id: z.string() }) + .array() + .default([]), policyType: z.nativeEnum(PolicyType), enforcementLevel: z.nativeEnum(EnforcementLevel), allowedSelfApprovals: z.boolean().default(true) @@ -103,6 +115,14 @@ export const AccessPolicyForm = ({ editValues?.approvers ?.filter((approver) => approver.type === ApproverType.Group) .map(({ id, type }) => ({ id, type: type as ApproverType.Group })) || [], + userBypassers: + editValues?.bypassers + ?.filter((bypasser) => bypasser.type === BypasserType.User) + .map(({ id, type }) => ({ id, type: type as BypasserType.User })) || [], + groupBypassers: + editValues?.bypassers + ?.filter((bypasser) => bypasser.type === BypasserType.Group) + .map(({ id, type }) => ({ id, type: type as BypasserType.Group })) || [], approvals: editValues?.approvals, allowedSelfApprovals: editValues?.allowedSelfApprovals } @@ -125,20 +145,30 @@ export const AccessPolicyForm = ({ const { mutateAsync: updateSecretApprovalPolicy } = useUpdateSecretApprovalPolicy(); const policyName = policyDetails[watch("policyType")]?.name || "Policy"; + const enforcementLevel = watch("enforcementLevel"); + + const formUserBypassers = watch("userBypassers"); + const formGroupBypassers = watch("groupBypassers"); + const bypasserCount = (formUserBypassers || []).length + (formGroupBypassers || []).length; const handleCreatePolicy = async ({ environment, groupApprovers, userApprovers, + groupBypassers, + userBypassers, ...data }: TFormSchema) => { if (!projectId) return; try { + const bypassers = [...userBypassers, ...groupBypassers]; + if (data.policyType === PolicyType.ChangePolicy) { await createSecretApprovalPolicy({ ...data, approvers: [...userApprovers, ...groupApprovers], + bypassers: bypassers.length > 0 ? bypassers : undefined, environment: environment.slug, workspaceId: currentWorkspace?.id || "" }); @@ -146,6 +176,7 @@ export const AccessPolicyForm = ({ await createAccessApprovalPolicy({ ...data, approvers: [...userApprovers, ...groupApprovers], + bypassers: bypassers.length > 0 ? bypassers : undefined, environment: environment.slug, projectSlug }); @@ -168,17 +199,22 @@ export const AccessPolicyForm = ({ environment, userApprovers, groupApprovers, + userBypassers, + groupBypassers, ...data }: TFormSchema) => { if (!projectId || !projectSlug) return; if (!editValues?.id) return; try { + const bypassers = [...userBypassers, ...groupBypassers]; + if (data.policyType === PolicyType.ChangePolicy) { await updateSecretApprovalPolicy({ id: editValues?.id, ...data, approvers: [...userApprovers, ...groupApprovers], + bypassers: bypassers.length > 0 ? bypassers : undefined, workspaceId: currentWorkspace?.id || "" }); } else { @@ -186,6 +222,7 @@ export const AccessPolicyForm = ({ id: editValues?.id, ...data, approvers: [...userApprovers, ...groupApprovers], + bypassers: bypassers.length > 0 ? bypassers : undefined, environment: environment.slug, projectSlug }); @@ -230,6 +267,24 @@ export const AccessPolicyForm = ({ [groups] ); + const bypasserMemberOptions = useMemo( + () => + members.map((member) => ({ + id: member.user.id, + type: BypasserType.User + })), + [members] + ); + + const bypasserGroupOptions = useMemo( + () => + groups?.map(({ group }) => ({ + id: group.id, + type: BypasserType.Group + })), + [groups] + ); + return (
- ( - - option.id} - getOptionLabel={(option) => { - const member = members?.find((m) => m.user.id === option.id); +
+ ( + + option.id} + getOptionLabel={(option) => { + const member = members?.find((m) => m.user.id === option.id); - if (!member) return option.id; + if (!member) return option.id; - return getMemberLabel(member); - }} - value={value} - onChange={onChange} - /> - - )} - /> - ( - - option.id} - getOptionLabel={(option) => - groups?.find(({ group }) => group.id === option.id)?.group.name ?? option.id - } - value={value} - onChange={onChange} - /> - - )} - /> + return getMemberLabel(member); + }} + value={value} + onChange={onChange} + /> + + )} + /> + ( + + option.id} + getOptionLabel={(option) => + groups?.find(({ group }) => group.id === option.id)?.group.name ?? option.id + } + value={value} + onChange={onChange} + /> + + )} + /> +
- Allow request creators to bypass policy in break-glass situations + Allow certain users to bypass policy in break-glass situations
)} /> + {enforcementLevel === EnforcementLevel.Soft && ( + <> +
+ ( + + option.id} + getOptionLabel={(option) => { + const member = members?.find((m) => m.user.id === option.id); + + if (!member) return option.id; + + return getMemberLabel(member); + }} + value={value} + onChange={onChange} + /> + + )} + /> + ( + + option.id} + getOptionLabel={(option) => + groups?.find(({ group }) => group.id === option.id)?.group.name ?? + option.id + } + value={value} + onChange={onChange} + /> + + )} + /> +
+ + {bypasserCount <= 0 && ( + + Not selecting specific users or groups will allow anyone to bypass this policy + + )} + + )}
-
- {isSoftEnforcement && !isMergable && canBypassApprovalPermission && ( + {isSoftEnforcement && !isMergable && isBypasser && ( +
setByPassApproval(checked === true)} @@ -169,8 +160,8 @@ export const SecretApprovalRequestAction = ({ )}
- )} -
+
+ )}
{canApprove || isSoftEnforcement ? (
diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx index 17046450e..218365ae9 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/SecretApprovalRequest/components/SecretApprovalRequestChanges.tsx @@ -139,6 +139,11 @@ export const SecretApprovalRequestChanges = ({ ({ userId }) => userId === userSession.id ); + const isBypasser = + !secretApprovalRequestDetails?.policy?.bypassers || + !secretApprovalRequestDetails.policy.bypassers.length || + secretApprovalRequestDetails.policy.bypassers.some(({ userId }) => userId === userSession.id); + const reviewedUsers = secretApprovalRequestDetails?.reviewers?.reduce< Record >( @@ -414,6 +419,7 @@ export const SecretApprovalRequestChanges = ({