feat: allow access approvals with user groups

This commit is contained in:
Meet
2024-09-20 01:24:30 +05:30
parent 5740d2b4e4
commit 009be0ded8
18 changed files with 457 additions and 33 deletions

View File

@@ -6,6 +6,7 @@ import {
TAccessApprovalPoliciesApprovers,
TAccessApprovalPoliciesApproversInsert,
TAccessApprovalPoliciesApproversUpdate,
TAccessApprovalPoliciesGroupApprovers,
TAccessApprovalPoliciesInsert,
TAccessApprovalPoliciesUpdate,
TAccessApprovalRequests,
@@ -800,5 +801,10 @@ declare module "knex/types/tables" {
TWorkflowIntegrationsInsert,
TWorkflowIntegrationsUpdate
>;
[TableName.AccessApprovalPolicyGroupApprover]: KnexOriginal.CompositeTableType<
TAccessApprovalPoliciesGroupApprovers,
TAccessApprovalPoliciesGroupApproversInsert,
TAccessApprovalPoliciesGroupApproversInsert
>;
}
}

View File

@@ -0,0 +1,22 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
import { createOnUpdateTrigger } from "../utils";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasTable(TableName.AccessApprovalPolicyGroupApprover))) {
await knex.schema.createTable(TableName.AccessApprovalPolicyGroupApprover, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.uuid("approverGroupId").notNullable();
t.foreign("approverGroupId").references("id").inTable(TableName.Groups).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.AccessApprovalPolicyGroupApprover);
}
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TableName.AccessApprovalPolicyGroupApprover);
}

View File

@@ -0,0 +1,25 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const AccessApprovalPoliciesGroupApproversSchema = z.object({
id: z.string().uuid(),
approverGroupId: z.string().uuid(),
policyId: z.string().uuid(),
createdAt: z.date(),
updatedAt: z.date()
});
export type TAccessApprovalPoliciesGroupApprovers = z.infer<typeof AccessApprovalPoliciesGroupApproversSchema>;
export type TAccessApprovalPoliciesGroupApproversInsert = Omit<
z.input<typeof AccessApprovalPoliciesGroupApproversSchema>,
TImmutableDBKeys
>;
export type TAccessApprovalPoliciesGroupApproversUpdate = Partial<
Omit<z.input<typeof AccessApprovalPoliciesGroupApproversSchema>, TImmutableDBKeys>
>;

View File

@@ -1,5 +1,6 @@
export * from "./access-approval-policies";
export * from "./access-approval-policies-approvers";
export * from "./access-approval-policies-group-approvers";
export * from "./access-approval-requests";
export * from "./access-approval-requests-reviewers";
export * from "./api-keys";

View File

@@ -73,6 +73,7 @@ export enum TableName {
ScimToken = "scim_tokens",
AccessApprovalPolicy = "access_approval_policies",
AccessApprovalPolicyApprover = "access_approval_policies_approvers",
AccessApprovalPolicyGroupApprover = "access_approval_policies_group_approvers",
AccessApprovalRequest = "access_approval_requests",
AccessApprovalRequestReviewer = "access_approval_requests_reviewers",
SecretApprovalPolicy = "secret_approval_policies",

View File

@@ -17,13 +17,14 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
name: z.string().optional(),
secretPath: z.string().trim().default("/"),
environment: z.string(),
approvers: z.string().array().min(1),
approvers: z.string().array().default([]),
groupApprovers: z.string().array().default([]),
approvals: z.number().min(1).default(1),
enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard)
})
.refine((data) => data.approvals <= data.approvers.length, {
path: ["approvals"],
message: "The number of approvals should be lower than the number of approvers."
.refine((data) => data.approvers.length > 0 || data.groupApprovers.length > 0, {
path: ["approvers", "groupApprovers"],
message: "At least one approver should be provided."
}),
response: {
200: z.object({
@@ -63,6 +64,11 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
userId: z.string()
})
.array(),
groupApprovers: z
.object({
groupId: z.string()
})
.array(),
secretPath: z.string().optional().nullable()
})
.array()
@@ -127,13 +133,14 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
.trim()
.optional()
.transform((val) => (val === "" ? "/" : val)),
approvers: z.string().array().min(1),
approvals: z.number().min(1).default(1),
approvers: z.string().array().optional().default([]),
approvals: z.number().min(1).optional(),
groupApprovers: z.string().array().optional().default([]),
enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard)
})
.refine((data) => data.approvals <= data.approvers.length, {
path: ["approvals"],
message: "The number of approvals should be lower than the number of approvers."
.refine((data) => data.approvers || data.groupApprovers, {
path: ["approvers", "groupApprovers"],
message: "At least one approver should be provided."
}),
response: {
200: z.object({

View File

@@ -20,7 +20,13 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => {
`${TableName.AccessApprovalPolicy}.id`,
`${TableName.AccessApprovalPolicyApprover}.policyId`
)
.leftJoin(
TableName.AccessApprovalPolicyGroupApprover,
`${TableName.AccessApprovalPolicy}.id`,
`${TableName.AccessApprovalPolicyGroupApprover}.policyId`
)
.select(tx.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover))
.select(tx.ref("approverGroupId").withSchema(TableName.AccessApprovalPolicyGroupApprover))
.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"))
@@ -54,6 +60,13 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => {
mapper: ({ approverUserId }) => ({
userId: approverUserId
})
},
{
key: "approverGroupId",
label: "groupApprovers" as const,
mapper: ({ approverGroupId }) => ({
groupId: approverGroupId
})
}
]
});
@@ -88,6 +101,13 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => {
mapper: ({ approverUserId }) => ({
userId: approverUserId
})
},
{
key: "approverGroupId",
label: "groupApprovers" as const,
mapper: ({ approverGroupId }) => ({
groupId: approverGroupId
})
}
]
});

