mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-14 08:29:55 +00:00
Merge pull request #3777 from akhilmhdh/feat/seq-access-request
feat: Sequentail access approval request
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
const hasStepColumn = await knex.schema.hasColumn(TableName.AccessApprovalPolicyApprover, "sequence");
|
||||
const hasApprovalRequiredColumn = await knex.schema.hasColumn(
|
||||
TableName.AccessApprovalPolicyApprover,
|
||||
"approvalsRequired"
|
||||
);
|
||||
if (!hasStepColumn || !hasApprovalRequiredColumn) {
|
||||
await knex.schema.alterTable(TableName.AccessApprovalPolicyApprover, (t) => {
|
||||
if (!hasStepColumn) t.integer("sequence").defaultTo(1);
|
||||
if (!hasApprovalRequiredColumn) t.integer("approvalsRequired").nullable();
|
||||
});
|
||||
}
|
||||
|
||||
// set rejected status for all access request that was rejected and still has status pending
|
||||
const subquery = knex(TableName.AccessApprovalRequest)
|
||||
.leftJoin(
|
||||
TableName.AccessApprovalRequestReviewer,
|
||||
`${TableName.AccessApprovalRequestReviewer}.requestId`,
|
||||
`${TableName.AccessApprovalRequest}.id`
|
||||
)
|
||||
.where(`${TableName.AccessApprovalRequest}.status` as "status", "pending")
|
||||
.where(`${TableName.AccessApprovalRequestReviewer}.status` as "status", "rejected")
|
||||
.select(`${TableName.AccessApprovalRequest}.id`);
|
||||
|
||||
await knex(TableName.AccessApprovalRequest).where("id", "in", subquery).update("status", "rejected");
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
const hasStepColumn = await knex.schema.hasColumn(TableName.AccessApprovalPolicyApprover, "sequence");
|
||||
const hasApprovalRequiredColumn = await knex.schema.hasColumn(
|
||||
TableName.AccessApprovalPolicyApprover,
|
||||
"approvalsRequired"
|
||||
);
|
||||
if (hasStepColumn || hasApprovalRequiredColumn) {
|
||||
await knex.schema.alterTable(TableName.AccessApprovalPolicyApprover, (t) => {
|
||||
if (hasStepColumn) t.dropColumn("sequence");
|
||||
if (hasApprovalRequiredColumn) t.dropColumn("approvalsRequired");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,9 @@ export const AccessApprovalPoliciesApproversSchema = z.object({
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
approverUserId: z.string().uuid().nullable().optional(),
|
||||
approverGroupId: z.string().uuid().nullable().optional()
|
||||
approverGroupId: z.string().uuid().nullable().optional(),
|
||||
sequence: z.number().default(0).nullable().optional(),
|
||||
approvalsRequired: z.number().default(1).nullable().optional()
|
||||
});
|
||||
|
||||
export type TAccessApprovalPoliciesApprovers = z.infer<typeof AccessApprovalPoliciesApproversSchema>;
|
||||
|
||||
@@ -23,12 +23,26 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
|
||||
environment: z.string(),
|
||||
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()
|
||||
.max(100, "Cannot have more than 100 approvers")
|
||||
.min(1, { message: "At least one approver should be provided" }),
|
||||
.min(1, { message: "At least one approver should be provided" })
|
||||
.refine(
|
||||
// @ts-expect-error this is ok
|
||||
(el) => el.every((i) => Boolean(i?.id) || Boolean(i?.username)),
|
||||
"Must provide either username or id"
|
||||
),
|
||||
bypassers: z
|
||||
.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal(BypasserType.Group), id: z.string() }),
|
||||
@@ -37,6 +51,13 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
|
||||
.array()
|
||||
.max(100, "Cannot have more than 100 bypassers")
|
||||
.optional(),
|
||||
approvalsRequired: z
|
||||
.object({
|
||||
numberOfApprovals: z.number().int(),
|
||||
stepNumber: z.number().int()
|
||||
})
|
||||
.array()
|
||||
.optional(),
|
||||
approvals: z.number().min(1).default(1),
|
||||
enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard),
|
||||
allowedSelfApprovals: z.boolean().default(true)
|
||||
@@ -78,7 +99,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(),
|
||||
@@ -152,12 +178,26 @@ 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" })
|
||||
.max(100, "Cannot have more than 100 approvers"),
|
||||
.max(100, "Cannot have more than 100 approvers")
|
||||
.refine(
|
||||
// @ts-expect-error this is ok
|
||||
(el) => el.every((i) => Boolean(i?.id) || Boolean(i?.username)),
|
||||
"Must provide either username or id"
|
||||
),
|
||||
bypassers: z
|
||||
.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal(BypasserType.Group), id: z.string() }),
|
||||
@@ -168,7 +208,14 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
|
||||
.optional(),
|
||||
approvals: z.number().min(1).optional(),
|
||||
enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard),
|
||||
allowedSelfApprovals: z.boolean().default(true)
|
||||
allowedSelfApprovals: z.boolean().default(true),
|
||||
approvalsRequired: z
|
||||
.object({
|
||||
numberOfApprovals: z.number().int(),
|
||||
stepNumber: z.number().int()
|
||||
})
|
||||
.array()
|
||||
.optional()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
@@ -235,7 +282,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(),
|
||||
|
||||
@@ -270,7 +270,6 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
|
||||
}),
|
||||
body: z.object({
|
||||
schemas: z.array(z.string()),
|
||||
id: z.string().trim(),
|
||||
userName: z.string().trim(),
|
||||
name: z
|
||||
.object({
|
||||
@@ -278,7 +277,6 @@ export const registerScimRouter = async (server: FastifyZodProvider) => {
|
||||
givenName: z.string().trim().optional()
|
||||
})
|
||||
.optional(),
|
||||
displayName: z.string().trim(),
|
||||
emails: z
|
||||
.array(
|
||||
z.object({
|
||||
|
||||
@@ -48,6 +48,8 @@ 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("bypasserUserId").withSchema(TableName.AccessApprovalPolicyBypasser))
|
||||
.select(tx.ref("bypasserGroupId").withSchema(TableName.AccessApprovalPolicyBypasser))
|
||||
.select(tx.ref("name").withSchema(TableName.Environment).as("envName"))
|
||||
@@ -80,23 +82,31 @@ 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
|
||||
})
|
||||
}
|
||||
]
|
||||
});
|
||||
if (!formattedDoc?.[0]) return;
|
||||
|
||||
return formattedDoc?.[0];
|
||||
return {
|
||||
...formattedDoc?.[0],
|
||||
approvers: formattedDoc?.[0]?.approvers.sort((a, b) => (a.sequence || 1) - (b.sequence || 1))
|
||||
};
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "FindById" });
|
||||
}
|
||||
@@ -129,18 +139,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
|
||||
})
|
||||
},
|
||||
{
|
||||
@@ -163,7 +177,10 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => {
|
||||
]
|
||||
});
|
||||
|
||||
return formattedDocs;
|
||||
return formattedDocs.map((el) => ({
|
||||
...el,
|
||||
approvers: el?.approvers.sort((a, b) => (a.sequence || 1) - (b.sequence || 1))
|
||||
}));
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Find" });
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
@@ -41,9 +42,9 @@ type TAccessApprovalPolicyServiceFactoryDep = {
|
||||
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "find">;
|
||||
groupDAL: TGroupDALFactory;
|
||||
userDAL: Pick<TUserDALFactory, "find">;
|
||||
accessApprovalRequestDAL: Pick<TAccessApprovalRequestDALFactory, "update" | "find">;
|
||||
accessApprovalRequestDAL: Pick<TAccessApprovalRequestDALFactory, "update" | "find" | "resetReviewByPolicyId">;
|
||||
additionalPrivilegeDAL: Pick<TProjectUserAdditionalPrivilegeDALFactory, "delete">;
|
||||
accessApprovalRequestReviewerDAL: Pick<TAccessApprovalRequestReviewerDALFactory, "update">;
|
||||
accessApprovalRequestReviewerDAL: Pick<TAccessApprovalRequestReviewerDALFactory, "update" | "delete">;
|
||||
orgMembershipDAL: Pick<TOrgMembershipDALFactory, "find">;
|
||||
};
|
||||
|
||||
@@ -76,27 +77,23 @@ export const accessApprovalPolicyServiceFactory = ({
|
||||
projectSlug,
|
||||
environment,
|
||||
enforcementLevel,
|
||||
allowedSelfApprovals
|
||||
allowedSelfApprovals,
|
||||
approvalsRequired
|
||||
}: TCreateAccessApprovalPolicy) => {
|
||||
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||
if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` });
|
||||
|
||||
// If there is a group approver people might be added to the group later to meet the approvers quota
|
||||
const groupApprovers = approvers
|
||||
.filter((approver) => approver.type === ApproverType.Group)
|
||||
.map((approver) => approver.id) as string[];
|
||||
const groupApprovers = approvers.filter((approver) => approver.type === ApproverType.Group);
|
||||
|
||||
const userApprovers = approvers
|
||||
.filter((approver) => approver.type === ApproverType.User)
|
||||
.map((approver) => approver.id)
|
||||
.filter(Boolean) as string[];
|
||||
const userApprovers = approvers.filter((approver) => approver.type === ApproverType.User && approver.id) as {
|
||||
id: string;
|
||||
sequence?: number;
|
||||
}[];
|
||||
|
||||
const userApproverNames = approvers
|
||||
.map((approver) => (approver.type === ApproverType.User ? approver.username : undefined))
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
if (!groupApprovers && approvals > userApprovers.length + userApproverNames.length)
|
||||
throw new BadRequestError({ message: "Approvals cannot be greater than approvers" });
|
||||
const userApproverNames = approvers.filter(
|
||||
(approver) => approver.type === ApproverType.User && approver.username
|
||||
) as { username: string; sequence?: number }[];
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
@@ -116,14 +113,13 @@ export const accessApprovalPolicyServiceFactory = ({
|
||||
|
||||
let approverUserIds = userApprovers;
|
||||
if (userApproverNames.length) {
|
||||
const approverUsers = await userDAL.find({
|
||||
const approverUsersInDB = await userDAL.find({
|
||||
$in: {
|
||||
username: userApproverNames
|
||||
username: userApproverNames.map((el) => el.username)
|
||||
}
|
||||
});
|
||||
|
||||
const approverNamesFromDb = approverUsers.map((user) => user.username);
|
||||
const invalidUsernames = userApproverNames.filter((username) => !approverNamesFromDb.includes(username));
|
||||
const approverUsersInDBGroupByUsername = groupBy(approverUsersInDB, (i) => i.username);
|
||||
const invalidUsernames = userApproverNames.filter((el) => !approverUsersInDBGroupByUsername?.[el.username]?.[0]);
|
||||
|
||||
if (invalidUsernames.length) {
|
||||
throw new BadRequestError({
|
||||
@@ -131,32 +127,13 @@ export const accessApprovalPolicyServiceFactory = ({
|
||||
});
|
||||
}
|
||||
|
||||
approverUserIds = approverUserIds.concat(approverUsers.map((user) => user.id));
|
||||
}
|
||||
|
||||
const usersPromises: Promise<
|
||||
{
|
||||
id: string;
|
||||
email: string | null | undefined;
|
||||
username: string;
|
||||
firstName: string | null | undefined;
|
||||
lastName: string | null | undefined;
|
||||
isPartOfGroup: boolean;
|
||||
}[]
|
||||
>[] = [];
|
||||
const verifyAllApprovers = [...approverUserIds];
|
||||
|
||||
for (const groupId of groupApprovers) {
|
||||
usersPromises.push(
|
||||
groupDAL.findAllGroupPossibleMembers({ orgId: actorOrgId, groupId, offset: 0 }).then((group) => group.members)
|
||||
approverUserIds = approverUserIds.concat(
|
||||
userApproverNames.map((el) => ({
|
||||
id: approverUsersInDBGroupByUsername[el.username]?.[0].id,
|
||||
sequence: el.sequence
|
||||
}))
|
||||
);
|
||||
}
|
||||
const verifyGroupApprovers = (await Promise.all(usersPromises))
|
||||
.flat()
|
||||
.filter((user) => user.isPartOfGroup)
|
||||
.map((user) => user.id);
|
||||
verifyAllApprovers.push(...verifyGroupApprovers);
|
||||
|
||||
let groupBypassers: string[] = [];
|
||||
let bypasserUserIds: string[] = [];
|
||||
|
||||
@@ -195,6 +172,7 @@ export const accessApprovalPolicyServiceFactory = ({
|
||||
}
|
||||
}
|
||||
|
||||
const approvalsRequiredGroupByStepNumber = groupBy(approvalsRequired || [], (i) => i.stepNumber);
|
||||
const accessApproval = await accessApprovalPolicyDAL.transaction(async (tx) => {
|
||||
const doc = await accessApprovalPolicyDAL.create(
|
||||
{
|
||||
@@ -210,9 +188,13 @@ export const accessApprovalPolicyServiceFactory = ({
|
||||
|
||||
if (approverUserIds.length) {
|
||||
await accessApprovalPolicyApproverDAL.insertMany(
|
||||
approverUserIds.map((userId) => ({
|
||||
approverUserId: userId,
|
||||
policyId: doc.id
|
||||
approverUserIds.map((el) => ({
|
||||
approverUserId: el.id,
|
||||
policyId: doc.id,
|
||||
sequence: el.sequence,
|
||||
approvalsRequired: el.sequence
|
||||
? approvalsRequiredGroupByStepNumber?.[el.sequence]?.[0]?.numberOfApprovals
|
||||
: approvals
|
||||
})),
|
||||
tx
|
||||
);
|
||||
@@ -220,9 +202,13 @@ export const accessApprovalPolicyServiceFactory = ({
|
||||
|
||||
if (groupApprovers) {
|
||||
await accessApprovalPolicyApproverDAL.insertMany(
|
||||
groupApprovers.map((groupId) => ({
|
||||
approverGroupId: groupId,
|
||||
policyId: doc.id
|
||||
groupApprovers.map((el) => ({
|
||||
approverGroupId: el.id,
|
||||
policyId: doc.id,
|
||||
sequence: el.sequence,
|
||||
approvalsRequired: el.sequence
|
||||
? approvalsRequiredGroupByStepNumber?.[el.sequence]?.[0]?.numberOfApprovals
|
||||
: approvals
|
||||
})),
|
||||
tx
|
||||
);
|
||||
@@ -290,22 +276,22 @@ export const accessApprovalPolicyServiceFactory = ({
|
||||
actorAuthMethod,
|
||||
approvals,
|
||||
enforcementLevel,
|
||||
allowedSelfApprovals
|
||||
allowedSelfApprovals,
|
||||
approvalsRequired
|
||||
}: TUpdateAccessApprovalPolicy) => {
|
||||
const groupApprovers = approvers
|
||||
.filter((approver) => approver.type === ApproverType.Group)
|
||||
.map((approver) => approver.id) as string[];
|
||||
const groupApprovers = approvers.filter((approver) => approver.type === ApproverType.Group);
|
||||
|
||||
const userApprovers = approvers
|
||||
.filter((approver) => approver.type === ApproverType.User)
|
||||
.map((approver) => approver.id)
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
const userApproverNames = approvers
|
||||
.map((approver) => (approver.type === ApproverType.User ? approver.username : undefined))
|
||||
.filter(Boolean) as string[];
|
||||
const userApprovers = approvers.filter((approver) => approver.type === ApproverType.User && approver.id) as {
|
||||
id: string;
|
||||
sequence?: number;
|
||||
}[];
|
||||
const userApproverNames = approvers.filter(
|
||||
(approver) => approver.type === ApproverType.User && approver.username
|
||||
) as { username: string; sequence?: number }[];
|
||||
|
||||
const accessApprovalPolicy = await accessApprovalPolicyDAL.findById(policyId);
|
||||
if (!accessApprovalPolicy) throw new BadRequestError({ message: "Approval policy not found" });
|
||||
|
||||
const currentApprovals = approvals || accessApprovalPolicy.approvals;
|
||||
if (
|
||||
groupApprovers?.length === 0 &&
|
||||
@@ -401,6 +387,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,
|
||||
@@ -417,16 +404,18 @@ export const accessApprovalPolicyServiceFactory = ({
|
||||
await accessApprovalPolicyApproverDAL.delete({ policyId: doc.id }, tx);
|
||||
|
||||
if (userApprovers.length || userApproverNames.length) {
|
||||
let userApproverIds = userApprovers;
|
||||
let approverUserIds = userApprovers;
|
||||
if (userApproverNames.length) {
|
||||
const approverUsers = await userDAL.find({
|
||||
const approverUsersInDB = await userDAL.find({
|
||||
$in: {
|
||||
username: userApproverNames
|
||||
username: userApproverNames.map((el) => el.username)
|
||||
}
|
||||
});
|
||||
const approverUsersInDBGroupByUsername = groupBy(approverUsersInDB, (i) => i.username);
|
||||
|
||||
const approverNamesFromDb = approverUsers.map((user) => user.username);
|
||||
const invalidUsernames = userApproverNames.filter((username) => !approverNamesFromDb.includes(username));
|
||||
const invalidUsernames = userApproverNames.filter(
|
||||
(el) => !approverUsersInDBGroupByUsername?.[el.username]?.[0]
|
||||
);
|
||||
|
||||
if (invalidUsernames.length) {
|
||||
throw new BadRequestError({
|
||||
@@ -434,13 +423,21 @@ export const accessApprovalPolicyServiceFactory = ({
|
||||
});
|
||||
}
|
||||
|
||||
userApproverIds = userApproverIds.concat(approverUsers.map((user) => user.id));
|
||||
approverUserIds = approverUserIds.concat(
|
||||
userApproverNames.map((el) => ({
|
||||
id: approverUsersInDBGroupByUsername[el.username]?.[0].id,
|
||||
sequence: el.sequence
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
await accessApprovalPolicyApproverDAL.insertMany(
|
||||
userApproverIds.map((userId) => ({
|
||||
approverUserId: userId,
|
||||
policyId: doc.id
|
||||
approverUserIds.map((el) => ({
|
||||
approverUserId: el.id,
|
||||
policyId: doc.id,
|
||||
sequence: el.sequence,
|
||||
approvalsRequired: el.sequence
|
||||
? approvalsRequiredGroupByStepNumber?.[el.sequence]?.[0]?.numberOfApprovals
|
||||
: approvals
|
||||
})),
|
||||
tx
|
||||
);
|
||||
@@ -448,9 +445,13 @@ export const accessApprovalPolicyServiceFactory = ({
|
||||
|
||||
if (groupApprovers) {
|
||||
await accessApprovalPolicyApproverDAL.insertMany(
|
||||
groupApprovers.map((groupId) => ({
|
||||
approverGroupId: groupId,
|
||||
policyId: doc.id
|
||||
groupApprovers.map((el) => ({
|
||||
approverGroupId: el.id,
|
||||
policyId: doc.id,
|
||||
sequence: el.sequence,
|
||||
approvalsRequired: el.sequence
|
||||
? approvalsRequiredGroupByStepNumber?.[el.sequence]?.[0]?.numberOfApprovals
|
||||
: approvals
|
||||
})),
|
||||
tx
|
||||
);
|
||||
@@ -478,6 +479,8 @@ export const accessApprovalPolicyServiceFactory = ({
|
||||
);
|
||||
}
|
||||
|
||||
await accessApprovalRequestDAL.resetReviewByPolicyId(doc.id, tx);
|
||||
|
||||
return doc;
|
||||
});
|
||||
return {
|
||||
|
||||
@@ -27,7 +27,10 @@ export type TCreateAccessApprovalPolicy = {
|
||||
approvals: number;
|
||||
secretPath: string;
|
||||
environment: string;
|
||||
approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; username?: string })[];
|
||||
approvers: (
|
||||
| { type: ApproverType.Group; id: string; sequence?: number }
|
||||
| { type: ApproverType.User; id?: string; username?: string; sequence?: number }
|
||||
)[];
|
||||
bypassers?: (
|
||||
| { type: BypasserType.Group; id: string }
|
||||
| { type: BypasserType.User; id?: string; username?: string }
|
||||
@@ -36,12 +39,16 @@ export type TCreateAccessApprovalPolicy = {
|
||||
name: string;
|
||||
enforcementLevel: EnforcementLevel;
|
||||
allowedSelfApprovals: boolean;
|
||||
approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[];
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TUpdateAccessApprovalPolicy = {
|
||||
policyId: string;
|
||||
approvals?: number;
|
||||
approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; username?: string })[];
|
||||
approvers: (
|
||||
| { type: ApproverType.Group; id: string; sequence?: number }
|
||||
| { type: ApproverType.User; id?: string; username?: string; sequence?: number }
|
||||
)[];
|
||||
bypassers?: (
|
||||
| { type: BypasserType.Group; id: string }
|
||||
| { type: BypasserType.User; id?: string; username?: string }
|
||||
@@ -50,6 +57,7 @@ export type TUpdateAccessApprovalPolicy = {
|
||||
name?: string;
|
||||
enforcementLevel?: EnforcementLevel;
|
||||
allowedSelfApprovals: boolean;
|
||||
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"),
|
||||
@@ -164,8 +173,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
|
||||
permissions: doc.privilegePermissions
|
||||
}
|
||||
: null,
|
||||
|
||||
isApproved: !!doc.policyDeletedAt || !!doc.privilegeId || doc.status !== ApprovalStatus.PENDING
|
||||
isApproved: doc.status === ApprovalStatus.APPROVED
|
||||
}),
|
||||
childrenMapper: [
|
||||
{
|
||||
@@ -173,11 +181,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 +222,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 +306,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 +403,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 +424,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 +478,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
|
||||
}
|
||||
};
|
||||
@@ -495,7 +541,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
|
||||
req.status === ApprovalStatus.PENDING
|
||||
);
|
||||
|
||||
// an approval is finalized if there are any rejections, a privilege ID is set or the number of approvals is equal to the number of approvals required
|
||||
// an approval is finalized if there are any rejections, a privilege ID is set or the number of approvals is equal to the number of approvals required.
|
||||
const finalizedApprovals = formattedRequests.filter(
|
||||
(req) =>
|
||||
req.privilegeId ||
|
||||
@@ -509,5 +555,27 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
|
||||
}
|
||||
};
|
||||
|
||||
return { ...accessApprovalRequestOrm, findById, findRequestsWithPrivilegeByPolicyIds, getCount };
|
||||
const resetReviewByPolicyId = async (policyId: string, tx?: Knex) => {
|
||||
try {
|
||||
await (tx || db)(TableName.AccessApprovalRequestReviewer)
|
||||
.leftJoin(
|
||||
TableName.AccessApprovalRequest,
|
||||
`${TableName.AccessApprovalRequest}.id`,
|
||||
`${TableName.AccessApprovalRequestReviewer}.requestId`
|
||||
)
|
||||
.where(`${TableName.AccessApprovalRequest}.status` as "status", ApprovalStatus.PENDING)
|
||||
.where(`${TableName.AccessApprovalRequest}.policyId` as "policyId", policyId)
|
||||
.del();
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "ResetReviewByPolicyId" });
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
...accessApprovalRequestOrm,
|
||||
findById,
|
||||
findRequestsWithPrivilegeByPolicyIds,
|
||||
getCount,
|
||||
resetReviewByPolicyId
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
@@ -380,8 +380,44 @@ export const accessApprovalRequestServiceFactory = ({
|
||||
}
|
||||
|
||||
const existingReviews = await accessApprovalRequestReviewerDAL.find({ requestId: accessApprovalRequest.id });
|
||||
if (existingReviews.some((review) => review.status === ApprovalStatus.REJECTED)) {
|
||||
throw new BadRequestError({ message: "The request has already been rejected by another reviewer" });
|
||||
if (accessApprovalRequest.status !== ApprovalStatus.PENDING) {
|
||||
throw new BadRequestError({ message: "The request has been closed" });
|
||||
}
|
||||
|
||||
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) => {
|
||||
@@ -426,11 +462,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 &&
|
||||
|
||||
@@ -106,9 +106,11 @@ export const userServiceFactory = ({
|
||||
code
|
||||
});
|
||||
|
||||
const userEmails = user?.email ? await userDAL.find({ email: user.email }) : [];
|
||||
|
||||
await userDAL.updateById(user.id, {
|
||||
isEmailVerified: true,
|
||||
username: usersByusername.length === 1 && user.email ? user.email.toLowerCase() : undefined
|
||||
username: userEmails?.length === 1 && userEmails?.[0]?.id === user.id ? user.email.toLowerCase() : undefined
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user