policy api improvements

This commit is contained in:
x032205
2025-12-06 14:20:49 +05:30
committed by =
parent 18256d6af2
commit 5859293c33
8 changed files with 181 additions and 51 deletions
+2 -2
View File
@@ -2470,8 +2470,8 @@ export const registerRoutes = async (
approvalPolicyDAL, approvalPolicyDAL,
approvalPolicyStepsDAL, approvalPolicyStepsDAL,
approvalPolicyStepApproversDAL, approvalPolicyStepApproversDAL,
projectDAL, permissionService,
permissionService projectMembershipDAL
}); });
// setup the communication with license key server // setup the communication with license key server
@@ -1,6 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import { 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 {
@@ -50,7 +50,34 @@ export const registerApprovalPolicyEndpoints = <P extends TApprovalPolicy>({
}, },
onRequest: verifyAuth([AuthMode.JWT]), onRequest: verifyAuth([AuthMode.JWT]),
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
return { policy };
}
});
server.route({
method: "GET",
url: "/:policyId",
config: {
rateLimit: readLimit
},
schema: {
description: "Get approval policy",
params: z.object({
policyId: z.string().uuid()
}),
response: {
200: z.object({
policy: policyResponseSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const { policy } = await server.services.approvalPolicy.getById(req.params.policyId, req.permission);
// TODO: Audit log // TODO: Audit log
@@ -78,7 +105,7 @@ export const registerApprovalPolicyEndpoints = <P extends TApprovalPolicy>({
}, },
onRequest: verifyAuth([AuthMode.JWT]), onRequest: verifyAuth([AuthMode.JWT]),
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: Audit log
@@ -99,17 +126,17 @@ export const registerApprovalPolicyEndpoints = <P extends TApprovalPolicy>({
}), }),
response: { response: {
200: z.object({ 200: z.object({
policy: policyResponseSchema policyId: z.string().uuid()
}) })
} }
}, },
onRequest: verifyAuth([AuthMode.JWT]), onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => { handler: async (req) => {
const policy = 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: Audit log
return { policy }; return { policyId };
} }
}); });
}; };
@@ -1,14 +1,70 @@
import { TDbClient } from "@app/db"; import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas"; import { TableName } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify } from "@app/lib/knex"; import { ormify } from "@app/lib/knex";
import { ApproverType } from "./approval-policy-enums";
// Approval Policy // Approval Policy
export type TApprovalPolicyDALFactory = ReturnType<typeof approvalPolicyDALFactory>; export type TApprovalPolicyDALFactory = ReturnType<typeof approvalPolicyDALFactory>;
export const approvalPolicyDALFactory = (db: TDbClient) => { export const approvalPolicyDALFactory = (db: TDbClient) => {
const orm = ormify(db, TableName.ApprovalPolicies); const orm = ormify(db, TableName.ApprovalPolicies);
const findStepsByPolicyId = async (policyId: string) => {
try {
const dbInstance = db.replicaNode();
const steps = await dbInstance(TableName.ApprovalPolicySteps).where({ policyId }).orderBy("stepNumber", "asc");
if (!steps.length) {
return [];
}
const stepIds = steps.map((step) => step.id);
const approvers = await dbInstance(TableName.ApprovalPolicyStepApprovers)
.whereIn("policyStepId", stepIds)
.select("policyStepId", "userId", "groupId");
const approversByStepId = approvers.reduce<Record<string, { type: string; id: string }[]>>((acc, approver) => {
const stepApprovers = acc[approver.policyStepId] || [];
stepApprovers.push({
type: approver.userId ? ApproverType.User : ApproverType.Group,
id: (approver.userId || approver.groupId) as string
});
acc[approver.policyStepId] = stepApprovers;
return acc;
}, {});
return steps.map((step) => {
const stepApprovers = approversByStepId[step.id] || [];
const formattedStep: {
name?: string;
requiredApprovals: number;
notifyApprovers?: boolean;
approvers: { type: string; id: string }[];
} = {
requiredApprovals: step.requiredApprovals,
approvers: stepApprovers
};
if (step.name) {
formattedStep.name = step.name;
}
if (typeof step.notifyApprovers === "boolean") {
formattedStep.notifyApprovers = step.notifyApprovers;
}
return formattedStep;
});
} catch (error) {
throw new DatabaseError({ error, name: "Find approval policy steps" });
}
};
return { return {
...orm ...orm,
findStepsByPolicyId
}; };
}; };
@@ -4,43 +4,31 @@ import { ApprovalPoliciesSchema } from "@app/db/schemas";
import { ApproverType } from "./approval-policy-enums"; import { ApproverType } from "./approval-policy-enums";
export const BaseApprovalPolicySchema = ApprovalPoliciesSchema; const ApprovalPolicyStepSchema = z.object({
name: z.string().min(1).max(128).nullable().optional(),
requiredApprovals: z.number().min(1).max(100),
notifyApprovers: z.boolean().optional(),
approvers: z
.object({
type: z.nativeEnum(ApproverType),
id: z.string().uuid()
})
.array()
});
export const BaseApprovalPolicySchema = ApprovalPoliciesSchema.extend({
steps: ApprovalPolicyStepSchema.array()
});
export const BaseCreateApprovalPolicySchema = z.object({ export const BaseCreateApprovalPolicySchema = z.object({
projectId: z.string().uuid(), projectId: z.string().uuid(),
organizationId: z.string().uuid(),
name: z.string().min(1).max(128), name: z.string().min(1).max(128),
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: z steps: ApprovalPolicyStepSchema.array()
.object({
name: z.string().min(1).max(128).nullable().optional(),
requiredApprovals: z.number().min(1).max(100),
notifyApprovers: z.boolean().optional(),
approvers: z
.object({
type: z.nativeEnum(ApproverType),
id: z.string().uuid()
})
.array()
})
.array()
}); });
export const BaseUpdateApprovalPolicySchema = z.object({ export const BaseUpdateApprovalPolicySchema = z.object({
name: z.string().min(1).max(128).optional(), name: z.string().min(1).max(128).optional(),
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: z steps: ApprovalPolicyStepSchema.array().optional()
.object({
name: z.string().min(1).max(128).nullable().optional(),
requiredApprovals: z.number().min(1).max(100),
notifyApprovers: z.boolean().optional(),
approvers: z
.object({
type: z.nativeEnum(ApproverType),
id: z.string().uuid()
})
.array()
})
.array()
.optional()
}); });
@@ -1,9 +1,9 @@
import { ActionProjectType, ProjectMembershipRole, TApprovalPolicies } from "@app/db/schemas"; import { ActionProjectType, ProjectMembershipRole, TApprovalPolicies } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { 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 { TProjectDALFactory } from "@app/services/project/project-dal";
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
import { import {
TApprovalPolicyDALFactory, TApprovalPolicyDALFactory,
TApprovalPolicyStepApproversDALFactory, TApprovalPolicyStepApproversDALFactory,
@@ -16,8 +16,8 @@ type TApprovalPolicyServiceFactoryDep = {
approvalPolicyDAL: TApprovalPolicyDALFactory; approvalPolicyDAL: TApprovalPolicyDALFactory;
approvalPolicyStepsDAL: TApprovalPolicyStepsDALFactory; approvalPolicyStepsDAL: TApprovalPolicyStepsDALFactory;
approvalPolicyStepApproversDAL: TApprovalPolicyStepApproversDALFactory; approvalPolicyStepApproversDAL: TApprovalPolicyStepApproversDALFactory;
projectDAL: TProjectDALFactory;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">; permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findProjectMembershipsByUserIds">;
}; };
export type TApprovalPolicyServiceFactory = ReturnType<typeof approvalPolicyServiceFactory>; export type TApprovalPolicyServiceFactory = ReturnType<typeof approvalPolicyServiceFactory>;
@@ -25,11 +25,28 @@ export const approvalPolicyServiceFactory = ({
approvalPolicyDAL, approvalPolicyDAL,
approvalPolicyStepsDAL, approvalPolicyStepsDAL,
approvalPolicyStepApproversDAL, approvalPolicyStepApproversDAL,
permissionService permissionService,
projectMembershipDAL
}: TApprovalPolicyServiceFactoryDep) => { }: TApprovalPolicyServiceFactoryDep) => {
const $verifyProjectUserMembership = async (userIds: string[], orgId: string, projectId: string) => {
const uniqueUserIds = [...new Set(userIds)];
if (uniqueUserIds.length === 0) return;
const allMemberships = await projectMembershipDAL.findProjectMembershipsByUserIds(orgId, uniqueUserIds);
const projectMemberships = allMemberships.filter((membership) => membership.projectId === projectId);
if (projectMemberships.length !== uniqueUserIds.length) {
const projectMemberUserIds = new Set(projectMemberships.map((membership) => membership.userId));
const userIdsNotInProject = uniqueUserIds.filter((id) => !projectMemberUserIds.has(id));
throw new BadRequestError({
message: `Some users are not members of the project: ${userIdsNotInProject.join(", ")}`
});
}
};
const create = async ( const create = async (
policyType: ApprovalPolicyType, policyType: ApprovalPolicyType,
{ projectId, organizationId, name, maxRequestTtlSeconds, conditions, constraints, steps }: TCreatePolicyDTO, { projectId, name, maxRequestTtlSeconds, conditions, constraints, steps }: TCreatePolicyDTO,
actor: OrgServiceActor actor: OrgServiceActor
) => { ) => {
const { hasRole } = await permissionService.getProjectPermission({ const { hasRole } = await permissionService.getProjectPermission({
@@ -45,11 +62,18 @@ export const approvalPolicyServiceFactory = ({
throw new ForbiddenRequestError({ message: "User has insufficient privileges" }); throw new ForbiddenRequestError({ message: "User has insufficient privileges" });
} }
// Verify all users are part of project
const approverUserIds = steps
.flatMap((step) => step.approvers ?? [])
.filter((approver) => approver.type === ApproverType.User)
.map((approver) => approver.id);
await $verifyProjectUserMembership(approverUserIds, actor.orgId, projectId);
const policy = await approvalPolicyDAL.transaction(async (tx) => { const policy = await approvalPolicyDAL.transaction(async (tx) => {
const newPolicy = await approvalPolicyDAL.create( const newPolicy = await approvalPolicyDAL.create(
{ {
projectId, projectId,
organizationId, organizationId: actor.orgId,
name, name,
maxRequestTtlSeconds, maxRequestTtlSeconds,
conditions: { version: 1, conditions }, conditions: { version: 1, conditions },
@@ -94,10 +118,34 @@ export const approvalPolicyServiceFactory = ({
}); });
return { return {
policy policy: { ...policy, steps }
}; };
}; };
const getById = async (policyId: string, actor: OrgServiceActor) => {
const policy = await approvalPolicyDAL.findById(policyId);
if (!policy) {
throw new ForbiddenRequestError({ message: "Policy not found" });
}
const { hasRole } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId: policy.projectId,
actionProjectType: ActionProjectType.Any
});
if (!hasRole(ProjectMembershipRole.Admin)) {
throw new ForbiddenRequestError({ message: "User has insufficient privileges" });
}
const steps = await approvalPolicyDAL.findStepsByPolicyId(policyId);
return { policy: { ...policy, steps } };
};
const updateById = async ( const updateById = async (
policyId: string, policyId: string,
{ name, maxRequestTtlSeconds, conditions, constraints, steps }: TUpdatePolicyDTO, { name, maxRequestTtlSeconds, conditions, constraints, steps }: TUpdatePolicyDTO,
@@ -121,6 +169,15 @@ export const approvalPolicyServiceFactory = ({
throw new ForbiddenRequestError({ message: "User has insufficient privileges" }); throw new ForbiddenRequestError({ message: "User has insufficient privileges" });
} }
if (steps !== undefined) {
// Verify all users are part of project
const approverUserIds = steps
.flatMap((step) => step.approvers ?? [])
.filter((approver) => approver.type === ApproverType.User)
.map((approver) => approver.id);
await $verifyProjectUserMembership(approverUserIds, actor.orgId, policy.projectId);
}
const updatedPolicy = await approvalPolicyDAL.transaction(async (tx) => { const updatedPolicy = await approvalPolicyDAL.transaction(async (tx) => {
const updateDoc: Partial<TApprovalPolicies> = {}; const updateDoc: Partial<TApprovalPolicies> = {};
@@ -178,8 +235,10 @@ export const approvalPolicyServiceFactory = ({
return updated; return updated;
}); });
const fetchedSteps = await approvalPolicyDAL.findStepsByPolicyId(policyId);
return { return {
policy: updatedPolicy policy: { ...updatedPolicy, steps: fetchedSteps }
}; };
}; };
@@ -202,15 +261,16 @@ export const approvalPolicyServiceFactory = ({
throw new ForbiddenRequestError({ message: "User has insufficient privileges" }); throw new ForbiddenRequestError({ message: "User has insufficient privileges" });
} }
const deletedPolicy = await approvalPolicyDAL.deleteById(policyId); await approvalPolicyDAL.deleteById(policyId);
return { return {
policy: deletedPolicy policyId
}; };
}; };
return { return {
create, create,
getById,
updateById, updateById,
deleteById deleteById
}; };
@@ -15,7 +15,6 @@ export type TApprovalPolicyConstraints = TPamAccessPolicyConstraints;
// DTOs // DTOs
export interface TCreatePolicyDTO { export interface TCreatePolicyDTO {
projectId: TApprovalPolicy["projectId"]; projectId: TApprovalPolicy["projectId"];
organizationId: TApprovalPolicy["organizationId"];
name: TApprovalPolicy["name"]; name: TApprovalPolicy["name"];
maxRequestTtlSeconds?: TApprovalPolicy["maxRequestTtlSeconds"]; maxRequestTtlSeconds?: TApprovalPolicy["maxRequestTtlSeconds"];
conditions: TApprovalPolicy["conditions"]["conditions"]; conditions: TApprovalPolicy["conditions"]["conditions"];
@@ -26,7 +26,7 @@ export const pamAccessPolicyFactory: TApprovalResourceFactory<TPamAccessPolicyIn
for (const policy of policies) { for (const policy of policies) {
const p = policy as TPamAccessPolicy; const p = policy as TPamAccessPolicy;
for (const c of p.conditions.conditions) { for (const c of p.conditions.conditions) {
if (!c.targetResources.some((r) => r === inputs.resourceId)) { if (!c.resourceIds.some((r) => r === inputs.resourceId)) {
// eslint-disable-next-line no-continue // eslint-disable-next-line no-continue
continue; continue;
} }
@@ -15,7 +15,7 @@ export const PamAccessPolicyInputsSchema = z.object({
// Conditions // Conditions
export const PamAccessPolicyConditionsSchema = z export const PamAccessPolicyConditionsSchema = z
.object({ .object({
targetResources: z.string().uuid().array(), resourceIds: z.string().uuid().array(),
accountPaths: z.string().array() // TODO: Add path & wildcard validation accountPaths: z.string().array() // TODO: Add path & wildcard validation
}) })
.array(); .array();