View File

@@ -0,0 +1,12 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TAccessApprovalPolicyGroupApproverDALFactory = ReturnType<
typeof accessApprovalPolicyGroupApproverDALFactory
>;
export const accessApprovalPolicyGroupApproverDALFactory = (db: TDbClient) => {
const accessApprovalPolicyGroupApproverOrm = ormify(db, TableName.AccessApprovalPolicyGroupApprover);
return { ...accessApprovalPolicyGroupApproverOrm };
};

View File

@@ -3,13 +3,16 @@ import { ForbiddenError } from "@casl/ability";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { BadRequestError } from "@app/lib/errors";
import { logger } from "@app/lib/logger";
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";
import { TGroupDALFactory } from "../group/group-dal";
import { TAccessApprovalPolicyApproverDALFactory } from "./access-approval-policy-approver-dal";
import { TAccessApprovalPolicyDALFactory } from "./access-approval-policy-dal";
import { verifyApprovers } from "./access-approval-policy-fns";
import { TAccessApprovalPolicyGroupApproverDALFactory } from "./access-approval-policy-group-approver-dal";
import {
TCreateAccessApprovalPolicy,
TDeleteAccessApprovalPolicy,
@@ -25,13 +28,17 @@ type TSecretApprovalPolicyServiceFactoryDep = {
projectEnvDAL: Pick<TProjectEnvDALFactory, "find" | "findOne">;
accessApprovalPolicyApproverDAL: TAccessApprovalPolicyApproverDALFactory;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "find">;
groupDAL: TGroupDALFactory;
accessApprovalPolicyGroupApproverDAL: TAccessApprovalPolicyGroupApproverDALFactory;
};
export type TAccessApprovalPolicyServiceFactory = ReturnType<typeof accessApprovalPolicyServiceFactory>;
export const accessApprovalPolicyServiceFactory = ({
accessApprovalPolicyDAL,
accessApprovalPolicyGroupApproverDAL,
accessApprovalPolicyApproverDAL,
groupDAL,
permissionService,
projectEnvDAL,
projectDAL
@@ -45,6 +52,7 @@ export const accessApprovalPolicyServiceFactory = ({
actorAuthMethod,
approvals,
approvers,
groupApprovers,
projectSlug,
environment,
enforcementLevel
@@ -52,7 +60,11 @@ export const accessApprovalPolicyServiceFactory = ({
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
if (!project) throw new BadRequestError({ message: "Project not found" });
if (approvals > approvers.length)
if (!groupApprovers && !approvers)
throw new BadRequestError({ message: "Either of approvers or group approvers must be provided" });
// If there is a group approver people might be added to the group later to meet the approvers quota
if (!groupApprovers && approvals > approvers.length)
throw new BadRequestError({ message: "Approvals cannot be greater than approvers" });
const { permission } = await permissionService.getProjectPermission(
@@ -69,6 +81,35 @@ export const accessApprovalPolicyServiceFactory = ({
const env = await projectEnvDAL.findOne({ slug: environment, projectId: project.id });
if (!env) throw new BadRequestError({ message: "Environment not found" });
const verifyAllApprovers = approvers;
const usersPromises: Promise<
{
id: string;
email: string | null | undefined;
username: string;
firstName: string | null | undefined;
lastName: string | null | undefined;
isPartOfGroup: boolean;
}[]
>[] = [];
for (const groupId of groupApprovers) {
usersPromises.push(groupDAL.findAllGroupMembers({ orgId: actorOrgId, groupId, offset: 0 }));
}
const verifyGroupApprovers = (await Promise.all(usersPromises)).flat().map((user) => user.id);
verifyAllApprovers.push(...verifyGroupApprovers);
logger.info("verifyApproversCreate");
logger.info({
projectId: project.id,
orgId: actorOrgId,
envSlug: environment,
secretPath,
actorAuthMethod,
permissionService,
userIds: verifyAllApprovers
});
await verifyApprovers({
projectId: project.id,
orgId: actorOrgId,
@@ -76,7 +117,7 @@ export const accessApprovalPolicyServiceFactory = ({
secretPath,
actorAuthMethod,
permissionService,
userIds: approvers
userIds: verifyAllApprovers
});
const accessApproval = await accessApprovalPolicyDAL.transaction(async (tx) => {
@@ -97,6 +138,15 @@ export const accessApprovalPolicyServiceFactory = ({
})),
tx
);
await accessApprovalPolicyGroupApproverDAL.insertMany(
groupApprovers.map((groupId) => ({
approverGroupId: groupId,
policyId: doc.id
})),
tx
);
return doc;
});
return { ...accessApproval, environment: env, projectId: project.id };
@@ -129,6 +179,7 @@ export const accessApprovalPolicyServiceFactory = ({
const updateAccessApprovalPolicy = async ({
policyId,
approvers,
groupApprovers,
secretPath,
name,
actorId,
@@ -162,6 +213,16 @@ export const accessApprovalPolicyServiceFactory = ({
tx
);
if (approvers) {
logger.info("verifyApproversPatch");
logger.info({
projectId: accessApprovalPolicy.projectId,
orgId: actorOrgId,
envSlug: accessApprovalPolicy.environment.slug,
secretPath,
actorAuthMethod,
permissionService,
userIds: approvers
});
await verifyApprovers({
projectId: accessApprovalPolicy.projectId,
orgId: actorOrgId,
@@ -171,7 +232,6 @@ export const accessApprovalPolicyServiceFactory = ({
permissionService,
userIds: approvers
});
await accessApprovalPolicyApproverDAL.delete({ policyId: doc.id }, tx);
await accessApprovalPolicyApproverDAL.insertMany(
approvers.map((userId) => ({
@@ -181,6 +241,43 @@ export const accessApprovalPolicyServiceFactory = ({
tx
);
}
if (groupApprovers) {
const usersPromises: Promise<
{
id: string;
email: string | null | undefined;
username: string;
firstName: string | null | undefined;
lastName: string | null | undefined;
isPartOfGroup: boolean;
}[]
>[] = [];
for (const groupId of groupApprovers) {
usersPromises.push(groupDAL.findAllGroupMembers({ orgId: actorOrgId, groupId, offset: 0 }));
}
const verifyGroupApprovers = (await Promise.all(usersPromises)).flat().map((user) => user.id);
await verifyApprovers({
projectId: accessApprovalPolicy.projectId,
orgId: actorOrgId,
envSlug: accessApprovalPolicy.environment.slug,
secretPath: doc.secretPath!,
actorAuthMethod,
permissionService,
userIds: verifyGroupApprovers
});
await accessApprovalPolicyGroupApproverDAL.delete({ policyId: doc.id }, tx);
await accessApprovalPolicyGroupApproverDAL.insertMany(
groupApprovers.map((groupId) => ({
approverGroupId: groupId,
policyId: doc.id
})),
tx
);
}
return doc;
});
return {

View File

@@ -18,6 +18,7 @@ export type TCreateAccessApprovalPolicy = {
secretPath: string;
environment: string;
approvers: string[];
groupApprovers: string[];
projectSlug: string;
name: string;
enforcementLevel: EnforcementLevel;
@@ -27,6 +28,7 @@ export type TUpdateAccessApprovalPolicy = {
policyId: string;
approvals?: number;
approvers?: string[];
groupApprovers?: string[];
secretPath?: string;
name?: string;
enforcementLevel?: EnforcementLevel;

View File

@@ -39,6 +39,17 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
`${TableName.AccessApprovalPolicy}.id`,
`${TableName.AccessApprovalPolicyApprover}.policyId`
)
.leftJoin(
TableName.AccessApprovalPolicyGroupApprover,
`${TableName.AccessApprovalPolicy}.id`,
`${TableName.AccessApprovalPolicyGroupApprover}.policyId`
)
.leftJoin(
TableName.UserGroupMembership,
`${TableName.AccessApprovalPolicyGroupApprover}.approverGroupId`,
`${TableName.UserGroupMembership}.groupId`
)
.leftJoin(TableName.Users, `${TableName.UserGroupMembership}.userId`, `${TableName.Users}.id`)
.join<TUsers>(
db(TableName.Users).as("requestedByUser"),
@@ -59,6 +70,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
)
.select(db.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover))
.select(db.ref("userId").withSchema(TableName.UserGroupMembership).as("approverGroupUserId"))
.select(
db.ref("projectId").withSchema(TableName.Environment),
@@ -142,7 +154,12 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
label: "reviewers" as const,
mapper: ({ reviewerUserId: userId, reviewerStatus: status }) => (userId ? { userId, status } : undefined)
},
{ key: "approverUserId", label: "approvers" as const, mapper: ({ approverUserId }) => approverUserId }
{ key: "approverUserId", label: "approvers" as const, mapper: ({ approverUserId }) => approverUserId },
{
key: "approverGroupUserId",
label: "approvers" as const,
mapper: ({ approverGroupUserId }) => approverGroupUserId
}
]
});
@@ -172,18 +189,36 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
`requestedByUser.id`
)
.join(
.leftJoin(
TableName.AccessApprovalPolicyApprover,
`${TableName.AccessApprovalPolicy}.id`,
`${TableName.AccessApprovalPolicyApprover}.policyId`
)
.join<TUsers>(
.leftJoin<TUsers>(
db(TableName.Users).as("accessApprovalPolicyApproverUser"),
`${TableName.AccessApprovalPolicyApprover}.approverUserId`,
"accessApprovalPolicyApproverUser.id"
)
.leftJoin(
TableName.AccessApprovalPolicyGroupApprover,
`${TableName.AccessApprovalPolicy}.id`,
`${TableName.AccessApprovalPolicyGroupApprover}.policyId`
)
.leftJoin(
TableName.UserGroupMembership,
`${TableName.AccessApprovalPolicyGroupApprover}.approverGroupId`,
`${TableName.UserGroupMembership}.groupId`
)
.leftJoin<TUsers>(
db(TableName.Users).as("accessApprovalPolicyGroupApproverUser"),
`${TableName.UserGroupMembership}.userId`,
"accessApprovalPolicyGroupApproverUser.id"
)
.leftJoin(
TableName.AccessApprovalRequestReviewer,
`${TableName.AccessApprovalRequest}.id`,
@@ -200,10 +235,15 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
.select(selectAllTableCols(TableName.AccessApprovalRequest))
.select(
tx.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover),
tx.ref("userId").withSchema(TableName.UserGroupMembership),
tx.ref("email").withSchema("accessApprovalPolicyApproverUser").as("approverEmail"),
tx.ref("email").withSchema("accessApprovalPolicyGroupApproverUser").as("approverGroupEmail"),
tx.ref("username").withSchema("accessApprovalPolicyApproverUser").as("approverUsername"),
tx.ref("username").withSchema("accessApprovalPolicyGroupApproverUser").as("approverGroupUsername"),
tx.ref("firstName").withSchema("accessApprovalPolicyApproverUser").as("approverFirstName"),
tx.ref("firstName").withSchema("accessApprovalPolicyGroupApproverUser").as("approverGroupFirstName"),
tx.ref("lastName").withSchema("accessApprovalPolicyApproverUser").as("approverLastName"),
tx.ref("lastName").withSchema("accessApprovalPolicyGroupApproverUser").as("approverGroupLastName"),
tx.ref("email").withSchema("requestedByUser").as("requestedByUserEmail"),
tx.ref("username").withSchema("requestedByUser").as("requestedByUserUsername"),
tx.ref("firstName").withSchema("requestedByUser").as("requestedByUserFirstName"),
@@ -282,6 +322,23 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
lastName,
username
})
},
{
key: "userId",
label: "approvers" as const,
mapper: ({
userId,
approverGroupEmail: email,
approverGroupUsername: username,
approverGroupLastName: lastName,
approverFirstName: firstName
}) => ({
userId,
email,
firstName,
lastName,
username
})
}
]
});

View File

@@ -18,6 +18,8 @@ import { TUserDALFactory } from "@app/services/user/user-dal";
import { TAccessApprovalPolicyApproverDALFactory } from "../access-approval-policy/access-approval-policy-approver-dal";
import { TAccessApprovalPolicyDALFactory } from "../access-approval-policy/access-approval-policy-dal";
import { verifyApprovers } from "../access-approval-policy/access-approval-policy-fns";
import { TAccessApprovalPolicyGroupApproverDALFactory } from "../access-approval-policy/access-approval-policy-group-approver-dal";
import { TGroupDALFactory } from "../group/group-dal";
import { TPermissionServiceFactory } from "../permission/permission-service";
import { TProjectUserAdditionalPrivilegeDALFactory } from "../project-user-additional-privilege/project-user-additional-privilege-dal";
import { ProjectUserAdditionalPrivilegeTemporaryMode } from "../project-user-additional-privilege/project-user-additional-privilege-types";
@@ -36,6 +38,7 @@ type TSecretApprovalRequestServiceFactoryDep = {
additionalPrivilegeDAL: Pick<TProjectUserAdditionalPrivilegeDALFactory, "create" | "findById">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
accessApprovalPolicyApproverDAL: Pick<TAccessApprovalPolicyApproverDALFactory, "find">;
accessApprovalPolicyGroupApproverDAL: Pick<TAccessApprovalPolicyGroupApproverDALFactory, "find">;
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne">;
projectDAL: Pick<
TProjectDALFactory,
@@ -57,6 +60,7 @@ type TSecretApprovalRequestServiceFactoryDep = {
TAccessApprovalRequestReviewerDALFactory,
"create" | "find" | "findOne" | "transaction"
>;
groupDAL: Pick<TGroupDALFactory, "findAllGroupMembers">;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findById">;
smtpService: Pick<TSmtpService, "sendMail">;
userDAL: Pick<
@@ -70,6 +74,7 @@ type TSecretApprovalRequestServiceFactoryDep = {
export type TAccessApprovalRequestServiceFactory = ReturnType<typeof accessApprovalRequestServiceFactory>;
export const accessApprovalRequestServiceFactory = ({
groupDAL,
projectDAL,
projectEnvDAL,
permissionService,
@@ -78,6 +83,7 @@ export const accessApprovalRequestServiceFactory = ({
projectMembershipDAL,
accessApprovalPolicyDAL,
accessApprovalPolicyApproverDAL,
accessApprovalPolicyGroupApproverDAL,
additionalPrivilegeDAL,
smtpService,
userDAL,
@@ -124,13 +130,35 @@ export const accessApprovalRequestServiceFactory = ({
});
if (!policy) throw new UnauthorizedError({ message: "No policy matching criteria was found." });
const approverIds = [];
const approvers = await accessApprovalPolicyApproverDAL.find({
policyId: policy.id
});
approvers.forEach((approver) => {
approverIds.push(approver.approverUserId);
});
const groupApprovers = await accessApprovalPolicyGroupApproverDAL.find({
policyId: policy.id
});
const groupUsers = (
await Promise.all(
groupApprovers.map((groupApprover) =>
groupDAL.findAllGroupMembers({
orgId: actorOrgId,
groupId: groupApprover.id
})
)
)
).flat();
approverIds.push(...groupUsers.map((user) => user.id));
const approverUsers = await userDAL.find({
$in: {
id: approvers.map((approver) => approver.approverUserId)
id: [...new Set(approverIds)]
}
});

View File

@@ -7,6 +7,7 @@ import { registerCertificateEstRouter } from "@app/ee/routes/est/certificate-est
import { registerV1EERoutes } from "@app/ee/routes/v1";
import { accessApprovalPolicyApproverDALFactory } 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 { accessApprovalPolicyGroupApproverDALFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-group-approver-dal";
import { accessApprovalPolicyServiceFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-service";
import { accessApprovalRequestDALFactory } from "@app/ee/services/access-approval-request/access-approval-request-dal";
import { accessApprovalRequestReviewerDALFactory } from "@app/ee/services/access-approval-request/access-approval-request-reviewer-dal";
@@ -626,6 +627,7 @@ export const registerRoutes = async (
const pkiAlertDAL = pkiAlertDALFactory(db);
const pkiCollectionDAL = pkiCollectionDALFactory(db);
const pkiCollectionItemDAL = pkiCollectionItemDALFactory(db);
const accessApprovalPolicyGroupApproverDAL = accessApprovalPolicyGroupApproverDALFactory(db);
const certificateService = certificateServiceFactory({
certificateDAL,
@@ -923,6 +925,8 @@ export const registerRoutes = async (
const accessApprovalPolicyService = accessApprovalPolicyServiceFactory({
accessApprovalPolicyDAL,
accessApprovalPolicyApproverDAL,
accessApprovalPolicyGroupApproverDAL,
groupDAL,
permissionService,
projectEnvDAL,
projectMembershipDAL,
@@ -942,7 +946,9 @@ export const registerRoutes = async (
smtpService,
accessApprovalPolicyApproverDAL,
projectSlackConfigDAL,
kmsService
kmsService,
accessApprovalPolicyGroupApproverDAL,
groupDAL
});
const secretReplicationService = secretReplicationServiceFactory({

View File

@@ -21,6 +21,7 @@ export const useCreateAccessApprovalPolicy = () => {
projectSlug,
approvals,
approvers,
groupApprovers,
name,
secretPath,
enforcementLevel
@@ -30,6 +31,7 @@ export const useCreateAccessApprovalPolicy = () => {
projectSlug,
approvals,
approvers,
groupApprovers,
secretPath,
name,
enforcementLevel
@@ -46,10 +48,11 @@ export const useUpdateAccessApprovalPolicy = () => {
const queryClient = useQueryClient();
return useMutation<{}, {}, TUpdateAccessPolicyDTO>({
mutationFn: async ({ id, approvers, approvals, name, secretPath, enforcementLevel }) => {
mutationFn: async ({ id, approvers, groupApprovers, approvals, name, secretPath, enforcementLevel }) => {
const { data } = await apiRequest.patch(`/api/v1/access-approvals/policies/${id}`, {
approvals,
approvers,
groupApprovers,
secretPath,
name,
enforcementLevel

View File

@@ -17,6 +17,7 @@ export type TAccessApprovalPolicy = {
enforcementLevel: EnforcementLevel;
updatedAt: Date;
userApprovers?: { userId: string }[];
groupApprovers?: { groupId: string }[];
};
export type TAccessApprovalRequest = {
@@ -131,6 +132,7 @@ export type TCreateAccessPolicyDTO = {
name?: string;
environment: string;
approvers?: string[];
groupApprovers?: string[];
approvals?: number;
secretPath?: string;
enforcementLevel?: EnforcementLevel;
@@ -140,6 +142,7 @@ export type TUpdateAccessPolicyDTO = {
id: string;
name?: string;
approvers?: string[];
groupApprovers?: string[];
secretPath?: string;
environment?: string;
approvals?: number;

View File

@@ -41,7 +41,8 @@ import {
useDeleteAccessApprovalPolicy,
useDeleteSecretApprovalPolicy,
useGetSecretApprovalPolicies,
useGetWorkspaceUsers
useGetWorkspaceUsers,
useListWorkspaceGroups
} from "@app/hooks/api";
import { useGetAccessApprovalPolicies } from "@app/hooks/api/accessApproval/queries";
import { PolicyType } from "@app/hooks/api/policies/enums";
@@ -102,6 +103,8 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
const { currentWorkspace } = useWorkspace();
const { data: members } = useGetWorkspaceUsers(workspaceId, true);
const { data: groups } = useListWorkspaceGroups(currentWorkspace?.slug || "");
const { policies, isLoading: isPoliciesLoading } = useApprovalPolicies(
permission,
currentWorkspace
@@ -186,6 +189,7 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
<Th>Environment</Th>
<Th>Secret Path</Th>
<Th>Eligible Approvers</Th>
<Th>Eligible Group Approvers</Th>
<Th>Approval Required</Th>
<Th>
<DropdownMenu>
@@ -257,6 +261,7 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
workspaceId={workspaceId}
key={policy.id}
members={members}
groups={groups}
onEdit={() => handlePopUpOpen("policyForm", policy)}
onDelete={() => handlePopUpOpen("deletePolicy", policy)}
/>

View File

@@ -22,7 +22,7 @@ import {
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { policyDetails } from "@app/helpers/policies";
import { useCreateSecretApprovalPolicy, useUpdateSecretApprovalPolicy } from "@app/hooks/api";
import { useCreateSecretApprovalPolicy, useListWorkspaceGroups, useUpdateSecretApprovalPolicy } from "@app/hooks/api";
import {
useCreateAccessApprovalPolicy,
useUpdateAccessApprovalPolicy
@@ -45,13 +45,14 @@ const formSchema = z
name: z.string().optional(),
secretPath: z.string().optional(),
approvals: z.number().min(1),
approvers: z.string().array().min(1),
approvers: z.string().array().optional(),
groupApprovers: z.string().array().optional(),
policyType: z.nativeEnum(PolicyType),
enforcementLevel: z.nativeEnum(EnforcementLevel)
})
.refine((data) => data.approvals <= data.approvers.length, {
path: ["approvals"],
message: "The number of approvals should be lower than the number of approvers."
.refine((data) => data.approvers || data.groupApprovers, {
path: ["approvers", "groupApprovers"],
message: "At least one approver should be provided."
});
type TFormSchema = z.infer<typeof formSchema>;
@@ -75,11 +76,14 @@ export const AccessPolicyForm = ({
? {
...editValues,
environment: editValues.environment.slug,
approvers: editValues?.userApprovers?.map((user) => user.userId) || editValues?.approvers
approvers: editValues?.userApprovers?.map((user) => user.userId) || editValues?.approvers,
groupApprovers: editValues?.groupApprovers?.map((group) => group.groupId) || editValues?.groupApprovers,
approvals: editValues?.approvals
}
: undefined
});
const { currentWorkspace } = useWorkspace();
const { data: groups } = useListWorkspaceGroups(projectSlug);
const environments = currentWorkspace?.environments || [];
const isEditMode = Boolean(editValues);
@@ -266,8 +270,7 @@ export const AccessPolicyForm = ({
name="approvers"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Required Approvers"
isRequired
label="Required User Approvers"
isError={Boolean(error)}
errorText={error?.message}
>
@@ -312,6 +315,56 @@ export const AccessPolicyForm = ({
</FormControl>
)}
/>
<Controller
control={control}
name="groupApprovers"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Required Group Approvers"
isError={Boolean(error)}
errorText={error?.message}
>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Input
isReadOnly
value={value?.length ? `${value.length} selected` : "None"}
className="text-left"
/>
</DropdownMenuTrigger>
<DropdownMenuContent
style={{ width: "var(--radix-dropdown-menu-trigger-width)" }}
align="start"
>
<DropdownMenuLabel>
Select groups that are allowed to approve requests
</DropdownMenuLabel>
{groups && groups.map(({ group }) => {
const { id } = group;
const isChecked = value?.includes(id);
return (
<DropdownMenuItem
onClick={(evt) => {
evt.preventDefault();
onChange(
isChecked
? value?.filter((el: string) => el !== id)
: [...(value || []), id]
);
}}
key={`create-policy-members-${id}`}
iconPos="right"
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
>
{group.name}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
)}
/>
<Controller
control={control}
name="approvals"

View File

@@ -18,6 +18,7 @@ import { Badge } from "@app/components/v2/Badge";
import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context";
import { policyDetails } from "@app/helpers/policies";
import { useUpdateAccessApprovalPolicy, useUpdateSecretApprovalPolicy } from "@app/hooks/api";
import { TGroupMembership } from "@app/hooks/api/groups/types";
import { EnforcementLevel, PolicyType } from "@app/hooks/api/policies/enums";
import { WorkspaceEnv } from "@app/hooks/api/types";
import { TWorkspaceUser } from "@app/hooks/api/users/types";
@@ -31,6 +32,7 @@ interface IPolicy {
approvals: number;
approvers?: string[];
userApprovers?: { userId: string }[];
groupApprovers?: { groupId: string }[];
updatedAt: Date;
policyType: PolicyType;
enforcementLevel: EnforcementLevel;
@@ -39,6 +41,7 @@ interface IPolicy {
type Props = {
policy: IPolicy;
members?: TWorkspaceUser[];
groups?: TGroupMembership[];
projectSlug: string;
workspaceId: string;
onEdit: () => void;
@@ -48,12 +51,14 @@ type Props = {
export const ApprovalPolicyRow = ({
policy,
members = [],
groups = [],
projectSlug,
workspaceId,
onEdit,
onDelete
}: Props) => {
const [selectedApprovers, setSelectedApprovers] = useState<string[]>(policy.userApprovers?.map(({ userId }) => userId) || policy.approvers || []);
const [selectedGroupApprovers, setSelectedGroupApprovers] = useState<string[]>(policy.groupApprovers?.map(({ groupId }) => groupId) || []);
const { mutate: updateAccessApprovalPolicy, isLoading: isAccessApprovalPolicyLoading } = useUpdateAccessApprovalPolicy();
const { mutate: updateSecretApprovalPolicy, isLoading: isSecretApprovalPolicyLoading } = useUpdateSecretApprovalPolicy();
const isLoading = isAccessApprovalPolicyLoading || isSecretApprovalPolicyLoading;
@@ -74,9 +79,10 @@ export const ApprovalPolicyRow = ({
{
projectSlug,
id: policy.id,
approvers: selectedApprovers
approvers: selectedApprovers,
groupApprovers: selectedGroupApprovers
},
{ onSettled: () => {} }
{ onSettled: () => { } }
);
} else {
updateSecretApprovalPolicy(
@@ -85,7 +91,7 @@ export const ApprovalPolicyRow = ({
id: policy.id,
approvers: selectedApprovers
},
{ onSettled: () => {} }
{ onSettled: () => { } }
);
}
} else {
@@ -95,7 +101,7 @@ export const ApprovalPolicyRow = ({
);
}
}}
>
>
<DropdownMenuTrigger
asChild
disabled={
@@ -116,8 +122,8 @@ export const ApprovalPolicyRow = ({
<DropdownMenuLabel>
Select members that are allowed to approve changes
</DropdownMenuLabel>
{members?.map(({ id, user }) => {
const userId = policy.policyType === PolicyType.ChangePolicy ? user.id : id;
{members?.map(({ user }) => {
const userId = user.id;
const isChecked = selectedApprovers.includes(userId);
return (
<DropdownMenuItem
@@ -138,6 +144,76 @@ export const ApprovalPolicyRow = ({
</DropdownMenuContent>
</DropdownMenu>
</Td>
<Td>
<DropdownMenu
onOpenChange={(isOpen) => {
if (!isOpen) {
if (policy.policyType === PolicyType.AccessPolicy) {
updateAccessApprovalPolicy(
{
projectSlug,
id: policy.id,
approvers: selectedApprovers,
groupApprovers: selectedGroupApprovers
},
{ onSettled: () => { } }
);
} else {
updateSecretApprovalPolicy(
{
workspaceId,
id: policy.id,
approvers: selectedApprovers,
},
{ onSettled: () => { } }
);
}
} else {
setSelectedGroupApprovers(policy.policyType === PolicyType.ChangePolicy
? policy?.groupApprovers?.map(({ groupId }) => groupId) || []
: policy?.groupApprovers?.map(({groupId}) => groupId) || []
);
}
}}
>
<DropdownMenuTrigger asChild>
<Input
isReadOnly
value={selectedGroupApprovers?.length ? `${selectedGroupApprovers.length} selected` : "None"}
className="text-left"
/>
</DropdownMenuTrigger>
<DropdownMenuContent
style={{ width: "var(--radix-dropdown-menu-trigger-width)" }}
align="start"
>
<DropdownMenuLabel>
Select groups that are allowed to approve requests
</DropdownMenuLabel>
{groups && groups.map(({ group }) => {
const { id } = group;
const isChecked = selectedGroupApprovers?.includes(id);
return (
<DropdownMenuItem
onClick={(evt) => {
evt.preventDefault();
setSelectedGroupApprovers(
isChecked
? selectedGroupApprovers?.filter((el: string) => el !== id)
: [...(selectedGroupApprovers || []), id]
);
}}
key={`create-policy-groups-${id}`}
iconPos="right"
icon={isChecked && <FontAwesomeIcon icon={faCheckCircle} />}
>
{group.name}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</Td>
<Td>{policy.approvals}</Td>
<Td>
<Badge className={policyDetails[policy.policyType].className}>