feat: ui changes for approval to work

This commit is contained in:
=
2025-06-11 15:20:46 +05:30
parent d32b6ad41d
commit f9c00cf442
18 changed files with 1001 additions and 371 deletions
@@ -47,12 +47,11 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
.max(100, "Cannot have more than 100 bypassers")
.optional(),
approvalsRequired: z
.record(
z.number().int(),
z.object({
numberOfApprovals: z.number().int()
})
)
.object({
numberOfApprovals: z.number().int(),
stepNumber: z.number().int()
})
.array()
.optional(),
approvals: z.number().min(1).default(1),
enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard),
@@ -95,7 +94,12 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
approvals: sapPubSchema
.extend({
approvers: z
.object({ type: z.nativeEnum(ApproverType), id: z.string().nullable().optional() })
.object({
type: z.nativeEnum(ApproverType),
id: z.string().nullable().optional(),
sequence: z.number().nullable().optional(),
approvalsRequired: z.number().nullable().optional()
})
.array()
.nullable()
.optional(),
@@ -169,8 +173,17 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
.transform((val) => (val === "" ? "/" : val)),
approvers: z
.discriminatedUnion("type", [
z.object({ type: z.literal(ApproverType.Group), id: z.string() }),
z.object({ type: z.literal(ApproverType.User), id: z.string().optional(), username: z.string().optional() })
z.object({
type: z.literal(ApproverType.Group),
id: z.string(),
sequence: z.number().int().default(1)
}),
z.object({
type: z.literal(ApproverType.User),
id: z.string().optional(),
username: z.string().optional(),
sequence: z.number().int().default(1)
})
])
.array()
.min(1, { message: "At least one approver should be provided" })
@@ -187,12 +200,11 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard),
allowedSelfApprovals: z.boolean().default(true),
approvalsRequired: z
.record(
z.number().int(),
z.object({
numberOfApprovals: z.number().int()
})
)
.object({
numberOfApprovals: z.number().int(),
stepNumber: z.number().int()
})
.array()
.optional()
}),
response: {
@@ -260,7 +272,8 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
.object({
type: z.nativeEnum(ApproverType),
id: z.string().nullable().optional(),
name: z.string().nullable().optional()
name: z.string().nullable().optional(),
approvalsRequired: z.number().nullable().optional()
})
.array()
.nullable()
@@ -112,7 +112,15 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
id: z.string(),
name: z.string(),
approvals: z.number(),
approvers: z.string().array(),
approvers: z
.object({
userId: z.string().nullable().optional(),
sequence: z.number().nullable().optional(),
approvalsRequired: z.number().nullable().optional(),
email: z.string().nullable().optional(),
username: z.string().nullable().optional()
})
.array(),
bypassers: z.string().array(),
secretPath: z.string().nullish(),
envId: z.string(),
@@ -48,6 +48,9 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => {
.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("sequence").withSchema(TableName.AccessApprovalPolicyApprover).as("approverSequence"))
.select(tx.ref("approvalsRequired").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"))
@@ -80,17 +83,21 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => {
{
key: "approverUserId",
label: "approvers" as const,
mapper: ({ approverUserId: id }) => ({
mapper: ({ approverUserId: id, approverSequence, approvalsRequired }) => ({
id,
type: "user"
type: "user",
sequence: approverSequence,
approvalsRequired
})
},
{
key: "approverGroupId",
label: "approvers" as const,
mapper: ({ approverGroupId: id }) => ({
mapper: ({ approverGroupId: id, approverSequence, approvalsRequired }) => ({
id,
type: "group"
type: "group",
sequence: approverSequence,
approvalsRequired
})
}
]
@@ -129,18 +136,22 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => {
{
key: "approverUserId",
label: "approvers" as const,
mapper: ({ approverUserId: id, approverUsername }) => ({
mapper: ({ approverUserId: id, approverUsername, approverSequence, approvalsRequired }) => ({
id,
type: ApproverType.User,
name: approverUsername
name: approverUsername,
sequence: approverSequence,
approvalsRequired
})
},
{
key: "approverGroupId",
label: "approvers" as const,
mapper: ({ approverGroupId: id }) => ({
mapper: ({ approverGroupId: id, approverSequence, approvalsRequired }) => ({
id,
type: ApproverType.Group
type: ApproverType.Group,
sequence: approverSequence,
approvalsRequired
})
},
{
@@ -4,6 +4,7 @@ import { ActionProjectType } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { groupBy } from "@app/lib/fn";
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";
@@ -30,7 +31,6 @@ import {
TListAccessApprovalPoliciesDTO,
TUpdateAccessApprovalPolicy
} from "./access-approval-policy-types";
import { groupBy } from "@app/lib/fn";
type TAccessApprovalPolicyServiceFactoryDep = {
projectDAL: TProjectDALFactory;
@@ -44,7 +44,7 @@ type TAccessApprovalPolicyServiceFactoryDep = {
userDAL: Pick<TUserDALFactory, "find">;
accessApprovalRequestDAL: Pick<TAccessApprovalRequestDALFactory, "update" | "find">;
additionalPrivilegeDAL: Pick<TProjectUserAdditionalPrivilegeDALFactory, "delete">;
accessApprovalRequestReviewerDAL: Pick<TAccessApprovalRequestReviewerDALFactory, "update">;
accessApprovalRequestReviewerDAL: Pick<TAccessApprovalRequestReviewerDALFactory, "update" | "delete">;
orgMembershipDAL: Pick<TOrgMembershipDALFactory, "find">;
};
@@ -176,6 +176,7 @@ export const accessApprovalPolicyServiceFactory = ({
}
}
const approvalsRequiredGroupByStepNumber = groupBy(approvalsRequired || [], (i) => i.stepNumber);
const accessApproval = await accessApprovalPolicyDAL.transaction(async (tx) => {
const doc = await accessApprovalPolicyDAL.create(
{
@@ -195,7 +196,9 @@ export const accessApprovalPolicyServiceFactory = ({
approverUserId: el.id,
policyId: doc.id,
sequence: el.sequence,
approvalsRequired: el.sequence ? approvalsRequired?.[el.sequence]?.numberOfApprovals : approvals
approvalsRequired: el.sequence
? approvalsRequiredGroupByStepNumber?.[el.sequence]?.[0]?.numberOfApprovals
: approvals
})),
tx
);
@@ -207,7 +210,9 @@ export const accessApprovalPolicyServiceFactory = ({
approverGroupId: el.id,
policyId: doc.id,
sequence: el.sequence,
approvalsRequired: el.sequence ? approvalsRequired?.[el.sequence]?.numberOfApprovals : approvals
approvalsRequired: el.sequence
? approvalsRequiredGroupByStepNumber?.[el.sequence]?.[0]?.numberOfApprovals
: approvals
})),
tx
);
@@ -284,7 +289,6 @@ export const accessApprovalPolicyServiceFactory = ({
id: string;
sequence?: number;
}[];
const userApproverNames = approvers.filter(
(approver) => approver.type === ApproverType.User && approver.username
) as { username: string; sequence?: number }[];
@@ -385,6 +389,7 @@ export const accessApprovalPolicyServiceFactory = ({
}
}
const approvalsRequiredGroupByStepNumber = groupBy(approvalsRequired || [], (i) => i.stepNumber);
const updatedPolicy = await accessApprovalPolicyDAL.transaction(async (tx) => {
const doc = await accessApprovalPolicyDAL.updateById(
accessApprovalPolicy.id,
@@ -427,13 +432,14 @@ export const accessApprovalPolicyServiceFactory = ({
}))
);
}
await accessApprovalPolicyApproverDAL.insertMany(
approverUserIds.map((el) => ({
approverUserId: el.id,
policyId: doc.id,
sequence: el.sequence,
approvalsRequired: el.sequence ? approvalsRequired?.[el.sequence]?.numberOfApprovals : approvals
approvalsRequired: el.sequence
? approvalsRequiredGroupByStepNumber?.[el.sequence]?.[0]?.numberOfApprovals
: approvals
})),
tx
);
@@ -445,7 +451,9 @@ export const accessApprovalPolicyServiceFactory = ({
approverGroupId: el.id,
policyId: doc.id,
sequence: el.sequence,
approvalsRequired: el.sequence ? approvalsRequired?.[el.sequence]?.numberOfApprovals : approvals
approvalsRequired: el.sequence
? approvalsRequiredGroupByStepNumber?.[el.sequence]?.[0]?.numberOfApprovals
: approvals
})),
tx
);
@@ -39,7 +39,7 @@ export type TCreateAccessApprovalPolicy = {
name: string;
enforcementLevel: EnforcementLevel;
allowedSelfApprovals: boolean;
approvalsRequired?: Record<number, { numberOfApprovals: number }>;
approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[];
} & Omit<TProjectPermission, "projectId">;
export type TUpdateAccessApprovalPolicy = {
@@ -57,7 +57,7 @@ export type TUpdateAccessApprovalPolicy = {
name?: string;
enforcementLevel?: EnforcementLevel;
allowedSelfApprovals: boolean;
approvalsRequired?: Record<number, { numberOfApprovals: number }>;
approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[];
} & Omit<TProjectPermission, "projectId">;
export type TDeleteAccessApprovalPolicy = {
@@ -39,12 +39,16 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
`${TableName.AccessApprovalRequest}.id`,
`${TableName.AccessApprovalRequestReviewer}.requestId`
)
.leftJoin(
TableName.AccessApprovalPolicyApprover,
`${TableName.AccessApprovalPolicy}.id`,
`${TableName.AccessApprovalPolicyApprover}.policyId`
)
.leftJoin<TUsers>(
db(TableName.Users).as("accessApprovalPolicyApproverUser"),
`${TableName.AccessApprovalPolicyApprover}.approverUserId`,
"accessApprovalPolicyApproverUser.id"
)
.leftJoin(
TableName.UserGroupMembership,
`${TableName.AccessApprovalPolicyApprover}.approverGroupId`,
@@ -82,13 +86,18 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
db.ref("envId").withSchema(TableName.AccessApprovalPolicy).as("policyEnvId"),
db.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt")
)
.select(db.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover))
.select(db.ref("sequence").withSchema(TableName.AccessApprovalPolicyApprover).as("approverSequence"))
.select(db.ref("approvalsRequired").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("email").withSchema("accessApprovalPolicyApproverUser").as("approverEmail"),
db.ref("email").withSchema(TableName.Users).as("approverGroupEmail"),
db.ref("username").withSchema("accessApprovalPolicyApproverUser").as("approverUsername"),
db.ref("username").withSchema(TableName.Users).as("approverGroupUsername")
)
.select(
db.ref("projectId").withSchema(TableName.Environment),
db.ref("slug").withSchema(TableName.Environment).as("envSlug"),
@@ -173,11 +182,33 @@ 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, approverSequence, approvalsRequired, approverUsername, approverEmail }) => ({
userId: approverUserId,
sequence: approverSequence,
approvalsRequired,
email: approverEmail,
username: approverUsername
})
},
{
key: "approverGroupUserId",
label: "approvers" as const,
mapper: ({ approverGroupUserId }) => approverGroupUserId
mapper: ({
approverGroupUserId,
approverSequence,
approvalsRequired,
approverGroupEmail,
approverGroupUsername
}) => ({
userId: approverGroupUserId,
sequence: approverSequence,
approvalsRequired,
email: approverGroupEmail,
username: approverGroupUsername
})
},
{ key: "bypasserUserId", label: "bypassers" as const, mapper: ({ bypasserUserId }) => bypasserUserId },
{
@@ -192,7 +223,11 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
return formattedDocs.map((doc) => ({
...doc,
policy: { ...doc.policy, approvers: doc.approvers, bypassers: doc.bypassers }
policy: {
...doc.policy,
approvers: doc.approvers.filter((el) => el.userId).sort((a, b) => (a.sequence || 0) - (b.sequence || 0)),
bypassers: doc.bypassers
}
}));
} catch (error) {
throw new DatabaseError({ error, name: "FindRequestsWithPrivilege" });
@@ -272,6 +307,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
.select(selectAllTableCols(TableName.AccessApprovalRequest))
.select(
tx.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover),
tx.ref("sequence").withSchema(TableName.AccessApprovalPolicyApprover).as("approverSequence"),
tx.ref("approvalsRequired").withSchema(TableName.AccessApprovalPolicyApprover),
tx.ref("userId").withSchema(TableName.UserGroupMembership),
tx.ref("email").withSchema("accessApprovalPolicyApproverUser").as("approverEmail"),
tx.ref("email").withSchema("accessApprovalPolicyGroupApproverUser").as("approverGroupEmail"),
@@ -367,13 +404,17 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
approverEmail: email,
approverUsername: username,
approverLastName: lastName,
approverFirstName: firstName
approverFirstName: firstName,
approverSequence,
approvalsRequired
}) => ({
userId: approverUserId,
email,
firstName,
lastName,
username
username,
sequence: approverSequence,
approvalsRequired
})
},
{
@@ -384,13 +425,17 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
approverGroupEmail: email,
approverGroupUsername: username,
approverGroupLastName: lastName,
approverFirstName: firstName
approverFirstName: firstName,
approverSequence,
approvalsRequired
}) => ({
userId,
email,
firstName,
lastName,
username
username,
sequence: approverSequence,
approvalsRequired
})
},
{
@@ -434,7 +479,9 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
...formattedDoc[0],
policy: {
...formattedDoc[0].policy,
approvers: formattedDoc[0].approvers,
approvers: formattedDoc[0].approvers
.filter((el) => el.userId)
.sort((a, b) => (a.sequence || 0) - (b.sequence || 0)),
bypassers: formattedDoc[0].bypassers
}
};
@@ -4,6 +4,7 @@ import msFn from "ms";
import { ActionProjectType, ProjectMembershipRole } from "@app/db/schemas";
import { getConfig } from "@app/lib/config/env";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { groupBy } from "@app/lib/fn";
import { ms } from "@app/lib/ms";
import { alphaNumericNanoId } from "@app/lib/nanoid";
import { EnforcementLevel } from "@app/lib/types";
@@ -358,7 +359,6 @@ export const accessApprovalRequestServiceFactory = ({
const cannotBypassUnderSoftEnforcement = !(isSoftEnforcement && canBypass);
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({
@@ -383,6 +383,41 @@ export const accessApprovalRequestServiceFactory = ({
if (existingReviews.some((review) => review.status === ApprovalStatus.REJECTED)) {
throw new BadRequestError({ message: "The request has already been rejected by another reviewer" });
}
const reviewsGroupById = groupBy(
existingReviews.filter((review) => review.status === ApprovalStatus.APPROVED),
(i) => i.reviewerUserId
);
const approvedSequences = policy.approvers.reduce(
(acc, curr) => {
const hasApproved = reviewsGroupById?.[curr.userId as string]?.[0];
if (acc?.[acc.length - 1]?.step === curr.sequence) {
if (hasApproved) {
acc[acc.length - 1].approvals += 1;
}
return acc;
}
acc.push({
step: curr.sequence || 1,
approvals: hasApproved ? 1 : 0,
requiredApprovals: curr.approvalsRequired || 1
});
return acc;
},
[] as { step: number; approvals: number; requiredApprovals: number }[]
);
const presentSequence = approvedSequences.find((el) => el.approvals < el.requiredApprovals) || {
step: 1,
approvals: 0,
requiredApprovals: 1
};
if (presentSequence) {
const isApproverOfTheSequence = policy.approvers.find(
(el) => el.sequence === presentSequence.step && el.userId === actorId
);
if (!isApproverOfTheSequence) throw new BadRequestError({ message: "You are not reviewer in this step" });
}
const reviewStatus = await accessApprovalRequestReviewerDAL.transaction(async (tx) => {
const isBreakGlassApprovalAttempt =
@@ -426,11 +461,14 @@ export const accessApprovalRequestServiceFactory = ({
);
}
const otherReviews = existingReviews.filter((er) => er.reviewerUserId !== actorId);
const allUniqueReviews = [...otherReviews, reviewForThisActorProcessing];
if (status === ApprovalStatus.REJECTED) {
await accessApprovalRequestDAL.updateById(accessApprovalRequest.id, { status: ApprovalStatus.REJECTED }, tx);
return reviewForThisActorProcessing;
}
const approvedReviews = allUniqueReviews.filter((r) => r.status === ApprovalStatus.APPROVED);
const meetsStandardApprovalThreshold = approvedReviews.length >= policy.approvals;
const meetsStandardApprovalThreshold =
(presentSequence?.approvals || 0) + 1 >= presentSequence.requiredApprovals &&
approvedSequences.at(-1)?.step === presentSequence?.step;
if (
reviewForThisActorProcessing.status === ApprovalStatus.APPROVED &&
@@ -36,12 +36,12 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
oidcSSO: false,
scim: false,
ldap: false,
groups: false,
groups: true,
status: null,
trial_end: null,
has_used_trial: true,
secretApproval: false,
secretRotation: false,
secretApproval: true,
secretRotation: true,
caCrl: false,
instanceUserManagement: false,
externalKms: false,