diff --git a/backend/src/db/migrations/20251203002657_global-approvals.ts b/backend/src/db/migrations/20251203002657_global-approvals.ts index fa30cab2d..7bfeabf81 100644 --- a/backend/src/db/migrations/20251203002657_global-approvals.ts +++ b/backend/src/db/migrations/20251203002657_global-approvals.ts @@ -108,6 +108,7 @@ export async function up(knex: Knex): Promise { t.string("status").notNullable().index(); t.integer("requiredApprovals").notNullable(); + t.boolean("notifyApprovers").defaultTo(false); t.timestamp("startedAt").nullable(); t.timestamp("completedAt").nullable(); diff --git a/backend/src/db/schemas/approval-request-steps.ts b/backend/src/db/schemas/approval-request-steps.ts index b7b0d188d..7b5233601 100644 --- a/backend/src/db/schemas/approval-request-steps.ts +++ b/backend/src/db/schemas/approval-request-steps.ts @@ -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() }); diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index fd9daddcc..511022ccd 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -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 diff --git a/backend/src/server/routes/v1/approval-policy-routers/approval-policy-endpoints.ts b/backend/src/server/routes/v1/approval-policy-routers/approval-policy-endpoints.ts index 41597182c..77143fdb7 100644 --- a/backend/src/server/routes/v1/approval-policy-routers/approval-policy-endpoints.ts +++ b/backend/src/server/routes/v1/approval-policy-routers/approval-policy-endpoints.ts @@ -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 =

