approval requests

This commit is contained in:
x032205
2025-12-05 20:38:41 -05:00
committed by =
parent e34df4c6e5
commit bb60fb2f08
15 changed files with 904 additions and 44 deletions

View File

@@ -108,6 +108,7 @@ export async function up(knex: Knex): Promise<void> {
t.string("status").notNullable().index();
t.integer("requiredApprovals").notNullable();
t.boolean("notifyApprovers").defaultTo(false);
t.timestamp("startedAt").nullable();
t.timestamp("completedAt").nullable();

View File

@@ -14,6 +14,7 @@ export const ApprovalRequestStepsSchema = z.object({
name: z.string().nullable().optional(),
status: z.string(),
requiredApprovals: z.number(),
notifyApprovers: z.boolean().default(false).nullable().optional(),
startedAt: z.date().nullable().optional(),
completedAt: z.date().nullable().optional()
});

View File

@@ -163,7 +163,11 @@ import {
approvalPolicyDALFactory,
approvalPolicyStepApproversDALFactory,
approvalPolicyStepsDALFactory,
approvalRequestGrantsDALFactory
approvalRequestApprovalsDALFactory,
approvalRequestDALFactory,
approvalRequestGrantsDALFactory,
approvalRequestStepEligibleApproversDALFactory,
approvalRequestStepsDALFactory
} from "@app/services/approval-policy/approval-policy-dal";
import { approvalPolicyServiceFactory } from "@app/services/approval-policy/approval-policy-service";
import { authDALFactory } from "@app/services/auth/auth-dal";
@@ -2465,13 +2469,23 @@ export const registerRoutes = async (
const approvalPolicyStepsDAL = approvalPolicyStepsDALFactory(db);
const approvalPolicyStepApproversDAL = approvalPolicyStepApproversDALFactory(db);
const approvalRequestDAL = approvalRequestDALFactory(db);
const approvalRequestStepsDAL = approvalRequestStepsDALFactory(db);
const approvalRequestStepEligibleApproversDAL = approvalRequestStepEligibleApproversDALFactory(db);
const approvalRequestApprovalsDAL = approvalRequestApprovalsDALFactory(db);
const approvalPolicyService = approvalPolicyServiceFactory({
approvalPolicyDAL,
approvalPolicyStepsDAL,
approvalPolicyStepApproversDAL,
permissionService,
projectMembershipDAL
projectMembershipDAL,
approvalRequestDAL,
approvalRequestStepsDAL,
approvalRequestStepEligibleApproversDAL,
approvalRequestApprovalsDAL,
userGroupMembershipDAL,
notificationService
});
// setup the communication with license key server

View File

@@ -1,11 +1,13 @@
import { z } from "zod";
import { BadRequestError } from "@app/lib/errors";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { ApprovalPolicyType } from "@app/services/approval-policy/approval-policy-enums";
import {
TApprovalPolicy,
TCreatePolicyDTO,
TCreateRequestDTO,
TUpdatePolicyDTO
} from "@app/services/approval-policy/approval-policy-types";
import { AuthMode } from "@app/services/auth/auth-type";
@@ -15,7 +17,9 @@ export const registerApprovalPolicyEndpoints = <P extends TApprovalPolicy>({
policyType,
createPolicySchema,
updatePolicySchema,
policyResponseSchema
policyResponseSchema,
createRequestSchema,
requestResponseSchema
}: {
server: FastifyZodProvider;
policyType: ApprovalPolicyType;
@@ -32,7 +36,10 @@ export const registerApprovalPolicyEndpoints = <P extends TApprovalPolicy>({
}
>;
policyResponseSchema: z.ZodTypeAny;
createRequestSchema: z.ZodType<TCreateRequestDTO>;
requestResponseSchema: z.ZodTypeAny;
}) => {
// Policies
server.route({
method: "POST",
url: "/",
@@ -52,7 +59,7 @@ export const registerApprovalPolicyEndpoints = <P extends TApprovalPolicy>({
handler: async (req) => {
const { policy } = await server.services.approvalPolicy.create(policyType, req.body, req.permission);
// TODO: Audit log
// TODO(andrey): Audit log
return { policy };
}
@@ -79,7 +86,7 @@ export const registerApprovalPolicyEndpoints = <P extends TApprovalPolicy>({
handler: async (req) => {
const { policies } = await server.services.approvalPolicy.list(policyType, req.query.projectId, req.permission);
// TODO: Audit log
// TODO(andrey): Audit log
return { policies };
}
@@ -106,7 +113,7 @@ export const registerApprovalPolicyEndpoints = <P extends TApprovalPolicy>({
handler: async (req) => {
const { policy } = await server.services.approvalPolicy.getById(req.params.policyId, req.permission);
// TODO: Audit log
// TODO(andrey): Audit log
return { policy };
}
@@ -134,7 +141,7 @@ export const registerApprovalPolicyEndpoints = <P extends TApprovalPolicy>({
handler: async (req) => {
const { policy } = await server.services.approvalPolicy.updateById(req.params.policyId, req.body, req.permission);
// TODO: Audit log
// TODO(andrey): Audit log
return { policy };
}
@@ -161,9 +168,174 @@ export const registerApprovalPolicyEndpoints = <P extends TApprovalPolicy>({
handler: async (req) => {
const { policyId } = await server.services.approvalPolicy.deleteById(req.params.policyId, req.permission);
// TODO: Audit log
// TODO(andrey): Audit log
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 };
}
});
};

View File

@@ -1,7 +1,9 @@
import { ApprovalPolicyType } from "@app/services/approval-policy/approval-policy-enums";
import {
CreatePamAccessPolicySchema,
CreatePamAccessRequestSchema,
PamAccessPolicySchema,
PamAccessRequestSchema,
UpdatePamAccessPolicySchema
} 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,
createPolicySchema: CreatePamAccessPolicySchema,
updatePolicySchema: UpdatePamAccessPolicySchema,
policyResponseSchema: PamAccessPolicySchema
policyResponseSchema: PamAccessPolicySchema,
createRequestSchema: CreatePamAccessRequestSchema,
requestResponseSchema: PamAccessRequestSchema
});
}
};

View File

@@ -1,5 +1,5 @@
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 { ormify } from "@app/lib/knex";
@@ -149,9 +149,160 @@ export const approvalPolicyStepApproversDALFactory = (db: TDbClient) => {
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 const approvalRequestGrantsDALFactory = (db: TDbClient) => {
const orm = ormify(db, TableName.ApprovalRequestGrants);
return orm;
};
// Approval Request Approvals
export type TApprovalRequestApprovalsDALFactory = ReturnType<typeof approvalRequestApprovalsDALFactory>;
export const approvalRequestApprovalsDALFactory = (db: TDbClient) => {
const orm = ormify(db, TableName.ApprovalRequestApprovals);
return orm;
};

View File

@@ -7,6 +7,25 @@ export enum ApproverType {
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 {
Active = "active",
Expired = "expired",

View File

@@ -1,8 +1,17 @@
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";
type TApprovalPolicyFactoryImplementation = TApprovalResourceFactory<TApprovalPolicyInputs, TApprovalPolicy>;
type TApprovalPolicyFactoryImplementation = TApprovalResourceFactory<
TApprovalPolicyInputs,
TApprovalPolicy,
TApprovalRequestData
>;
export const APPROVAL_POLICY_FACTORY_MAP: Record<ApprovalPolicyType, TApprovalPolicyFactoryImplementation> = {
[ApprovalPolicyType.PamAccess]: pamAccessPolicyFactory as TApprovalPolicyFactoryImplementation

View File

@@ -1,13 +1,18 @@
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";
const ApprovalPolicyStepSchema = z.object({
name: z.string().min(1).max(128).nullable().optional(),
requiredApprovals: z.number().min(1).max(100),
notifyApprovers: z.boolean().optional(),
notifyApprovers: z.boolean().nullable().optional(),
approvers: z
.object({
type: z.nativeEnum(ApproverType),
@@ -16,6 +21,7 @@ const ApprovalPolicyStepSchema = z.object({
.array()
});
// Policy
export const BaseApprovalPolicySchema = ApprovalPoliciesSchema.extend({
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
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()
});

View File

@@ -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 { BadRequestError, ForbiddenRequestError } from "@app/lib/errors";
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 {
TApprovalPolicyDALFactory,
TApprovalPolicyStepApproversDALFactory,
TApprovalPolicyStepsDALFactory
TApprovalPolicyStepsDALFactory,
TApprovalRequestApprovalsDALFactory,
TApprovalRequestDALFactory,
TApprovalRequestStepEligibleApproversDALFactory,
TApprovalRequestStepsDALFactory
} from "./approval-policy-dal";
import { ApprovalPolicyType, ApproverType } from "./approval-policy-enums";
import { TCreatePolicyDTO, TUpdatePolicyDTO } from "./approval-policy-types";
import {
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 = {
approvalPolicyDAL: TApprovalPolicyDALFactory;
approvalPolicyStepsDAL: TApprovalPolicyStepsDALFactory;
approvalPolicyStepApproversDAL: TApprovalPolicyStepApproversDALFactory;
approvalRequestApprovalsDAL: TApprovalRequestApprovalsDALFactory;
approvalRequestDAL: TApprovalRequestDALFactory;
approvalRequestStepsDAL: TApprovalRequestStepsDALFactory;
approvalRequestStepEligibleApproversDAL: TApprovalRequestStepEligibleApproversDALFactory;
userGroupMembershipDAL: TUserGroupMembershipDALFactory;
notificationService: TNotificationServiceFactory;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findProjectMembershipsByUserIds">;
};
@@ -25,9 +51,43 @@ export const approvalPolicyServiceFactory = ({
approvalPolicyDAL,
approvalPolicyStepsDAL,
approvalPolicyStepApproversDAL,
approvalRequestApprovalsDAL,
approvalRequestDAL,
approvalRequestStepsDAL,
approvalRequestStepEligibleApproversDAL,
userGroupMembershipDAL,
notificationService,
permissionService,
projectMembershipDAL
}: 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 uniqueUserIds = [...new Set(userIds)];
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 {
create,
list,
getById,
updateById,
deleteById
deleteById,
createRequest,
listRequests,
getRequestById,
approveRequest,
rejectRequest
};
};

View File

@@ -8,7 +8,9 @@ import {
TPamAccessPolicy,
TPamAccessPolicyConditions,
TPamAccessPolicyConstraints,
TPamAccessPolicyInputs
TPamAccessPolicyInputs,
TPamAccessRequest,
TPamAccessRequestData
} from "./pam-access/pam-access-policy-types";
export type TApprovalPolicy = TPamAccessPolicy;
@@ -16,17 +18,20 @@ export type TApprovalPolicyInputs = TPamAccessPolicyInputs;
export type TApprovalPolicyConditions = TPamAccessPolicyConditions;
export type TApprovalPolicyConstraints = TPamAccessPolicyConstraints;
export type TApprovalRequest = TPamAccessRequest;
export type TApprovalRequestData = TPamAccessRequestData;
export interface ApprovalPolicyStep {
name?: string | null;
requiredApprovals: number;
notifyApprovers?: boolean;
notifyApprovers?: boolean | null;
approvers: {
type: ApproverType;
id: string;
}[];
}
// DTOs
// Policy DTOs
export interface TCreatePolicyDTO {
projectId: TApprovalPolicy["projectId"];
name: TApprovalPolicy["name"];
@@ -44,6 +49,14 @@ export interface TUpdatePolicyDTO {
steps?: ApprovalPolicyStep[];
}
// Request DTOs
export interface TCreateRequestDTO {
projectId: TApprovalRequest["projectId"];
requestData: TApprovalRequest["requestData"]["requestData"];
justification?: TApprovalRequest["justification"];
expiresAt?: TApprovalRequest["expiresAt"];
}
// Factory
export type TApprovalRequestFactoryMatchPolicy<I extends TApprovalPolicyInputs, P extends TApprovalPolicy> = (
approvalPolicyDAL: TApprovalPolicyDALFactory,
@@ -56,10 +69,19 @@ export type TApprovalRequestFactoryCanAccess<I extends TApprovalPolicyInputs> =
userId: string,
inputs: I
) => 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> = (
policyType: ApprovalPolicyType
) => {
export type TApprovalResourceFactory<
I extends TApprovalPolicyInputs,
P extends TApprovalPolicy,
R extends TApprovalRequestData
> = (policyType: ApprovalPolicyType) => {
matchPolicy: TApprovalRequestFactoryMatchPolicy<I, P>;
canAccess: TApprovalRequestFactoryCanAccess<I>;
validateConstraints: TApprovalRequestFactoryValidateConstraints<P, R>;
postApprovalRoutine: TApprovalRequestFactoryPostApprovalRoutine;
};

View File

@@ -4,22 +4,23 @@ import { ApprovalRequestGrantStatus } from "../approval-policy-enums";
import {
TApprovalRequestFactoryCanAccess,
TApprovalRequestFactoryMatchPolicy,
TApprovalRequestFactoryPostApprovalRoutine,
TApprovalRequestFactoryValidateConstraints,
TApprovalResourceFactory
} 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> = (
policyType
) => {
export const pamAccessPolicyFactory: TApprovalResourceFactory<
TPamAccessPolicyInputs,
TPamAccessPolicy,
TPamAccessRequestData
> = (policyType) => {
const matchPolicy: TApprovalRequestFactoryMatchPolicy<TPamAccessPolicyInputs, TPamAccessPolicy> = async (
approvalPolicyDAL,
projectId,
inputs
) => {
const policies = await approvalPolicyDAL.find({
type: policyType,
projectId
});
const policies = await approvalPolicyDAL.findByProjectId(policyType, projectId);
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
// 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) {
if (picomatch(pathPattern)(inputs.accountPath)) {
const wildcardCount = (pathPattern.match(/\*/g) || []).length;
@@ -67,7 +68,7 @@ export const pamAccessPolicyFactory: TApprovalResourceFactory<TPamAccessPolicyIn
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) => {
const grantAttributes = grant.attributes as TPamAccessPolicyInputs;
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 {
matchPolicy,
canAccess
canAccess,
validateConstraints,
postApprovalRoutine
};
};

View File

@@ -2,7 +2,9 @@ import { z } from "zod";
import {
BaseApprovalPolicySchema,
BaseApprovalRequestSchema,
BaseCreateApprovalPolicySchema,
BaseCreateApprovalRequestSchema,
BaseUpdateApprovalPolicySchema
} from "../approval-policy-schemas";
@@ -16,19 +18,25 @@ export const PamAccessPolicyInputsSchema = z.object({
export const PamAccessPolicyConditionsSchema = z
.object({
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();
// Constraints
export const PamAccessPolicyConstraintsSchema = z.object({
requestDurationHours: z.object({
// 168 hours = 7 days
min: z.number().min(0).max(168),
max: z.number().min(1).max(168)
requestDurationSeconds: z.object({
min: z.number().min(30).max(604800),
max: z.number().min(30).max(604800) // 30 seconds to 7 days
})
});
// 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
export const PamAccessPolicySchema = BaseApprovalPolicySchema.extend({
conditions: z.object({
@@ -50,3 +58,15 @@ export const UpdatePamAccessPolicySchema = BaseUpdateApprovalPolicySchema.extend
conditions: PamAccessPolicyConditionsSchema.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
});

View File

@@ -4,10 +4,17 @@ import {
PamAccessPolicyConditionsSchema,
PamAccessPolicyConstraintsSchema,
PamAccessPolicyInputsSchema,
PamAccessPolicySchema
PamAccessPolicyRequestDataSchema,
PamAccessPolicySchema,
PamAccessRequestSchema
} from "./pam-access-policy-schemas";
// Policy
export type TPamAccessPolicy = z.infer<typeof PamAccessPolicySchema>;
export type TPamAccessPolicyInputs = z.infer<typeof PamAccessPolicyInputsSchema>;
export type TPamAccessPolicyConditions = z.infer<typeof PamAccessPolicyConditionsSchema>;
export type TPamAccessPolicyConstraints = z.infer<typeof PamAccessPolicyConstraintsSchema>;
// Request
export type TPamAccessRequest = z.infer<typeof PamAccessRequestSchema>;
export type TPamAccessRequestData = z.infer<typeof PamAccessPolicyRequestDataSchema>;

View File

@@ -17,7 +17,8 @@ export enum NotificationType {
PROJECT_INVITATION = "project-invitation",
SECRET_SYNC_FAILED = "secret-sync-failed",
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 {