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..2553a0efc 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"; @@ -24,10 +24,19 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi approvers: z .discriminatedUnion("type", [ z.object({ type: z.literal(ApproverType.Group), id: z.string() }), - z.object({ type: z.literal(ApproverType.User), id: z.string().optional(), name: z.string().optional() }) + z.object({ type: z.literal(ApproverType.User), id: z.string().optional(), username: z.string().optional() }) ]) .array() + .max(100, "Cannot have more than 100 approvers") .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(), username: z.string().optional() }) + ]) + .array() + .max(100, "Cannot have more than 100 bypassers") + .optional(), approvals: z.number().min(1).default(1), enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard), allowedSelfApprovals: z.boolean().default(true) @@ -72,7 +81,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() @@ -143,10 +153,19 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi approvers: z .discriminatedUnion("type", [ z.object({ type: z.literal(ApproverType.Group), id: z.string() }), - z.object({ type: z.literal(ApproverType.User), id: z.string().optional(), name: z.string().optional() }) + z.object({ type: z.literal(ApproverType.User), id: z.string().optional(), username: z.string().optional() }) ]) .array() - .min(1, { message: "At least one approver should be provided" }), + .min(1, { message: "At least one approver should be provided" }) + .max(100, "Cannot have more than 100 approvers"), + bypassers: z + .discriminatedUnion("type", [ + z.object({ type: z.literal(BypasserType.Group), id: z.string() }), + z.object({ type: z.literal(BypasserType.User), id: z.string().optional(), username: z.string().optional() }) + ]) + .array() + .max(100, "Cannot have more than 100 bypassers") + .optional(), approvals: z.number().min(1).optional(), enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard), allowedSelfApprovals: z.boolean().default(true) @@ -220,6 +239,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..ebe1345b3 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"; @@ -30,10 +30,19 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi approvers: z .discriminatedUnion("type", [ z.object({ type: z.literal(ApproverType.Group), id: z.string() }), - z.object({ type: z.literal(ApproverType.User), id: z.string().optional(), name: z.string().optional() }) + z.object({ type: z.literal(ApproverType.User), id: z.string().optional(), username: z.string().optional() }) ]) .array() - .min(1, { message: "At least one approver should be provided" }), + .min(1, { message: "At least one approver should be provided" }) + .max(100, "Cannot have more than 100 approvers"), + bypassers: z + .discriminatedUnion("type", [ + z.object({ type: z.literal(BypasserType.Group), id: z.string() }), + z.object({ type: z.literal(BypasserType.User), id: z.string().optional(), username: z.string().optional() }) + ]) + .array() + .max(100, "Cannot have more than 100 bypassers") + .optional(), approvals: z.number().min(1).default(1), enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard), allowedSelfApprovals: z.boolean().default(true) @@ -75,10 +84,19 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi approvers: z .discriminatedUnion("type", [ z.object({ type: z.literal(ApproverType.Group), id: z.string() }), - z.object({ type: z.literal(ApproverType.User), id: z.string().optional(), name: z.string().optional() }) + z.object({ type: z.literal(ApproverType.User), id: z.string().optional(), username: z.string().optional() }) ]) .array() - .min(1, { message: "At least one approver should be provided" }), + .min(1, { message: "At least one approver should be provided" }) + .max(100, "Cannot have more than 100 approvers"), + bypassers: z + .discriminatedUnion("type", [ + z.object({ type: z.literal(BypasserType.Group), id: z.string() }), + z.object({ type: z.literal(BypasserType.User), id: z.string().optional(), username: z.string().optional() }) + ]) + .array() + .max(100, "Cannot have more than 100 bypassers") + .optional(), approvals: z.number().min(1).default(1), secretPath: z .string() @@ -157,6 +175,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() @@ -193,7 +217,14 @@ export const registerSecretApprovalPolicyRouter = async (server: FastifyZodProvi .object({ id: z.string().nullable().optional(), type: z.nativeEnum(ApproverType), - name: z.string().nullable().optional() + username: z.string().nullable().optional() + }) + .array(), + bypassers: z + .object({ + id: z.string().nullable().optional(), + type: z.nativeEnum(BypasserType), + username: 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 8b13ce4d4..c61d209c3 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..71d15ce1c 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,8 +2,9 @@ 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 { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal"; import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; @@ -14,10 +15,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,12 +37,14 @@ type TAccessApprovalPolicyServiceFactoryDep = { accessApprovalPolicyDAL: TAccessApprovalPolicyDALFactory; projectEnvDAL: Pick; accessApprovalPolicyApproverDAL: TAccessApprovalPolicyApproverDALFactory; + accessApprovalPolicyBypasserDAL: TAccessApprovalPolicyBypasserDALFactory; projectMembershipDAL: Pick; groupDAL: TGroupDALFactory; userDAL: Pick; accessApprovalRequestDAL: Pick; additionalPrivilegeDAL: Pick; accessApprovalRequestReviewerDAL: Pick; + orgMembershipDAL: Pick; }; export type TAccessApprovalPolicyServiceFactory = ReturnType; @@ -45,6 +52,7 @@ export type TAccessApprovalPolicyServiceFactory = ReturnType { const createAccessApprovalPolicy = async ({ name, @@ -63,6 +72,7 @@ export const accessApprovalPolicyServiceFactory = ({ actorAuthMethod, approvals, approvers, + bypassers, projectSlug, environment, enforcementLevel, @@ -82,7 +92,7 @@ export const accessApprovalPolicyServiceFactory = ({ .filter(Boolean) as string[]; const userApproverNames = approvers - .map((approver) => (approver.type === ApproverType.User ? approver.name : undefined)) + .map((approver) => (approver.type === ApproverType.User ? approver.username : undefined)) .filter(Boolean) as string[]; if (!groupApprovers && approvals > userApprovers.length + userApproverNames.length) @@ -98,7 +108,7 @@ export const accessApprovalPolicyServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionApprovalActions.Create, + ProjectPermissionActions.Create, ProjectPermissionSub.SecretApproval ); const env = await projectEnvDAL.findOne({ slug: environment, projectId: project.id }); @@ -147,6 +157,44 @@ export const accessApprovalPolicyServiceFactory = ({ .map((user) => 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.username : 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 +207,7 @@ export const accessApprovalPolicyServiceFactory = ({ }, tx ); + if (approverUserIds.length) { await accessApprovalPolicyApproverDAL.insertMany( approverUserIds.map((userId) => ({ @@ -179,8 +228,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 +281,7 @@ export const accessApprovalPolicyServiceFactory = ({ const updateAccessApprovalPolicy = async ({ policyId, approvers, + bypassers, secretPath, name, actorId, @@ -231,15 +302,15 @@ export const accessApprovalPolicyServiceFactory = ({ .filter(Boolean) as string[]; const userApproverNames = approvers - .map((approver) => (approver.type === ApproverType.User ? approver.name : undefined)) + .map((approver) => (approver.type === ApproverType.User ? approver.username : undefined)) .filter(Boolean) as string[]; const accessApprovalPolicy = await accessApprovalPolicyDAL.findById(policyId); - const currentAppovals = approvals || accessApprovalPolicy.approvals; + const currentApprovals = approvals || accessApprovalPolicy.approvals; if ( groupApprovers?.length === 0 && userApprovers && - currentAppovals > userApprovers.length + userApproverNames.length + currentApprovals > userApprovers.length + userApproverNames.length ) { throw new BadRequestError({ message: "Approvals cannot be greater than approvers" }); } @@ -256,10 +327,79 @@ 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[]; + + groupBypassers = [...new Set(groupBypassers)]; + + 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.username : 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 = [...new Set(bypasserUserIds.concat(bypasserUsers.map((user) => user.id)))]; + } + + // Validate user bypassers + if (bypasserUserIds.length > 0) { + const orgMemberships = await orgMembershipDAL.find({ + $in: { userId: bypasserUserIds }, + orgId: actorOrgId + }); + + if (orgMemberships.length !== bypasserUserIds.length) { + const foundUserIdsInOrg = new Set(orgMemberships.map((mem) => mem.userId)); + const missingUserIds = bypasserUserIds.filter((id) => !foundUserIdsInOrg.has(id)); + throw new BadRequestError({ + message: `One or more specified bypasser users are not part of the organization or do not exist. Invalid or non-member user IDs: ${missingUserIds.join(", ")}` + }); + } + } + + // Validate group bypassers + if (groupBypassers.length > 0) { + const orgGroups = await groupDAL.find({ + $in: { id: groupBypassers }, + orgId: actorOrgId + }); + + if (orgGroups.length !== groupBypassers.length) { + const foundGroupIdsInOrg = new Set(orgGroups.map((group) => group.id)); + const missingGroupIds = groupBypassers.filter((id) => !foundGroupIdsInOrg.has(id)); + throw new BadRequestError({ + message: `One or more specified bypasser groups are not part of the organization or do not exist. Invalid or non-member group IDs: ${missingGroupIds.join(", ")}` + }); + } + } + } const updatedPolicy = await accessApprovalPolicyDAL.transaction(async (tx) => { const doc = await accessApprovalPolicyDAL.updateById( @@ -316,6 +456,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 +506,7 @@ export const accessApprovalPolicyServiceFactory = ({ actionProjectType: ActionProjectType.SecretManager }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionApprovalActions.Delete, + ProjectPermissionActions.Delete, ProjectPermissionSub.SecretApproval ); @@ -435,10 +597,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..cef7f68f4 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,20 @@ 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 })[]; + approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; username?: string })[]; + bypassers?: ( + | { type: BypasserType.Group; id: string } + | { type: BypasserType.User; id?: string; username?: string } + )[]; projectSlug: string; name: string; enforcementLevel: EnforcementLevel; @@ -32,7 +41,11 @@ export type TCreateAccessApprovalPolicy = { export type TUpdateAccessApprovalPolicy = { policyId: string; approvals?: number; - approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; name?: string })[]; + approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; username?: string })[]; + bypassers?: ( + | { type: BypasserType.Group; id: string } + | { type: BypasserType.User; id?: string; username?: 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 6d20369cf..08d91cc21 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,13 @@ 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 user is (not an approver OR cant self approve) AND can't bypass policy + 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 +369,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..fd8be93cf 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"), @@ -143,7 +179,7 @@ export const secretApprovalPolicyDALFactory = (db: TDbClient) => { label: "approvers" as const, mapper: ({ approverUserId: id, approverUsername }) => ({ type: ApproverType.User, - name: approverUsername, + username: approverUsername, id }) }, @@ -155,6 +191,23 @@ export const secretApprovalPolicyDALFactory = (db: TDbClient) => { id }) }, + { + key: "bypasserUserId", + label: "bypassers" as const, + mapper: ({ bypasserUserId: id, bypasserUsername }) => ({ + type: BypasserType.User, + username: 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..696caf311 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, @@ -74,7 +80,7 @@ export const secretApprovalPolicyServiceFactory = ({ .filter(Boolean) as string[]; const userApproverNames = approvers - .map((approver) => (approver.type === ApproverType.User ? approver.name : undefined)) + .map((approver) => (approver.type === ApproverType.User ? approver.username : undefined)) .filter(Boolean) as string[]; if (!groupApprovers.length && approvals > approvers.length) @@ -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.username : 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, @@ -186,7 +252,7 @@ export const secretApprovalPolicyServiceFactory = ({ .filter(Boolean) as string[]; const userApproverNames = approvers - .map((approver) => (approver.type === ApproverType.User ? approver.name : undefined)) + .map((approver) => (approver.type === ApproverType.User ? approver.username : undefined)) .filter(Boolean) as string[]; const secretApprovalPolicy = await secretApprovalPolicyDAL.findById(secretPolicyId); @@ -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.username : 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..ed074336c 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,16 @@ 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 })[]; + approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; username?: string })[]; + bypassers?: ( + | { type: BypasserType.Group; id: string } + | { type: BypasserType.User; id?: string; username?: string } + )[]; projectId: string; name: string; enforcementLevel: EnforcementLevel; @@ -17,7 +21,11 @@ export type TUpdateSapDTO = { secretPolicyId: string; approvals?: number; secretPath?: string | null; - approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; name?: string })[]; + approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; username?: string })[]; + bypassers?: ( + | { type: BypasserType.Group; id: string } + | { type: BypasserType.User; id?: string; username?: 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..2b788bb2b 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, @@ -1226,7 +1236,8 @@ export const registerRoutes = async ( userDAL, accessApprovalRequestDAL, additionalPrivilegeDAL: projectUserAdditionalPrivilegeDAL, - accessApprovalRequestReviewerDAL + accessApprovalRequestReviewerDAL, + orgMembershipDAL }); const accessApprovalRequestService = accessApprovalRequestServiceFactory({ diff --git a/docs/documentation/platform/pr-workflows.mdx b/docs/documentation/platform/pr-workflows.mdx index 610d1fd47..c248c4dfa 100644 --- a/docs/documentation/platform/pr-workflows.mdx +++ b/docs/documentation/platform/pr-workflows.mdx @@ -37,6 +37,10 @@ The enforcement level determines how strict the policy is. A **Hard** enforcemen Enabling the "Bypass Approvals" toggle during policy creation will create a **Soft** enforcement level. Disabling the toggle makes the enforcement level **Hard**. +If you choose to allow approval bypasses (Soft Enforcement), you may select specific users or groups that can perform the bypass for that specific policy. Not choosing users or groups will allow anyone to bypass the policy. + +A policy bypasser cannot bypass requests from others; the bypass action can only be performed by the request creator. + ### Self approvals If the **Self Approvals** option is enabled, users who are designated as approvers on the policy can approve requests that they themselves have submitted. 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 c19c990dd..3641e8a3c 100644 --- a/frontend/src/context/index.tsx +++ b/frontend/src/context/index.tsx @@ -11,7 +11,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/organization/BillingPage/components/BillingCloudTab/PreviewSection.tsx b/frontend/src/pages/organization/BillingPage/components/BillingCloudTab/PreviewSection.tsx index 98309edbd..eaa90450e 100644 --- a/frontend/src/pages/organization/BillingPage/components/BillingCloudTab/PreviewSection.tsx +++ b/frontend/src/pages/organization/BillingPage/components/BillingCloudTab/PreviewSection.tsx @@ -112,7 +112,10 @@ export const PreviewSection = () => { Get unlimited members, projects, RBAC, smart alerts, and so much more.

- + {(isAllowed) => (