({ policyType, createPolicySchema, updatePolicySchema, - policyResponseSchema + policyResponseSchema, + createRequestSchema, + requestResponseSchema }: { server: FastifyZodProvider; policyType: ApprovalPolicyType; @@ -32,7 +36,10 @@ export const registerApprovalPolicyEndpoints =

({ } >; policyResponseSchema: z.ZodTypeAny; + createRequestSchema: z.ZodType; + requestResponseSchema: z.ZodTypeAny; }) => { + // Policies server.route({ method: "POST", url: "/", @@ -52,7 +59,7 @@ export const registerApprovalPolicyEndpoints =

({ 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 =

({ 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 =

({ 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 =

({ 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 =

({ 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 }; + } + }); }; diff --git a/backend/src/server/routes/v1/approval-policy-routers/index.ts b/backend/src/server/routes/v1/approval-policy-routers/index.ts index a60b20884..e1c1a3644 100644 --- a/backend/src/server/routes/v1/approval-policy-routers/index.ts +++ b/backend/src/server/routes/v1/approval-policy-routers/index.ts @@ -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 }); } }; diff --git a/backend/src/services/approval-policy/approval-policy-dal.ts b/backend/src/services/approval-policy/approval-policy-dal.ts index bb1854cae..66f8a84c6 100644 --- a/backend/src/services/approval-policy/approval-policy-dal.ts +++ b/backend/src/services/approval-policy/approval-policy-dal.ts @@ -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; +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>( + (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>((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 = {}; + + 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>( + (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>((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; +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; export const approvalRequestGrantsDALFactory = (db: TDbClient) => { const orm = ormify(db, TableName.ApprovalRequestGrants); return orm; }; + +// Approval Request Approvals +export type TApprovalRequestApprovalsDALFactory = ReturnType; +export const approvalRequestApprovalsDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.ApprovalRequestApprovals); + return orm; +}; diff --git a/backend/src/services/approval-policy/approval-policy-enums.ts b/backend/src/services/approval-policy/approval-policy-enums.ts index 91e7a58f9..bc57801f3 100644 --- a/backend/src/services/approval-policy/approval-policy-enums.ts +++ b/backend/src/services/approval-policy/approval-policy-enums.ts @@ -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", diff --git a/backend/src/services/approval-policy/approval-policy-factory.ts b/backend/src/services/approval-policy/approval-policy-factory.ts index ed5fb7579..42ef11ac7 100644 --- a/backend/src/services/approval-policy/approval-policy-factory.ts +++ b/backend/src/services/approval-policy/approval-policy-factory.ts @@ -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; +type TApprovalPolicyFactoryImplementation = TApprovalResourceFactory< + TApprovalPolicyInputs, + TApprovalPolicy, + TApprovalRequestData +>; export const APPROVAL_POLICY_FACTORY_MAP: Record = { [ApprovalPolicyType.PamAccess]: pamAccessPolicyFactory as TApprovalPolicyFactoryImplementation diff --git a/backend/src/services/approval-policy/approval-policy-schemas.ts b/backend/src/services/approval-policy/approval-policy-schemas.ts index 0ac225ba2..16aa854b4 100644 --- a/backend/src/services/approval-policy/approval-policy-schemas.ts +++ b/backend/src/services/approval-policy/approval-policy-schemas.ts @@ -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() +}); diff --git a/backend/src/services/approval-policy/approval-policy-service.ts b/backend/src/services/approval-policy/approval-policy-service.ts index 5c16dc729..8c2dc4168 100644 --- a/backend/src/services/approval-policy/approval-policy-service.ts +++ b/backend/src/services/approval-policy/approval-policy-service.ts @@ -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; projectMembershipDAL: Pick; }; @@ -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(); + + 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 }; }; diff --git a/backend/src/services/approval-policy/approval-policy-types.ts b/backend/src/services/approval-policy/approval-policy-types.ts index 0c5ffa71f..471e8dc1d 100644 --- a/backend/src/services/approval-policy/approval-policy-types.ts +++ b/backend/src/services/approval-policy/approval-policy-types.ts @@ -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 = ( approvalPolicyDAL: TApprovalPolicyDALFactory, @@ -56,10 +69,19 @@ export type TApprovalRequestFactoryCanAccess = userId: string, inputs: I ) => Promise; +export type TApprovalRequestFactoryValidateConstraints

= ( + policy: P, + inputs: R +) => boolean; +export type TApprovalRequestFactoryPostApprovalRoutine = (request: TApprovalRequest) => Promise; -export type TApprovalResourceFactory = ( - policyType: ApprovalPolicyType -) => { +export type TApprovalResourceFactory< + I extends TApprovalPolicyInputs, + P extends TApprovalPolicy, + R extends TApprovalRequestData +> = (policyType: ApprovalPolicyType) => { matchPolicy: TApprovalRequestFactoryMatchPolicy; canAccess: TApprovalRequestFactoryCanAccess; + validateConstraints: TApprovalRequestFactoryValidateConstraints; + postApprovalRoutine: TApprovalRequestFactoryPostApprovalRoutine; }; diff --git a/backend/src/services/approval-policy/pam-access/pam-access-policy-factory.ts b/backend/src/services/approval-policy/pam-access/pam-access-policy-factory.ts index 2373acf0e..a40372daa 100644 --- a/backend/src/services/approval-policy/pam-access/pam-access-policy-factory.ts +++ b/backend/src/services/approval-policy/pam-access/pam-access-policy-factory.ts @@ -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 = ( - policyType -) => { +export const pamAccessPolicyFactory: TApprovalResourceFactory< + TPamAccessPolicyInputs, + TPamAccessPolicy, + TPamAccessRequestData +> = (policyType) => { const matchPolicy: TApprovalRequestFactoryMatchPolicy = 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 { const grantAttributes = grant.attributes as TPamAccessPolicyInputs; const isMatch = picomatch(grantAttributes.accountPath); @@ -79,8 +80,24 @@ export const pamAccessPolicyFactory: TApprovalResourceFactory = ( + 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 }; }; diff --git a/backend/src/services/approval-policy/pam-access/pam-access-policy-schemas.ts b/backend/src/services/approval-policy/pam-access/pam-access-policy-schemas.ts index caa9562b2..1dd679bad 100644 --- a/backend/src/services/approval-policy/pam-access/pam-access-policy-schemas.ts +++ b/backend/src/services/approval-policy/pam-access/pam-access-policy-schemas.ts @@ -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 +}); diff --git a/backend/src/services/approval-policy/pam-access/pam-access-policy-types.ts b/backend/src/services/approval-policy/pam-access/pam-access-policy-types.ts index 48dd98dbc..78118fcb0 100644 --- a/backend/src/services/approval-policy/pam-access/pam-access-policy-types.ts +++ b/backend/src/services/approval-policy/pam-access/pam-access-policy-types.ts @@ -4,10 +4,17 @@ import { PamAccessPolicyConditionsSchema, PamAccessPolicyConstraintsSchema, PamAccessPolicyInputsSchema, - PamAccessPolicySchema + PamAccessPolicyRequestDataSchema, + PamAccessPolicySchema, + PamAccessRequestSchema } from "./pam-access-policy-schemas"; +// Policy export type TPamAccessPolicy = z.infer; export type TPamAccessPolicyInputs = z.infer; export type TPamAccessPolicyConditions = z.infer; export type TPamAccessPolicyConstraints = z.infer; + +// Request +export type TPamAccessRequest = z.infer; +export type TPamAccessRequestData = z.infer; diff --git a/backend/src/services/notification/notification-types.ts b/backend/src/services/notification/notification-types.ts index 84cf35a50..56045174a 100644 --- a/backend/src/services/notification/notification-types.ts +++ b/backend/src/services/notification/notification-types.ts @@ -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 {