mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-04 03:26:12 +00:00
approval requests
This commit is contained in:
@@ -108,6 +108,7 @@ export async function up(knex: Knex): Promise<void> {
|
|||||||
t.string("status").notNullable().index();
|
t.string("status").notNullable().index();
|
||||||
|
|
||||||
t.integer("requiredApprovals").notNullable();
|
t.integer("requiredApprovals").notNullable();
|
||||||
|
t.boolean("notifyApprovers").defaultTo(false);
|
||||||
|
|
||||||
t.timestamp("startedAt").nullable();
|
t.timestamp("startedAt").nullable();
|
||||||
t.timestamp("completedAt").nullable();
|
t.timestamp("completedAt").nullable();
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export const ApprovalRequestStepsSchema = z.object({
|
|||||||
name: z.string().nullable().optional(),
|
name: z.string().nullable().optional(),
|
||||||
status: z.string(),
|
status: z.string(),
|
||||||
requiredApprovals: z.number(),
|
requiredApprovals: z.number(),
|
||||||
|
notifyApprovers: z.boolean().default(false).nullable().optional(),
|
||||||
startedAt: z.date().nullable().optional(),
|
startedAt: z.date().nullable().optional(),
|
||||||
completedAt: z.date().nullable().optional()
|
completedAt: z.date().nullable().optional()
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -163,7 +163,11 @@ import {
|
|||||||
approvalPolicyDALFactory,
|
approvalPolicyDALFactory,
|
||||||
approvalPolicyStepApproversDALFactory,
|
approvalPolicyStepApproversDALFactory,
|
||||||
approvalPolicyStepsDALFactory,
|
approvalPolicyStepsDALFactory,
|
||||||
approvalRequestGrantsDALFactory
|
approvalRequestApprovalsDALFactory,
|
||||||
|
approvalRequestDALFactory,
|
||||||
|
approvalRequestGrantsDALFactory,
|
||||||
|
approvalRequestStepEligibleApproversDALFactory,
|
||||||
|
approvalRequestStepsDALFactory
|
||||||
} from "@app/services/approval-policy/approval-policy-dal";
|
} from "@app/services/approval-policy/approval-policy-dal";
|
||||||
import { approvalPolicyServiceFactory } from "@app/services/approval-policy/approval-policy-service";
|
import { approvalPolicyServiceFactory } from "@app/services/approval-policy/approval-policy-service";
|
||||||
import { authDALFactory } from "@app/services/auth/auth-dal";
|
import { authDALFactory } from "@app/services/auth/auth-dal";
|
||||||
@@ -2465,13 +2469,23 @@ export const registerRoutes = async (
|
|||||||
|
|
||||||
const approvalPolicyStepsDAL = approvalPolicyStepsDALFactory(db);
|
const approvalPolicyStepsDAL = approvalPolicyStepsDALFactory(db);
|
||||||
const approvalPolicyStepApproversDAL = approvalPolicyStepApproversDALFactory(db);
|
const approvalPolicyStepApproversDAL = approvalPolicyStepApproversDALFactory(db);
|
||||||
|
const approvalRequestDAL = approvalRequestDALFactory(db);
|
||||||
|
const approvalRequestStepsDAL = approvalRequestStepsDALFactory(db);
|
||||||
|
const approvalRequestStepEligibleApproversDAL = approvalRequestStepEligibleApproversDALFactory(db);
|
||||||
|
const approvalRequestApprovalsDAL = approvalRequestApprovalsDALFactory(db);
|
||||||
|
|
||||||
const approvalPolicyService = approvalPolicyServiceFactory({
|
const approvalPolicyService = approvalPolicyServiceFactory({
|
||||||
approvalPolicyDAL,
|
approvalPolicyDAL,
|
||||||
approvalPolicyStepsDAL,
|
approvalPolicyStepsDAL,
|
||||||
approvalPolicyStepApproversDAL,
|
approvalPolicyStepApproversDAL,
|
||||||
permissionService,
|
permissionService,
|
||||||
projectMembershipDAL
|
projectMembershipDAL,
|
||||||
|
approvalRequestDAL,
|
||||||
|
approvalRequestStepsDAL,
|
||||||
|
approvalRequestStepEligibleApproversDAL,
|
||||||
|
approvalRequestApprovalsDAL,
|
||||||
|
userGroupMembershipDAL,
|
||||||
|
notificationService
|
||||||
});
|
});
|
||||||
|
|
||||||
// setup the communication with license key server
|
// setup the communication with license key server
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { BadRequestError } from "@app/lib/errors";
|
||||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||||
import { ApprovalPolicyType } from "@app/services/approval-policy/approval-policy-enums";
|
import { ApprovalPolicyType } from "@app/services/approval-policy/approval-policy-enums";
|
||||||
import {
|
import {
|
||||||
TApprovalPolicy,
|
TApprovalPolicy,
|
||||||
TCreatePolicyDTO,
|
TCreatePolicyDTO,
|
||||||
|
TCreateRequestDTO,
|
||||||
TUpdatePolicyDTO
|
TUpdatePolicyDTO
|
||||||
} from "@app/services/approval-policy/approval-policy-types";
|
} from "@app/services/approval-policy/approval-policy-types";
|
||||||
import { AuthMode } from "@app/services/auth/auth-type";
|
import { AuthMode } from "@app/services/auth/auth-type";
|
||||||
@@ -15,7 +17,9 @@ export const registerApprovalPolicyEndpoints = <P extends TApprovalPolicy>({
|
|||||||
policyType,
|
policyType,
|
||||||
createPolicySchema,
|
createPolicySchema,
|
||||||
updatePolicySchema,
|
updatePolicySchema,
|
||||||
policyResponseSchema
|
policyResponseSchema,
|
||||||
|
createRequestSchema,
|
||||||
|
requestResponseSchema
|
||||||
}: {
|
}: {
|
||||||
server: FastifyZodProvider;
|
server: FastifyZodProvider;
|
||||||
policyType: ApprovalPolicyType;
|
policyType: ApprovalPolicyType;
|
||||||
@@ -32,7 +36,10 @@ export const registerApprovalPolicyEndpoints = <P extends TApprovalPolicy>({
|
|||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
policyResponseSchema: z.ZodTypeAny;
|
policyResponseSchema: z.ZodTypeAny;
|
||||||
|
createRequestSchema: z.ZodType<TCreateRequestDTO>;
|
||||||
|
requestResponseSchema: z.ZodTypeAny;
|
||||||
}) => {
|
}) => {
|
||||||
|
// Policies
|
||||||
server.route({
|
server.route({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/",
|
url: "/",
|
||||||
@@ -52,7 +59,7 @@ export const registerApprovalPolicyEndpoints = <P extends TApprovalPolicy>({
|
|||||||
handler: async (req) => {
|
handler: async (req) => {
|
||||||
const { policy } = await server.services.approvalPolicy.create(policyType, req.body, req.permission);
|
const { policy } = await server.services.approvalPolicy.create(policyType, req.body, req.permission);
|
||||||
|
|
||||||
// TODO: Audit log
|
// TODO(andrey): Audit log
|
||||||
|
|
||||||
return { policy };
|
return { policy };
|
||||||
}
|
}
|
||||||
@@ -79,7 +86,7 @@ export const registerApprovalPolicyEndpoints = <P extends TApprovalPolicy>({
|
|||||||
handler: async (req) => {
|
handler: async (req) => {
|
||||||
const { policies } = await server.services.approvalPolicy.list(policyType, req.query.projectId, req.permission);
|
const { policies } = await server.services.approvalPolicy.list(policyType, req.query.projectId, req.permission);
|
||||||
|
|
||||||
// TODO: Audit log
|
// TODO(andrey): Audit log
|
||||||
|
|
||||||
return { policies };
|
return { policies };
|
||||||
}
|
}
|
||||||
@@ -106,7 +113,7 @@ export const registerApprovalPolicyEndpoints = <P extends TApprovalPolicy>({
|
|||||||
handler: async (req) => {
|
handler: async (req) => {
|
||||||
const { policy } = await server.services.approvalPolicy.getById(req.params.policyId, req.permission);
|
const { policy } = await server.services.approvalPolicy.getById(req.params.policyId, req.permission);
|
||||||
|
|
||||||
// TODO: Audit log
|
// TODO(andrey): Audit log
|
||||||
|
|
||||||
return { policy };
|
return { policy };
|
||||||
}
|
}
|
||||||
@@ -134,7 +141,7 @@ export const registerApprovalPolicyEndpoints = <P extends TApprovalPolicy>({
|
|||||||
handler: async (req) => {
|
handler: async (req) => {
|
||||||
const { policy } = await server.services.approvalPolicy.updateById(req.params.policyId, req.body, req.permission);
|
const { policy } = await server.services.approvalPolicy.updateById(req.params.policyId, req.body, req.permission);
|
||||||
|
|
||||||
// TODO: Audit log
|
// TODO(andrey): Audit log
|
||||||
|
|
||||||
return { policy };
|
return { policy };
|
||||||
}
|
}
|
||||||
@@ -161,9 +168,174 @@ export const registerApprovalPolicyEndpoints = <P extends TApprovalPolicy>({
|
|||||||
handler: async (req) => {
|
handler: async (req) => {
|
||||||
const { policyId } = await server.services.approvalPolicy.deleteById(req.params.policyId, req.permission);
|
const { policyId } = await server.services.approvalPolicy.deleteById(req.params.policyId, req.permission);
|
||||||
|
|
||||||
// TODO: Audit log
|
// TODO(andrey): Audit log
|
||||||
|
|
||||||
return { policyId };
|
return { policyId };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Requests
|
||||||
|
server.route({
|
||||||
|
method: "GET",
|
||||||
|
url: "/requests",
|
||||||
|
config: {
|
||||||
|
rateLimit: readLimit
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
description: "List approval requests",
|
||||||
|
querystring: z.object({
|
||||||
|
projectId: z.string().uuid()
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
requests: z.array(requestResponseSchema)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const { requests } = await server.services.approvalPolicy.listRequests(
|
||||||
|
policyType,
|
||||||
|
req.query.projectId,
|
||||||
|
req.permission
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO(andrey): Audit log
|
||||||
|
|
||||||
|
return { requests };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
method: "POST",
|
||||||
|
url: "/requests",
|
||||||
|
config: {
|
||||||
|
rateLimit: writeLimit
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
description: "Create approval request",
|
||||||
|
body: createRequestSchema,
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
request: requestResponseSchema
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT]),
|
||||||
|
handler: async (req) => {
|
||||||
|
// To prevent type errors when accessing req.auth.user
|
||||||
|
if (req.auth.authMode !== AuthMode.JWT) {
|
||||||
|
throw new BadRequestError({ message: "You can only request access using JWT auth tokens." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { request } = await server.services.approvalPolicy.createRequest(
|
||||||
|
policyType,
|
||||||
|
{
|
||||||
|
requesterName: `${req.auth.user.firstName ?? ""} ${req.auth.user.lastName ?? ""}`.trim(),
|
||||||
|
requesterEmail: req.auth.user.email ?? "",
|
||||||
|
...req.body
|
||||||
|
},
|
||||||
|
req.permission
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO(andrey): Audit log
|
||||||
|
|
||||||
|
return { request };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
method: "GET",
|
||||||
|
url: "/requests/:requestId",
|
||||||
|
config: {
|
||||||
|
rateLimit: readLimit
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
description: "Get approval request",
|
||||||
|
params: z.object({
|
||||||
|
requestId: z.string().uuid()
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
request: requestResponseSchema
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const { request } = await server.services.approvalPolicy.getRequestById(req.params.requestId, req.permission);
|
||||||
|
|
||||||
|
// TODO(andrey): Audit log
|
||||||
|
|
||||||
|
return { request };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
method: "POST",
|
||||||
|
url: "/requests/:requestId/approve",
|
||||||
|
config: {
|
||||||
|
rateLimit: writeLimit
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
description: "Approve approval request",
|
||||||
|
params: z.object({
|
||||||
|
requestId: z.string().uuid()
|
||||||
|
}),
|
||||||
|
body: z.object({
|
||||||
|
comment: z.string().optional()
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
request: requestResponseSchema
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const { request } = await server.services.approvalPolicy.approveRequest(
|
||||||
|
req.params.requestId,
|
||||||
|
req.body,
|
||||||
|
req.permission
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO(andrey): Audit log
|
||||||
|
|
||||||
|
return { request };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
method: "POST",
|
||||||
|
url: "/requests/:requestId/reject",
|
||||||
|
config: {
|
||||||
|
rateLimit: writeLimit
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
description: "Reject approval request",
|
||||||
|
params: z.object({
|
||||||
|
requestId: z.string().uuid()
|
||||||
|
}),
|
||||||
|
body: z.object({
|
||||||
|
comment: z.string().optional()
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
request: requestResponseSchema
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const { request } = await server.services.approvalPolicy.rejectRequest(
|
||||||
|
req.params.requestId,
|
||||||
|
req.body,
|
||||||
|
req.permission
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO(andrey): Audit log
|
||||||
|
|
||||||
|
return { request };
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { ApprovalPolicyType } from "@app/services/approval-policy/approval-policy-enums";
|
import { ApprovalPolicyType } from "@app/services/approval-policy/approval-policy-enums";
|
||||||
import {
|
import {
|
||||||
CreatePamAccessPolicySchema,
|
CreatePamAccessPolicySchema,
|
||||||
|
CreatePamAccessRequestSchema,
|
||||||
PamAccessPolicySchema,
|
PamAccessPolicySchema,
|
||||||
|
PamAccessRequestSchema,
|
||||||
UpdatePamAccessPolicySchema
|
UpdatePamAccessPolicySchema
|
||||||
} from "@app/services/approval-policy/pam-access/pam-access-policy-schemas";
|
} from "@app/services/approval-policy/pam-access/pam-access-policy-schemas";
|
||||||
|
|
||||||
@@ -17,7 +19,9 @@ export const APPROVAL_POLICY_REGISTER_ROUTER_MAP: Record<
|
|||||||
policyType: ApprovalPolicyType.PamAccess,
|
policyType: ApprovalPolicyType.PamAccess,
|
||||||
createPolicySchema: CreatePamAccessPolicySchema,
|
createPolicySchema: CreatePamAccessPolicySchema,
|
||||||
updatePolicySchema: UpdatePamAccessPolicySchema,
|
updatePolicySchema: UpdatePamAccessPolicySchema,
|
||||||
policyResponseSchema: PamAccessPolicySchema
|
policyResponseSchema: PamAccessPolicySchema,
|
||||||
|
createRequestSchema: CreatePamAccessRequestSchema,
|
||||||
|
requestResponseSchema: PamAccessRequestSchema
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { TDbClient } from "@app/db";
|
import { TDbClient } from "@app/db";
|
||||||
import { TableName } from "@app/db/schemas";
|
import { TableName, TApprovalRequestApprovals } from "@app/db/schemas";
|
||||||
import { DatabaseError } from "@app/lib/errors";
|
import { DatabaseError } from "@app/lib/errors";
|
||||||
import { ormify } from "@app/lib/knex";
|
import { ormify } from "@app/lib/knex";
|
||||||
|
|
||||||
@@ -149,9 +149,160 @@ export const approvalPolicyStepApproversDALFactory = (db: TDbClient) => {
|
|||||||
return orm;
|
return orm;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Approval Policy Grants
|
// Approval Request
|
||||||
|
export type TApprovalRequestDALFactory = ReturnType<typeof approvalRequestDALFactory>;
|
||||||
|
export const approvalRequestDALFactory = (db: TDbClient) => {
|
||||||
|
const orm = ormify(db, TableName.ApprovalRequests);
|
||||||
|
|
||||||
|
const findStepsByRequestId = async (requestId: string) => {
|
||||||
|
try {
|
||||||
|
const dbInstance = db.replicaNode();
|
||||||
|
const steps = await dbInstance(TableName.ApprovalRequestSteps).where({ requestId }).orderBy("stepNumber", "asc");
|
||||||
|
|
||||||
|
if (!steps.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const stepIds = steps.map((step) => step.id);
|
||||||
|
|
||||||
|
const [approvers, approvals] = await Promise.all([
|
||||||
|
dbInstance(TableName.ApprovalRequestStepEligibleApprovers)
|
||||||
|
.whereIn("stepId", stepIds)
|
||||||
|
.select("stepId", "userId", "groupId"),
|
||||||
|
dbInstance(TableName.ApprovalRequestApprovals).whereIn("stepId", stepIds)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const approversByStepId = approvers.reduce<Record<string, { type: ApproverType; id: string }[]>>(
|
||||||
|
(acc, approver) => {
|
||||||
|
const stepApprovers = acc[approver.stepId] || [];
|
||||||
|
stepApprovers.push({
|
||||||
|
type: approver.userId ? ApproverType.User : ApproverType.Group,
|
||||||
|
id: (approver.userId || approver.groupId) as string
|
||||||
|
});
|
||||||
|
acc[approver.stepId] = stepApprovers;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
const approvalsByStepId = approvals.reduce<Record<string, TApprovalRequestApprovals[]>>((acc, approval) => {
|
||||||
|
const stepApprovals = acc[approval.stepId] || [];
|
||||||
|
stepApprovals.push(approval);
|
||||||
|
acc[approval.stepId] = stepApprovals;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return steps.map((step) => {
|
||||||
|
return {
|
||||||
|
...step,
|
||||||
|
approvers: approversByStepId[step.id] || [],
|
||||||
|
approvals: approvalsByStepId[step.id] || []
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw new DatabaseError({ error, name: "Find approval request steps" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const findByProjectId = async (policyType: ApprovalPolicyType, projectId: string) => {
|
||||||
|
try {
|
||||||
|
const dbInstance = db.replicaNode();
|
||||||
|
const requests = await dbInstance(TableName.ApprovalRequests).where({ type: policyType, projectId });
|
||||||
|
|
||||||
|
if (!requests.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestIds = requests.map((req) => req.id);
|
||||||
|
|
||||||
|
const steps = await dbInstance(TableName.ApprovalRequestSteps)
|
||||||
|
.whereIn("requestId", requestIds)
|
||||||
|
.orderBy("stepNumber", "asc");
|
||||||
|
|
||||||
|
const stepsByRequestId: Record<string, any[]> = {};
|
||||||
|
|
||||||
|
if (steps.length) {
|
||||||
|
const stepIds = steps.map((step) => step.id);
|
||||||
|
|
||||||
|
const [approvers, approvals] = await Promise.all([
|
||||||
|
dbInstance(TableName.ApprovalRequestStepEligibleApprovers)
|
||||||
|
.whereIn("stepId", stepIds)
|
||||||
|
.select("stepId", "userId", "groupId"),
|
||||||
|
dbInstance(TableName.ApprovalRequestApprovals).whereIn("stepId", stepIds)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const approversByStepId = approvers.reduce<Record<string, { type: ApproverType; id: string }[]>>(
|
||||||
|
(acc, approver) => {
|
||||||
|
const stepApprovers = acc[approver.stepId] || [];
|
||||||
|
stepApprovers.push({
|
||||||
|
type: approver.userId ? ApproverType.User : ApproverType.Group,
|
||||||
|
id: (approver.userId || approver.groupId) as string
|
||||||
|
});
|
||||||
|
acc[approver.stepId] = stepApprovers;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
const approvalsByStepId = approvals.reduce<Record<string, TApprovalRequestApprovals[]>>((acc, approval) => {
|
||||||
|
const stepApprovals = acc[approval.stepId] || [];
|
||||||
|
stepApprovals.push(approval);
|
||||||
|
acc[approval.stepId] = stepApprovals;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
steps.forEach((step) => {
|
||||||
|
const formattedStep = {
|
||||||
|
...step,
|
||||||
|
approvers: approversByStepId[step.id] || [],
|
||||||
|
approvals: approvalsByStepId[step.id] || []
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!stepsByRequestId[step.requestId]) {
|
||||||
|
stepsByRequestId[step.requestId] = [];
|
||||||
|
}
|
||||||
|
stepsByRequestId[step.requestId].push(formattedStep);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return requests.map((req) => ({
|
||||||
|
...req,
|
||||||
|
steps: stepsByRequestId[req.id] || []
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
throw new DatabaseError({ error, name: "Find approval requests by project id" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { ...orm, findStepsByRequestId, findByProjectId };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Approval Request Steps
|
||||||
|
export type TApprovalRequestStepsDALFactory = ReturnType<typeof approvalRequestStepsDALFactory>;
|
||||||
|
export const approvalRequestStepsDALFactory = (db: TDbClient) => {
|
||||||
|
const orm = ormify(db, TableName.ApprovalRequestSteps);
|
||||||
|
return orm;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Approval Request Step Eligible Approvers
|
||||||
|
export type TApprovalRequestStepEligibleApproversDALFactory = ReturnType<
|
||||||
|
typeof approvalRequestStepEligibleApproversDALFactory
|
||||||
|
>;
|
||||||
|
export const approvalRequestStepEligibleApproversDALFactory = (db: TDbClient) => {
|
||||||
|
const orm = ormify(db, TableName.ApprovalRequestStepEligibleApprovers);
|
||||||
|
return orm;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Approval Request Grants
|
||||||
export type TApprovalRequestGrantsDALFactory = ReturnType<typeof approvalRequestGrantsDALFactory>;
|
export type TApprovalRequestGrantsDALFactory = ReturnType<typeof approvalRequestGrantsDALFactory>;
|
||||||
export const approvalRequestGrantsDALFactory = (db: TDbClient) => {
|
export const approvalRequestGrantsDALFactory = (db: TDbClient) => {
|
||||||
const orm = ormify(db, TableName.ApprovalRequestGrants);
|
const orm = ormify(db, TableName.ApprovalRequestGrants);
|
||||||
return orm;
|
return orm;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Approval Request Approvals
|
||||||
|
export type TApprovalRequestApprovalsDALFactory = ReturnType<typeof approvalRequestApprovalsDALFactory>;
|
||||||
|
export const approvalRequestApprovalsDALFactory = (db: TDbClient) => {
|
||||||
|
const orm = ormify(db, TableName.ApprovalRequestApprovals);
|
||||||
|
return orm;
|
||||||
|
};
|
||||||
|
|||||||
@@ -7,6 +7,25 @@ export enum ApproverType {
|
|||||||
User = "user"
|
User = "user"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum ApprovalRequestStatus {
|
||||||
|
Pending = "pending",
|
||||||
|
Approved = "approved",
|
||||||
|
Rejected = "rejected",
|
||||||
|
Expired = "expired",
|
||||||
|
Cancelled = "cancelled"
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ApprovalRequestStepStatus {
|
||||||
|
Pending = "pending",
|
||||||
|
InProgress = "in-progress",
|
||||||
|
Completed = "completed"
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ApprovalRequestApprovalDecision {
|
||||||
|
Approved = "approved",
|
||||||
|
Rejected = "rejected"
|
||||||
|
}
|
||||||
|
|
||||||
export enum ApprovalRequestGrantStatus {
|
export enum ApprovalRequestGrantStatus {
|
||||||
Active = "active",
|
Active = "active",
|
||||||
Expired = "expired",
|
Expired = "expired",
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
import { ApprovalPolicyType } from "./approval-policy-enums";
|
import { ApprovalPolicyType } from "./approval-policy-enums";
|
||||||
import { TApprovalPolicy, TApprovalPolicyInputs, TApprovalResourceFactory } from "./approval-policy-types";
|
import {
|
||||||
|
TApprovalPolicy,
|
||||||
|
TApprovalPolicyInputs,
|
||||||
|
TApprovalRequestData,
|
||||||
|
TApprovalResourceFactory
|
||||||
|
} from "./approval-policy-types";
|
||||||
import { pamAccessPolicyFactory } from "./pam-access/pam-access-policy-factory";
|
import { pamAccessPolicyFactory } from "./pam-access/pam-access-policy-factory";
|
||||||
|
|
||||||
type TApprovalPolicyFactoryImplementation = TApprovalResourceFactory<TApprovalPolicyInputs, TApprovalPolicy>;
|
type TApprovalPolicyFactoryImplementation = TApprovalResourceFactory<
|
||||||
|
TApprovalPolicyInputs,
|
||||||
|
TApprovalPolicy,
|
||||||
|
TApprovalRequestData
|
||||||
|
>;
|
||||||
|
|
||||||
export const APPROVAL_POLICY_FACTORY_MAP: Record<ApprovalPolicyType, TApprovalPolicyFactoryImplementation> = {
|
export const APPROVAL_POLICY_FACTORY_MAP: Record<ApprovalPolicyType, TApprovalPolicyFactoryImplementation> = {
|
||||||
[ApprovalPolicyType.PamAccess]: pamAccessPolicyFactory as TApprovalPolicyFactoryImplementation
|
[ApprovalPolicyType.PamAccess]: pamAccessPolicyFactory as TApprovalPolicyFactoryImplementation
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { ApprovalPoliciesSchema } from "@app/db/schemas";
|
import {
|
||||||
|
ApprovalPoliciesSchema,
|
||||||
|
ApprovalRequestApprovalsSchema,
|
||||||
|
ApprovalRequestsSchema,
|
||||||
|
ApprovalRequestStepsSchema
|
||||||
|
} from "@app/db/schemas";
|
||||||
|
|
||||||
import { ApproverType } from "./approval-policy-enums";
|
import { ApproverType } from "./approval-policy-enums";
|
||||||
|
|
||||||
const ApprovalPolicyStepSchema = z.object({
|
const ApprovalPolicyStepSchema = z.object({
|
||||||
name: z.string().min(1).max(128).nullable().optional(),
|
name: z.string().min(1).max(128).nullable().optional(),
|
||||||
requiredApprovals: z.number().min(1).max(100),
|
requiredApprovals: z.number().min(1).max(100),
|
||||||
notifyApprovers: z.boolean().optional(),
|
notifyApprovers: z.boolean().nullable().optional(),
|
||||||
approvers: z
|
approvers: z
|
||||||
.object({
|
.object({
|
||||||
type: z.nativeEnum(ApproverType),
|
type: z.nativeEnum(ApproverType),
|
||||||
@@ -16,6 +21,7 @@ const ApprovalPolicyStepSchema = z.object({
|
|||||||
.array()
|
.array()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Policy
|
||||||
export const BaseApprovalPolicySchema = ApprovalPoliciesSchema.extend({
|
export const BaseApprovalPolicySchema = ApprovalPoliciesSchema.extend({
|
||||||
steps: ApprovalPolicyStepSchema.array()
|
steps: ApprovalPolicyStepSchema.array()
|
||||||
});
|
});
|
||||||
@@ -32,3 +38,31 @@ export const BaseUpdateApprovalPolicySchema = z.object({
|
|||||||
maxRequestTtlSeconds: z.number().min(3600).max(2592000).nullable().optional(), // 1 hour to 30 days
|
maxRequestTtlSeconds: z.number().min(3600).max(2592000).nullable().optional(), // 1 hour to 30 days
|
||||||
steps: ApprovalPolicyStepSchema.array().optional()
|
steps: ApprovalPolicyStepSchema.array().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Request
|
||||||
|
const ApprovalRequestStepSchema = ApprovalRequestStepsSchema.extend({
|
||||||
|
name: z.string().min(1).max(128).nullable().optional(),
|
||||||
|
requiredApprovals: z.number().min(1).max(100),
|
||||||
|
notifyApprovers: z.boolean().nullable().optional(),
|
||||||
|
stepNumber: z.number(),
|
||||||
|
status: z.string(),
|
||||||
|
startedAt: z.date().nullable().optional(),
|
||||||
|
completedAt: z.date().nullable().optional(),
|
||||||
|
approvers: z
|
||||||
|
.object({
|
||||||
|
type: z.nativeEnum(ApproverType),
|
||||||
|
id: z.string().uuid()
|
||||||
|
})
|
||||||
|
.array(),
|
||||||
|
approvals: ApprovalRequestApprovalsSchema.array()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BaseApprovalRequestSchema = ApprovalRequestsSchema.extend({
|
||||||
|
steps: ApprovalRequestStepSchema.array()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BaseCreateApprovalRequestSchema = z.object({
|
||||||
|
projectId: z.string().uuid(),
|
||||||
|
justification: z.string().max(256).nullable().optional(),
|
||||||
|
expiresAt: z.coerce.date().nullable().optional()
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,21 +1,47 @@
|
|||||||
import { ActionProjectType, ProjectMembershipRole, TApprovalPolicies } from "@app/db/schemas";
|
import { ActionProjectType, ProjectMembershipRole, TApprovalPolicies, TApprovalRequests } from "@app/db/schemas";
|
||||||
|
import { TUserGroupMembershipDALFactory } from "@app/ee/services/group/user-group-membership-dal";
|
||||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
|
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
|
||||||
import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors";
|
import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors";
|
||||||
import { OrgServiceActor } from "@app/lib/types";
|
import { OrgServiceActor } from "@app/lib/types";
|
||||||
|
import { TNotificationServiceFactory } from "@app/services/notification/notification-service";
|
||||||
|
import { NotificationType } from "@app/services/notification/notification-types";
|
||||||
|
|
||||||
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
|
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
|
||||||
import {
|
import {
|
||||||
TApprovalPolicyDALFactory,
|
TApprovalPolicyDALFactory,
|
||||||
TApprovalPolicyStepApproversDALFactory,
|
TApprovalPolicyStepApproversDALFactory,
|
||||||
TApprovalPolicyStepsDALFactory
|
TApprovalPolicyStepsDALFactory,
|
||||||
|
TApprovalRequestApprovalsDALFactory,
|
||||||
|
TApprovalRequestDALFactory,
|
||||||
|
TApprovalRequestStepEligibleApproversDALFactory,
|
||||||
|
TApprovalRequestStepsDALFactory
|
||||||
} from "./approval-policy-dal";
|
} from "./approval-policy-dal";
|
||||||
import { ApprovalPolicyType, ApproverType } from "./approval-policy-enums";
|
import {
|
||||||
import { TCreatePolicyDTO, TUpdatePolicyDTO } from "./approval-policy-types";
|
ApprovalPolicyType,
|
||||||
|
ApprovalRequestApprovalDecision,
|
||||||
|
ApprovalRequestStatus,
|
||||||
|
ApprovalRequestStepStatus,
|
||||||
|
ApproverType
|
||||||
|
} from "./approval-policy-enums";
|
||||||
|
import { APPROVAL_POLICY_FACTORY_MAP } from "./approval-policy-factory";
|
||||||
|
import {
|
||||||
|
ApprovalPolicyStep,
|
||||||
|
TApprovalRequest,
|
||||||
|
TCreatePolicyDTO,
|
||||||
|
TCreateRequestDTO,
|
||||||
|
TUpdatePolicyDTO
|
||||||
|
} from "./approval-policy-types";
|
||||||
|
|
||||||
type TApprovalPolicyServiceFactoryDep = {
|
type TApprovalPolicyServiceFactoryDep = {
|
||||||
approvalPolicyDAL: TApprovalPolicyDALFactory;
|
approvalPolicyDAL: TApprovalPolicyDALFactory;
|
||||||
approvalPolicyStepsDAL: TApprovalPolicyStepsDALFactory;
|
approvalPolicyStepsDAL: TApprovalPolicyStepsDALFactory;
|
||||||
approvalPolicyStepApproversDAL: TApprovalPolicyStepApproversDALFactory;
|
approvalPolicyStepApproversDAL: TApprovalPolicyStepApproversDALFactory;
|
||||||
|
approvalRequestApprovalsDAL: TApprovalRequestApprovalsDALFactory;
|
||||||
|
approvalRequestDAL: TApprovalRequestDALFactory;
|
||||||
|
approvalRequestStepsDAL: TApprovalRequestStepsDALFactory;
|
||||||
|
approvalRequestStepEligibleApproversDAL: TApprovalRequestStepEligibleApproversDALFactory;
|
||||||
|
userGroupMembershipDAL: TUserGroupMembershipDALFactory;
|
||||||
|
notificationService: TNotificationServiceFactory;
|
||||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">;
|
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">;
|
||||||
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findProjectMembershipsByUserIds">;
|
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findProjectMembershipsByUserIds">;
|
||||||
};
|
};
|
||||||
@@ -25,9 +51,43 @@ export const approvalPolicyServiceFactory = ({
|
|||||||
approvalPolicyDAL,
|
approvalPolicyDAL,
|
||||||
approvalPolicyStepsDAL,
|
approvalPolicyStepsDAL,
|
||||||
approvalPolicyStepApproversDAL,
|
approvalPolicyStepApproversDAL,
|
||||||
|
approvalRequestApprovalsDAL,
|
||||||
|
approvalRequestDAL,
|
||||||
|
approvalRequestStepsDAL,
|
||||||
|
approvalRequestStepEligibleApproversDAL,
|
||||||
|
userGroupMembershipDAL,
|
||||||
|
notificationService,
|
||||||
permissionService,
|
permissionService,
|
||||||
projectMembershipDAL
|
projectMembershipDAL
|
||||||
}: TApprovalPolicyServiceFactoryDep) => {
|
}: TApprovalPolicyServiceFactoryDep) => {
|
||||||
|
const $notifyApproversForStep = async (step: ApprovalPolicyStep, request: TApprovalRequests) => {
|
||||||
|
if (!step.notifyApprovers) return;
|
||||||
|
|
||||||
|
const userIdsToNotify = new Set<string>();
|
||||||
|
|
||||||
|
for (const approver of step.approvers) {
|
||||||
|
if (approver.type === ApproverType.User) {
|
||||||
|
userIdsToNotify.add(approver.id);
|
||||||
|
} else if (approver.type === ApproverType.Group) {
|
||||||
|
const members = await userGroupMembershipDAL.find({ groupId: approver.id });
|
||||||
|
members.forEach((member) => userIdsToNotify.add(member.userId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userIdsToNotify.size === 0) return;
|
||||||
|
|
||||||
|
// TODO: Potentially link to requests in the future?
|
||||||
|
await notificationService.createUserNotifications(
|
||||||
|
Array.from(userIdsToNotify).map((userId) => ({
|
||||||
|
userId,
|
||||||
|
orgId: request.organizationId,
|
||||||
|
type: NotificationType.APPROVAL_REQUIRED,
|
||||||
|
title: "Approval Required",
|
||||||
|
body: `You have a new approval request for ${request.type} from ${request.requesterName}.`
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const $verifyProjectUserMembership = async (userIds: string[], orgId: string, projectId: string) => {
|
const $verifyProjectUserMembership = async (userIds: string[], orgId: string, projectId: string) => {
|
||||||
const uniqueUserIds = [...new Set(userIds)];
|
const uniqueUserIds = [...new Set(userIds)];
|
||||||
if (uniqueUserIds.length === 0) return;
|
if (uniqueUserIds.length === 0) return;
|
||||||
@@ -287,11 +347,339 @@ export const approvalPolicyServiceFactory = ({
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const createRequest = async (
|
||||||
|
policyType: ApprovalPolicyType,
|
||||||
|
{
|
||||||
|
projectId,
|
||||||
|
requestData,
|
||||||
|
expiresAt,
|
||||||
|
justification,
|
||||||
|
requesterName,
|
||||||
|
requesterEmail
|
||||||
|
}: TCreateRequestDTO & {
|
||||||
|
requesterName: string;
|
||||||
|
requesterEmail: string;
|
||||||
|
},
|
||||||
|
actor: OrgServiceActor
|
||||||
|
) => {
|
||||||
|
// TODO(andrey): Perm check
|
||||||
|
|
||||||
|
const fac = APPROVAL_POLICY_FACTORY_MAP[policyType](policyType);
|
||||||
|
|
||||||
|
const policy = await fac.matchPolicy(approvalPolicyDAL, projectId, requestData);
|
||||||
|
|
||||||
|
if (!policy) {
|
||||||
|
throw new ForbiddenRequestError({ message: "Policy not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fac.validateConstraints(policy, requestData)) {
|
||||||
|
throw new ForbiddenRequestError({ message: "Policy constraints not met" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expiresAt) {
|
||||||
|
const now = new Date();
|
||||||
|
const ttlSeconds = (new Date(expiresAt).getTime() - now.getTime()) / 1000;
|
||||||
|
|
||||||
|
if (ttlSeconds < 3600) {
|
||||||
|
throw new BadRequestError({ message: "Expiration time must be at least 1 hour in the future" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (policy.maxRequestTtlSeconds && ttlSeconds > policy.maxRequestTtlSeconds) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: `Expiration time exceeds the maximum allowed TTL of ${policy.maxRequestTtlSeconds} seconds`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { request, steps } = await approvalRequestDAL.transaction(async (tx) => {
|
||||||
|
const newRequest = await approvalRequestDAL.create(
|
||||||
|
{
|
||||||
|
projectId,
|
||||||
|
organizationId: actor.orgId,
|
||||||
|
policyId: policy.id,
|
||||||
|
requesterId: actor.id,
|
||||||
|
requesterName,
|
||||||
|
requesterEmail,
|
||||||
|
type: policyType,
|
||||||
|
status: ApprovalRequestStatus.Pending,
|
||||||
|
justification,
|
||||||
|
currentStep: 1,
|
||||||
|
requestData: { version: 1, requestData },
|
||||||
|
expiresAt
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
const newSteps = await Promise.all(
|
||||||
|
policy.steps.map(async (step, i) => {
|
||||||
|
const stepNum = i + 1;
|
||||||
|
const newStep = await approvalRequestStepsDAL.create(
|
||||||
|
{
|
||||||
|
requestId: newRequest.id,
|
||||||
|
stepNumber: stepNum,
|
||||||
|
name: step.name,
|
||||||
|
status: stepNum === 1 ? ApprovalRequestStepStatus.InProgress : ApprovalRequestStepStatus.Pending,
|
||||||
|
requiredApprovals: step.requiredApprovals,
|
||||||
|
notifyApprovers: step.notifyApprovers,
|
||||||
|
startedAt: stepNum === 1 ? new Date() : null
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
step.approvers.map((approver) =>
|
||||||
|
approvalRequestStepEligibleApproversDAL.create(
|
||||||
|
{
|
||||||
|
stepId: newStep.id,
|
||||||
|
userId: approver.type === ApproverType.User ? approver.id : null,
|
||||||
|
groupId: approver.type === ApproverType.Group ? approver.id : null
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...newStep,
|
||||||
|
approvers: step.approvers,
|
||||||
|
approvals: []
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return { request: newRequest, steps: newSteps };
|
||||||
|
});
|
||||||
|
|
||||||
|
if (steps.length > 0) {
|
||||||
|
await $notifyApproversForStep(steps[0], request);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
request: { ...request, steps }
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRequestById = async (requestId: string, _actor: OrgServiceActor) => {
|
||||||
|
// TODO(andrey): Perm check
|
||||||
|
|
||||||
|
const request = await approvalRequestDAL.findById(requestId);
|
||||||
|
if (!request) {
|
||||||
|
throw new ForbiddenRequestError({ message: "Request not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const steps = await approvalRequestDAL.findStepsByRequestId(requestId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
request: { ...request, steps }
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const approveRequest = async (requestId: string, { comment }: { comment?: string }, actor: OrgServiceActor) => {
|
||||||
|
const request = await approvalRequestDAL.findById(requestId);
|
||||||
|
if (!request) {
|
||||||
|
throw new ForbiddenRequestError({ message: "Request not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.status !== ApprovalRequestStatus.Pending) {
|
||||||
|
throw new BadRequestError({ message: "Request is not pending" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.expiresAt && new Date(request.expiresAt) < new Date()) {
|
||||||
|
await approvalRequestDAL.updateById(requestId, { status: ApprovalRequestStatus.Expired });
|
||||||
|
throw new BadRequestError({ message: "Request has expired" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const steps = await approvalRequestDAL.findStepsByRequestId(requestId);
|
||||||
|
const currentStepIndex = steps.findIndex((s) => s.stepNumber === request.currentStep);
|
||||||
|
if (currentStepIndex === -1) {
|
||||||
|
throw new BadRequestError({ message: "Current step not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentStep = steps[currentStepIndex];
|
||||||
|
|
||||||
|
const userGroups = await userGroupMembershipDAL.findGroupMembershipsByUserIdInOrg(actor.id, actor.orgId);
|
||||||
|
const userGroupIds = new Set(userGroups.map((g) => g.groupId));
|
||||||
|
|
||||||
|
const isEligible = currentStep.approvers.some(
|
||||||
|
(approver) =>
|
||||||
|
(approver.type === ApproverType.User && approver.id === actor.id) ||
|
||||||
|
(approver.type === ApproverType.Group && userGroupIds.has(approver.id))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isEligible) {
|
||||||
|
throw new ForbiddenRequestError({ message: "You are not an eligible approver for this step" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasApproved = currentStep.approvals.some((a) => a.approverUserId === actor.id);
|
||||||
|
if (hasApproved) {
|
||||||
|
throw new BadRequestError({ message: "You have already approved this request" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { updatedRequest, nextStepToNotify } = await approvalRequestDAL.transaction(async (tx) => {
|
||||||
|
let nextStepToNotify = null;
|
||||||
|
|
||||||
|
// Create approval
|
||||||
|
await approvalRequestApprovalsDAL.create(
|
||||||
|
{
|
||||||
|
stepId: currentStep.id,
|
||||||
|
approverUserId: actor.id,
|
||||||
|
decision: ApprovalRequestApprovalDecision.Approved,
|
||||||
|
comment
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
const newApprovalCount = currentStep.approvals.length + 1;
|
||||||
|
if (newApprovalCount >= currentStep.requiredApprovals) {
|
||||||
|
// Step completed
|
||||||
|
await approvalRequestStepsDAL.updateById(
|
||||||
|
currentStep.id,
|
||||||
|
{
|
||||||
|
status: ApprovalRequestStepStatus.Completed,
|
||||||
|
completedAt: new Date()
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
const nextStep = steps[currentStepIndex + 1];
|
||||||
|
if (nextStep) {
|
||||||
|
// Move to next step
|
||||||
|
await approvalRequestDAL.updateById(
|
||||||
|
requestId,
|
||||||
|
{
|
||||||
|
currentStep: request.currentStep + 1
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
await approvalRequestStepsDAL.updateById(
|
||||||
|
nextStep.id,
|
||||||
|
{
|
||||||
|
status: ApprovalRequestStepStatus.InProgress,
|
||||||
|
startedAt: new Date()
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
if (nextStep.notifyApprovers) {
|
||||||
|
nextStepToNotify = nextStep;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// All steps completed
|
||||||
|
const completedReq = await approvalRequestDAL.updateById(
|
||||||
|
requestId,
|
||||||
|
{
|
||||||
|
status: ApprovalRequestStatus.Approved
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
return { updatedRequest: completedReq, nextStepToNotify: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { updatedRequest: request, nextStepToNotify };
|
||||||
|
});
|
||||||
|
|
||||||
|
if (nextStepToNotify) {
|
||||||
|
await $notifyApproversForStep(nextStepToNotify, updatedRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch fresh state
|
||||||
|
const finalSteps = await approvalRequestDAL.findStepsByRequestId(requestId);
|
||||||
|
const finalRequest = await approvalRequestDAL.findById(requestId);
|
||||||
|
|
||||||
|
const newRequest = { ...finalRequest, steps: finalSteps };
|
||||||
|
|
||||||
|
if (updatedRequest.status === ApprovalRequestStatus.Approved) {
|
||||||
|
const fac = APPROVAL_POLICY_FACTORY_MAP[updatedRequest.type as ApprovalPolicyType](
|
||||||
|
updatedRequest.type as ApprovalPolicyType
|
||||||
|
);
|
||||||
|
await fac.postApprovalRoutine(newRequest as TApprovalRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { request: newRequest };
|
||||||
|
};
|
||||||
|
|
||||||
|
const rejectRequest = async (requestId: string, { comment }: { comment?: string }, actor: OrgServiceActor) => {
|
||||||
|
const request = await approvalRequestDAL.findById(requestId);
|
||||||
|
if (!request) {
|
||||||
|
throw new ForbiddenRequestError({ message: "Request not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.status !== ApprovalRequestStatus.Pending) {
|
||||||
|
throw new BadRequestError({ message: "Request is not pending" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.expiresAt && new Date(request.expiresAt) < new Date()) {
|
||||||
|
await approvalRequestDAL.updateById(requestId, { status: ApprovalRequestStatus.Expired });
|
||||||
|
throw new BadRequestError({ message: "Request has expired" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const steps = await approvalRequestDAL.findStepsByRequestId(requestId);
|
||||||
|
const currentStep = steps.find((s) => s.stepNumber === request.currentStep);
|
||||||
|
|
||||||
|
if (!currentStep) {
|
||||||
|
throw new BadRequestError({ message: "Current step not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userGroups = await userGroupMembershipDAL.findGroupMembershipsByUserIdInOrg(actor.id, actor.orgId);
|
||||||
|
const userGroupIds = new Set(userGroups.map((g) => g.groupId));
|
||||||
|
|
||||||
|
const isEligible = currentStep.approvers.some(
|
||||||
|
(approver) =>
|
||||||
|
(approver.type === ApproverType.User && approver.id === actor.id) ||
|
||||||
|
(approver.type === ApproverType.Group && userGroupIds.has(approver.id))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isEligible) {
|
||||||
|
throw new ForbiddenRequestError({ message: "You are not an eligible approver for this step" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await approvalRequestDAL.transaction(async (tx) => {
|
||||||
|
await approvalRequestApprovalsDAL.create(
|
||||||
|
{
|
||||||
|
stepId: currentStep.id,
|
||||||
|
approverUserId: actor.id,
|
||||||
|
decision: ApprovalRequestApprovalDecision.Rejected,
|
||||||
|
comment
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
await approvalRequestDAL.updateById(
|
||||||
|
requestId,
|
||||||
|
{
|
||||||
|
status: ApprovalRequestStatus.Rejected
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const finalSteps = await approvalRequestDAL.findStepsByRequestId(requestId);
|
||||||
|
const finalRequest = await approvalRequestDAL.findById(requestId);
|
||||||
|
|
||||||
|
return { request: { ...finalRequest, steps: finalSteps } };
|
||||||
|
};
|
||||||
|
|
||||||
|
const listRequests = async (policyType: ApprovalPolicyType, projectId: string, actor: OrgServiceActor) => {
|
||||||
|
// TODO(andrey): Perm check
|
||||||
|
|
||||||
|
const requests = await approvalRequestDAL.findByProjectId(policyType, projectId);
|
||||||
|
|
||||||
|
return { requests };
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
create,
|
create,
|
||||||
list,
|
list,
|
||||||
getById,
|
getById,
|
||||||
updateById,
|
updateById,
|
||||||
deleteById
|
deleteById,
|
||||||
|
createRequest,
|
||||||
|
listRequests,
|
||||||
|
getRequestById,
|
||||||
|
approveRequest,
|
||||||
|
rejectRequest
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import {
|
|||||||
TPamAccessPolicy,
|
TPamAccessPolicy,
|
||||||
TPamAccessPolicyConditions,
|
TPamAccessPolicyConditions,
|
||||||
TPamAccessPolicyConstraints,
|
TPamAccessPolicyConstraints,
|
||||||
TPamAccessPolicyInputs
|
TPamAccessPolicyInputs,
|
||||||
|
TPamAccessRequest,
|
||||||
|
TPamAccessRequestData
|
||||||
} from "./pam-access/pam-access-policy-types";
|
} from "./pam-access/pam-access-policy-types";
|
||||||
|
|
||||||
export type TApprovalPolicy = TPamAccessPolicy;
|
export type TApprovalPolicy = TPamAccessPolicy;
|
||||||
@@ -16,17 +18,20 @@ export type TApprovalPolicyInputs = TPamAccessPolicyInputs;
|
|||||||
export type TApprovalPolicyConditions = TPamAccessPolicyConditions;
|
export type TApprovalPolicyConditions = TPamAccessPolicyConditions;
|
||||||
export type TApprovalPolicyConstraints = TPamAccessPolicyConstraints;
|
export type TApprovalPolicyConstraints = TPamAccessPolicyConstraints;
|
||||||
|
|
||||||
|
export type TApprovalRequest = TPamAccessRequest;
|
||||||
|
export type TApprovalRequestData = TPamAccessRequestData;
|
||||||
|
|
||||||
export interface ApprovalPolicyStep {
|
export interface ApprovalPolicyStep {
|
||||||
name?: string | null;
|
name?: string | null;
|
||||||
requiredApprovals: number;
|
requiredApprovals: number;
|
||||||
notifyApprovers?: boolean;
|
notifyApprovers?: boolean | null;
|
||||||
approvers: {
|
approvers: {
|
||||||
type: ApproverType;
|
type: ApproverType;
|
||||||
id: string;
|
id: string;
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// DTOs
|
// Policy DTOs
|
||||||
export interface TCreatePolicyDTO {
|
export interface TCreatePolicyDTO {
|
||||||
projectId: TApprovalPolicy["projectId"];
|
projectId: TApprovalPolicy["projectId"];
|
||||||
name: TApprovalPolicy["name"];
|
name: TApprovalPolicy["name"];
|
||||||
@@ -44,6 +49,14 @@ export interface TUpdatePolicyDTO {
|
|||||||
steps?: ApprovalPolicyStep[];
|
steps?: ApprovalPolicyStep[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Request DTOs
|
||||||
|
export interface TCreateRequestDTO {
|
||||||
|
projectId: TApprovalRequest["projectId"];
|
||||||
|
requestData: TApprovalRequest["requestData"]["requestData"];
|
||||||
|
justification?: TApprovalRequest["justification"];
|
||||||
|
expiresAt?: TApprovalRequest["expiresAt"];
|
||||||
|
}
|
||||||
|
|
||||||
// Factory
|
// Factory
|
||||||
export type TApprovalRequestFactoryMatchPolicy<I extends TApprovalPolicyInputs, P extends TApprovalPolicy> = (
|
export type TApprovalRequestFactoryMatchPolicy<I extends TApprovalPolicyInputs, P extends TApprovalPolicy> = (
|
||||||
approvalPolicyDAL: TApprovalPolicyDALFactory,
|
approvalPolicyDAL: TApprovalPolicyDALFactory,
|
||||||
@@ -56,10 +69,19 @@ export type TApprovalRequestFactoryCanAccess<I extends TApprovalPolicyInputs> =
|
|||||||
userId: string,
|
userId: string,
|
||||||
inputs: I
|
inputs: I
|
||||||
) => Promise<boolean>;
|
) => Promise<boolean>;
|
||||||
|
export type TApprovalRequestFactoryValidateConstraints<P extends TApprovalPolicy, R extends TApprovalRequestData> = (
|
||||||
|
policy: P,
|
||||||
|
inputs: R
|
||||||
|
) => boolean;
|
||||||
|
export type TApprovalRequestFactoryPostApprovalRoutine = (request: TApprovalRequest) => Promise<void>;
|
||||||
|
|
||||||
export type TApprovalResourceFactory<I extends TApprovalPolicyInputs, P extends TApprovalPolicy> = (
|
export type TApprovalResourceFactory<
|
||||||
policyType: ApprovalPolicyType
|
I extends TApprovalPolicyInputs,
|
||||||
) => {
|
P extends TApprovalPolicy,
|
||||||
|
R extends TApprovalRequestData
|
||||||
|
> = (policyType: ApprovalPolicyType) => {
|
||||||
matchPolicy: TApprovalRequestFactoryMatchPolicy<I, P>;
|
matchPolicy: TApprovalRequestFactoryMatchPolicy<I, P>;
|
||||||
canAccess: TApprovalRequestFactoryCanAccess<I>;
|
canAccess: TApprovalRequestFactoryCanAccess<I>;
|
||||||
|
validateConstraints: TApprovalRequestFactoryValidateConstraints<P, R>;
|
||||||
|
postApprovalRoutine: TApprovalRequestFactoryPostApprovalRoutine;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,22 +4,23 @@ import { ApprovalRequestGrantStatus } from "../approval-policy-enums";
|
|||||||
import {
|
import {
|
||||||
TApprovalRequestFactoryCanAccess,
|
TApprovalRequestFactoryCanAccess,
|
||||||
TApprovalRequestFactoryMatchPolicy,
|
TApprovalRequestFactoryMatchPolicy,
|
||||||
|
TApprovalRequestFactoryPostApprovalRoutine,
|
||||||
|
TApprovalRequestFactoryValidateConstraints,
|
||||||
TApprovalResourceFactory
|
TApprovalResourceFactory
|
||||||
} from "../approval-policy-types";
|
} from "../approval-policy-types";
|
||||||
import { TPamAccessPolicy, TPamAccessPolicyInputs } from "./pam-access-policy-types";
|
import { TPamAccessPolicy, TPamAccessPolicyInputs, TPamAccessRequestData } from "./pam-access-policy-types";
|
||||||
|
|
||||||
export const pamAccessPolicyFactory: TApprovalResourceFactory<TPamAccessPolicyInputs, TPamAccessPolicy> = (
|
export const pamAccessPolicyFactory: TApprovalResourceFactory<
|
||||||
policyType
|
TPamAccessPolicyInputs,
|
||||||
) => {
|
TPamAccessPolicy,
|
||||||
|
TPamAccessRequestData
|
||||||
|
> = (policyType) => {
|
||||||
const matchPolicy: TApprovalRequestFactoryMatchPolicy<TPamAccessPolicyInputs, TPamAccessPolicy> = async (
|
const matchPolicy: TApprovalRequestFactoryMatchPolicy<TPamAccessPolicyInputs, TPamAccessPolicy> = async (
|
||||||
approvalPolicyDAL,
|
approvalPolicyDAL,
|
||||||
projectId,
|
projectId,
|
||||||
inputs
|
inputs
|
||||||
) => {
|
) => {
|
||||||
const policies = await approvalPolicyDAL.find({
|
const policies = await approvalPolicyDAL.findByProjectId(policyType, projectId);
|
||||||
type: policyType,
|
|
||||||
projectId
|
|
||||||
});
|
|
||||||
|
|
||||||
let bestMatch: { policy: TPamAccessPolicy; wildcardCount: number; pathLength: number } | null = null;
|
let bestMatch: { policy: TPamAccessPolicy; wildcardCount: number; pathLength: number } | null = null;
|
||||||
|
|
||||||
@@ -32,7 +33,7 @@ export const pamAccessPolicyFactory: TApprovalResourceFactory<TPamAccessPolicyIn
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Find the most specific path pattern
|
// Find the most specific path pattern
|
||||||
// TODO: Make matching logic more advanced by accounting for wildcard positions
|
// TODO(andrey): Make matching logic more advanced by accounting for wildcard positions
|
||||||
for (const pathPattern of c.accountPaths) {
|
for (const pathPattern of c.accountPaths) {
|
||||||
if (picomatch(pathPattern)(inputs.accountPath)) {
|
if (picomatch(pathPattern)(inputs.accountPath)) {
|
||||||
const wildcardCount = (pathPattern.match(/\*/g) || []).length;
|
const wildcardCount = (pathPattern.match(/\*/g) || []).length;
|
||||||
@@ -67,7 +68,7 @@ export const pamAccessPolicyFactory: TApprovalResourceFactory<TPamAccessPolicyIn
|
|||||||
revokedAt: null
|
revokedAt: null
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: Move some of this check to be part of SQL query
|
// TODO(andrey): Move some of this check to be part of SQL query
|
||||||
return grants.some((grant) => {
|
return grants.some((grant) => {
|
||||||
const grantAttributes = grant.attributes as TPamAccessPolicyInputs;
|
const grantAttributes = grant.attributes as TPamAccessPolicyInputs;
|
||||||
const isMatch = picomatch(grantAttributes.accountPath);
|
const isMatch = picomatch(grantAttributes.accountPath);
|
||||||
@@ -79,8 +80,24 @@ export const pamAccessPolicyFactory: TApprovalResourceFactory<TPamAccessPolicyIn
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const validateConstraints: TApprovalRequestFactoryValidateConstraints<TPamAccessPolicy, TPamAccessRequestData> = (
|
||||||
|
policy,
|
||||||
|
inputs
|
||||||
|
) => {
|
||||||
|
const reqDuration = inputs.requestDurationSeconds;
|
||||||
|
const durationConstraint = policy.constraints.constraints.requestDurationSeconds;
|
||||||
|
|
||||||
|
return reqDuration >= durationConstraint.min && reqDuration <= durationConstraint.max;
|
||||||
|
};
|
||||||
|
|
||||||
|
const postApprovalRoutine: TApprovalRequestFactoryPostApprovalRoutine = async (_request) => {
|
||||||
|
// Placeholder
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
matchPolicy,
|
matchPolicy,
|
||||||
canAccess
|
canAccess,
|
||||||
|
validateConstraints,
|
||||||
|
postApprovalRoutine
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { z } from "zod";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
BaseApprovalPolicySchema,
|
BaseApprovalPolicySchema,
|
||||||
|
BaseApprovalRequestSchema,
|
||||||
BaseCreateApprovalPolicySchema,
|
BaseCreateApprovalPolicySchema,
|
||||||
|
BaseCreateApprovalRequestSchema,
|
||||||
BaseUpdateApprovalPolicySchema
|
BaseUpdateApprovalPolicySchema
|
||||||
} from "../approval-policy-schemas";
|
} from "../approval-policy-schemas";
|
||||||
|
|
||||||
@@ -16,19 +18,25 @@ export const PamAccessPolicyInputsSchema = z.object({
|
|||||||
export const PamAccessPolicyConditionsSchema = z
|
export const PamAccessPolicyConditionsSchema = z
|
||||||
.object({
|
.object({
|
||||||
resourceIds: z.string().uuid().array(),
|
resourceIds: z.string().uuid().array(),
|
||||||
accountPaths: z.string().array() // TODO: Add path & wildcard validation
|
accountPaths: z.string().array() // TODO(andrey): Add path & wildcard validation
|
||||||
})
|
})
|
||||||
.array();
|
.array();
|
||||||
|
|
||||||
// Constraints
|
// Constraints
|
||||||
export const PamAccessPolicyConstraintsSchema = z.object({
|
export const PamAccessPolicyConstraintsSchema = z.object({
|
||||||
requestDurationHours: z.object({
|
requestDurationSeconds: z.object({
|
||||||
// 168 hours = 7 days
|
min: z.number().min(30).max(604800),
|
||||||
min: z.number().min(0).max(168),
|
max: z.number().min(30).max(604800) // 30 seconds to 7 days
|
||||||
max: z.number().min(1).max(168)
|
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Request Data
|
||||||
|
export const PamAccessPolicyRequestDataSchema = z.object({
|
||||||
|
resourceId: z.string().uuid(),
|
||||||
|
accountPath: z.string(),
|
||||||
|
requestDurationSeconds: z.number().min(30).max(604800) // 30 seconds to 7 days
|
||||||
|
});
|
||||||
|
|
||||||
// Policy
|
// Policy
|
||||||
export const PamAccessPolicySchema = BaseApprovalPolicySchema.extend({
|
export const PamAccessPolicySchema = BaseApprovalPolicySchema.extend({
|
||||||
conditions: z.object({
|
conditions: z.object({
|
||||||
@@ -50,3 +58,15 @@ export const UpdatePamAccessPolicySchema = BaseUpdateApprovalPolicySchema.extend
|
|||||||
conditions: PamAccessPolicyConditionsSchema.optional(),
|
conditions: PamAccessPolicyConditionsSchema.optional(),
|
||||||
constraints: PamAccessPolicyConstraintsSchema.optional()
|
constraints: PamAccessPolicyConstraintsSchema.optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Request
|
||||||
|
export const PamAccessRequestSchema = BaseApprovalRequestSchema.extend({
|
||||||
|
requestData: z.object({
|
||||||
|
version: z.literal(1),
|
||||||
|
requestData: PamAccessPolicyRequestDataSchema
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CreatePamAccessRequestSchema = BaseCreateApprovalRequestSchema.extend({
|
||||||
|
requestData: PamAccessPolicyRequestDataSchema
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,10 +4,17 @@ import {
|
|||||||
PamAccessPolicyConditionsSchema,
|
PamAccessPolicyConditionsSchema,
|
||||||
PamAccessPolicyConstraintsSchema,
|
PamAccessPolicyConstraintsSchema,
|
||||||
PamAccessPolicyInputsSchema,
|
PamAccessPolicyInputsSchema,
|
||||||
PamAccessPolicySchema
|
PamAccessPolicyRequestDataSchema,
|
||||||
|
PamAccessPolicySchema,
|
||||||
|
PamAccessRequestSchema
|
||||||
} from "./pam-access-policy-schemas";
|
} from "./pam-access-policy-schemas";
|
||||||
|
|
||||||
|
// Policy
|
||||||
export type TPamAccessPolicy = z.infer<typeof PamAccessPolicySchema>;
|
export type TPamAccessPolicy = z.infer<typeof PamAccessPolicySchema>;
|
||||||
export type TPamAccessPolicyInputs = z.infer<typeof PamAccessPolicyInputsSchema>;
|
export type TPamAccessPolicyInputs = z.infer<typeof PamAccessPolicyInputsSchema>;
|
||||||
export type TPamAccessPolicyConditions = z.infer<typeof PamAccessPolicyConditionsSchema>;
|
export type TPamAccessPolicyConditions = z.infer<typeof PamAccessPolicyConditionsSchema>;
|
||||||
export type TPamAccessPolicyConstraints = z.infer<typeof PamAccessPolicyConstraintsSchema>;
|
export type TPamAccessPolicyConstraints = z.infer<typeof PamAccessPolicyConstraintsSchema>;
|
||||||
|
|
||||||
|
// Request
|
||||||
|
export type TPamAccessRequest = z.infer<typeof PamAccessRequestSchema>;
|
||||||
|
export type TPamAccessRequestData = z.infer<typeof PamAccessPolicyRequestDataSchema>;
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ export enum NotificationType {
|
|||||||
PROJECT_INVITATION = "project-invitation",
|
PROJECT_INVITATION = "project-invitation",
|
||||||
SECRET_SYNC_FAILED = "secret-sync-failed",
|
SECRET_SYNC_FAILED = "secret-sync-failed",
|
||||||
GATEWAY_HEALTH_ALERT = "gateway-health-alert",
|
GATEWAY_HEALTH_ALERT = "gateway-health-alert",
|
||||||
RELAY_HEALTH_ALERT = "relay-health-alert"
|
RELAY_HEALTH_ALERT = "relay-health-alert",
|
||||||
|
APPROVAL_REQUIRED = "approval-required"
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TCreateUserNotificationDTO {
|
export interface TCreateUserNotificationDTO {
|
||||||
|
|||||||
Reference in New Issue
Block a user