partial factory, endpoint, and service implementation

This commit is contained in:
x032205
2025-12-04 23:02:41 -05:00
committed by =
parent 62cd45d0c5
commit 18256d6af2
23 changed files with 844 additions and 57 deletions

View File

@@ -55,6 +55,7 @@ import { TAuthMode } from "@app/server/plugins/auth/inject-identity";
import { TAdditionalPrivilegeServiceFactory } from "@app/services/additional-privilege/additional-privilege-service";
import { TApiKeyServiceFactory } from "@app/services/api-key/api-key-service";
import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service";
import { TApprovalPolicyServiceFactory } from "@app/services/approval-policy/approval-policy-service";
import { TAuthLoginFactory } from "@app/services/auth/auth-login-service";
import { TAuthPasswordFactory } from "@app/services/auth/auth-password-service";
import { TAuthSignupFactory } from "@app/services/auth/auth-signup-service";
@@ -361,6 +362,7 @@ declare module "fastify" {
convertor: TConvertorServiceFactory;
subOrganization: TSubOrgServiceFactory;
pkiAlertV2: TPkiAlertV2ServiceFactory;
approvalPolicy: TApprovalPolicyServiceFactory;
};
// this is exclusive use for middlewares in which we need to inject data
// everywhere else access using service layer

View File

@@ -26,6 +26,30 @@ import {
TAppConnections,
TAppConnectionsInsert,
TAppConnectionsUpdate,
TApprovalPolicies,
TApprovalPoliciesInsert,
TApprovalPoliciesUpdate,
TApprovalPolicyStepApprovers,
TApprovalPolicyStepApproversInsert,
TApprovalPolicyStepApproversUpdate,
TApprovalPolicySteps,
TApprovalPolicyStepsInsert,
TApprovalPolicyStepsUpdate,
TApprovalRequestApprovals,
TApprovalRequestApprovalsInsert,
TApprovalRequestApprovalsUpdate,
TApprovalRequestGrants,
TApprovalRequestGrantsInsert,
TApprovalRequestGrantsUpdate,
TApprovalRequests,
TApprovalRequestsInsert,
TApprovalRequestStepEligibleApprovers,
TApprovalRequestStepEligibleApproversInsert,
TApprovalRequestStepEligibleApproversUpdate,
TApprovalRequestSteps,
TApprovalRequestStepsInsert,
TApprovalRequestStepsUpdate,
TApprovalRequestsUpdate,
TAuditLogs,
TAuditLogsInsert,
TAuditLogStreams,
@@ -571,31 +595,7 @@ import {
TWebhooksUpdate,
TWorkflowIntegrations,
TWorkflowIntegrationsInsert,
TWorkflowIntegrationsUpdate,
TApprovalPolicies,
TApprovalPoliciesInsert,
TApprovalPoliciesUpdate,
TApprovalPolicyStepApprovers,
TApprovalPolicyStepApproversInsert,
TApprovalPolicyStepApproversUpdate,
TApprovalPolicySteps,
TApprovalPolicyStepsInsert,
TApprovalPolicyStepsUpdate,
TApprovalRequestApprovals,
TApprovalRequestApprovalsInsert,
TApprovalRequestApprovalsUpdate,
TApprovalRequestGrants,
TApprovalRequestGrantsInsert,
TApprovalRequestGrantsUpdate,
TApprovalRequestStepEligibleApprovers,
TApprovalRequestStepEligibleApproversInsert,
TApprovalRequestStepEligibleApproversUpdate,
TApprovalRequestSteps,
TApprovalRequestStepsInsert,
TApprovalRequestStepsUpdate,
TApprovalRequests,
TApprovalRequestsInsert,
TApprovalRequestsUpdate
TWorkflowIntegrationsUpdate
} from "@app/db/schemas";
import {
TCertificateRequests,

View File

@@ -1,4 +1,5 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";

View File

@@ -6,6 +6,14 @@ export * from "./access-approval-requests-reviewers";
export * from "./additional-privileges";
export * from "./api-keys";
export * from "./app-connections";
export * from "./approval-policies";
export * from "./approval-policy-step-approvers";
export * from "./approval-policy-steps";
export * from "./approval-request-approvals";
export * from "./approval-request-grants";
export * from "./approval-request-step-eligible-approvers";
export * from "./approval-request-steps";
export * from "./approval-requests";
export * from "./audit-log-streams";
export * from "./audit-logs";
export * from "./auth-token-sessions";
@@ -195,11 +203,3 @@ export * from "./users";
export * from "./vault-external-migration-configs";
export * from "./webhooks";
export * from "./workflow-integrations";
export * from "./approval-policies";
export * from "./approval-policy-step-approvers";
export * from "./approval-policy-steps";
export * from "./approval-request-approvals";
export * from "./approval-request-grants";
export * from "./approval-request-step-eligible-approvers";
export * from "./approval-request-steps";
export * from "./approval-requests";

View File

@@ -10,9 +10,21 @@ import {
ProjectPermissionSub
} from "@app/ee/services/permission/project-permission";
import { DatabaseErrorCode } from "@app/lib/error-codes";
import { BadRequestError, DatabaseError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import {
BadRequestError,
DatabaseError,
ForbiddenRequestError,
NotFoundError,
PolicyViolationError
} from "@app/lib/errors";
import { logger } from "@app/lib/logger";
import { OrgServiceActor } from "@app/lib/types";
import {
TApprovalPolicyDALFactory,
TApprovalRequestGrantsDALFactory
} from "@app/services/approval-policy/approval-policy-dal";
import { ApprovalPolicyType } from "@app/services/approval-policy/approval-policy-enums";
import { APPROVAL_POLICY_FACTORY_MAP } from "@app/services/approval-policy/approval-policy-factory";
import { ActorType } from "@app/services/auth/auth-type";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { KmsDataKey } from "@app/services/kms/kms-types";
@@ -51,6 +63,8 @@ type TPamAccountServiceFactoryDep = {
>;
userDAL: TUserDALFactory;
auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">;
approvalPolicyDAL: TApprovalPolicyDALFactory;
approvalRequestGrantsDAL: TApprovalRequestGrantsDALFactory;
};
export type TPamAccountServiceFactory = ReturnType<typeof pamAccountServiceFactory>;
@@ -67,7 +81,9 @@ export const pamAccountServiceFactory = ({
licenseService,
kmsService,
gatewayV2Service,
auditLogService
auditLogService,
approvalPolicyDAL,
approvalRequestGrantsDAL
}: TPamAccountServiceFactoryDep) => {
const create = async (
{
@@ -531,23 +547,50 @@ export const pamAccountServiceFactory = ({
const resource = await pamResourceDAL.findById(account.resourceId);
if (!resource) throw new NotFoundError({ message: `Resource with ID '${account.resourceId}' not found` });
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId,
actionProjectType: ActionProjectType.PAM
});
const fac = APPROVAL_POLICY_FACTORY_MAP[ApprovalPolicyType.PamAccess](ApprovalPolicyType.PamAccess);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPamAccountActions.Access,
subject(ProjectPermissionSub.PamAccounts, {
resourceName: resource.name,
accountName: account.name,
accountPath: folderPath
})
);
const inputs = {
resourceId: resource.id,
accountPath: folderPath
};
const canAccess = await fac.canAccess(approvalRequestGrantsDAL, actor.id, resource.projectId, inputs);
if (canAccess) {
// Grant exists, allow access without checking permission
} else {
const policy = await fac.matchPolicy(approvalPolicyDAL, resource.projectId, inputs);
if (policy) {
throw new PolicyViolationError({
message: "A policy is in place for this resource",
details: {
policyId: policy.id,
policyName: policy.name,
policyType: policy.type
}
});
}
// If there isn't a policy in place, continue with checking permission
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId: account.projectId,
actionProjectType: ActionProjectType.PAM
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPamAccountActions.Access,
subject(ProjectPermissionSub.PamAccounts, {
resourceName: resource.name,
accountName: account.name,
accountPath: folderPath
})
);
}
const session = await pamSessionDAL.create({
accountName: account.name,

View File

@@ -183,3 +183,23 @@ export class CryptographyError extends Error {
this.error = error;
}
}
export class PolicyViolationError extends Error {
name: string;
error: unknown;
details?: unknown;
constructor({
name,
error,
message,
details
}: { message?: string; name?: string; error?: unknown; details?: unknown } = {}) {
super(message || "A policy is in place for this resource");
this.name = name || "PolicyViolationError";
this.error = error;
this.details = details;
}
}

View File

@@ -17,6 +17,7 @@ import {
NotFoundError,
OidcAuthError,
PermissionBoundaryError,
PolicyViolationError,
RateLimitError,
ScimRequestError,
UnauthorizedError
@@ -255,6 +256,14 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider
detail: error.message
// TODO: add subproblems if they exist
});
} else if (error instanceof PolicyViolationError) {
void res.status(HttpStatusCodes.Forbidden).send({
reqId: req.id,
statusCode: HttpStatusCodes.Forbidden,
error: "PolicyViolationError",
message: error.message,
details: error.details
});
} else {
void res.status(HttpStatusCodes.InternalServerError).send({
reqId: req.id,

View File

@@ -159,6 +159,13 @@ import { apiKeyDALFactory } from "@app/services/api-key/api-key-dal";
import { apiKeyServiceFactory } from "@app/services/api-key/api-key-service";
import { appConnectionDALFactory } from "@app/services/app-connection/app-connection-dal";
import { appConnectionServiceFactory } from "@app/services/app-connection/app-connection-service";
import {
approvalPolicyDALFactory,
approvalPolicyStepApproversDALFactory,
approvalPolicyStepsDALFactory,
approvalRequestGrantsDALFactory
} 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";
import { authLoginServiceFactory } from "@app/services/auth/auth-login-service";
import { authPaswordServiceFactory } from "@app/services/auth/auth-password-service";
@@ -2412,6 +2419,9 @@ export const registerRoutes = async (
gatewayV2Service
});
const approvalRequestGrantsDAL = approvalRequestGrantsDALFactory(db);
const approvalPolicyDAL = approvalPolicyDALFactory(db);
const pamAccountService = pamAccountServiceFactory({
pamAccountDAL,
gatewayV2Service,
@@ -2423,7 +2433,9 @@ export const registerRoutes = async (
permissionService,
projectDAL,
userDAL,
auditLogService
auditLogService,
approvalRequestGrantsDAL,
approvalPolicyDAL
});
const pamAccountRotation = pamAccountRotationServiceFactory({
@@ -2451,6 +2463,17 @@ export const registerRoutes = async (
auditLogService
});
const approvalPolicyStepsDAL = approvalPolicyStepsDALFactory(db);
const approvalPolicyStepApproversDAL = approvalPolicyStepApproversDALFactory(db);
const approvalPolicyService = approvalPolicyServiceFactory({
approvalPolicyDAL,
approvalPolicyStepsDAL,
approvalPolicyStepApproversDAL,
projectDAL,
permissionService
});
// setup the communication with license key server
await licenseService.init();
@@ -2630,7 +2653,8 @@ export const registerRoutes = async (
additionalPrivilege: additionalPrivilegeService,
identityProject: identityProjectService,
convertor: convertorService,
pkiAlertV2: pkiAlertV2Service
pkiAlertV2: pkiAlertV2Service,
approvalPolicy: approvalPolicyService
});
const cronJobs: CronJob[] = [];

View File

@@ -16,8 +16,8 @@ import { registerCamundaConnectionRouter } from "./camunda-connection-router";
import { registerChecklyConnectionRouter } from "./checkly-connection-router";
import { registerCloudflareConnectionRouter } from "./cloudflare-connection-router";
import { registerDatabricksConnectionRouter } from "./databricks-connection-router";
import { registerDNSMadeEasyConnectionRouter } from "./dns-made-easy-connection-router";
import { registerDigitalOceanConnectionRouter } from "./digital-ocean-connection-router";
import { registerDNSMadeEasyConnectionRouter } from "./dns-made-easy-connection-router";
import { registerFlyioConnectionRouter } from "./flyio-connection-router";
import { registerGcpConnectionRouter } from "./gcp-connection-router";
import { registerGitHubConnectionRouter } from "./github-connection-router";

View File

@@ -0,0 +1,115 @@
import { z } from "zod";
import { 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,
TUpdatePolicyDTO
} from "@app/services/approval-policy/approval-policy-types";
import { AuthMode } from "@app/services/auth/auth-type";
export const registerApprovalPolicyEndpoints = <P extends TApprovalPolicy>({
server,
policyType,
createPolicySchema,
updatePolicySchema,
policyResponseSchema
}: {
server: FastifyZodProvider;
policyType: ApprovalPolicyType;
createPolicySchema: z.ZodType<
TCreatePolicyDTO & {
conditions: P["conditions"]["conditions"];
constraints: P["constraints"]["constraints"];
}
>;
updatePolicySchema: z.ZodType<
TUpdatePolicyDTO & {
conditions?: P["conditions"]["conditions"];
constraints?: P["constraints"]["constraints"];
}
>;
policyResponseSchema: z.ZodTypeAny;
}) => {
server.route({
method: "POST",
url: "/",
config: {
rateLimit: writeLimit
},
schema: {
description: "Create approval policy",
body: createPolicySchema,
response: {
200: z.object({
policy: policyResponseSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const policy = await server.services.approvalPolicy.create(policyType, req.body, req.permission);
// TODO: Audit log
return { policy };
}
});
server.route({
method: "PATCH",
url: "/:policyId",
config: {
rateLimit: writeLimit
},
schema: {
description: "Update approval policy",
params: z.object({
policyId: z.string().uuid()
}),
body: updatePolicySchema,
response: {
200: z.object({
policy: policyResponseSchema
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const policy = await server.services.approvalPolicy.updateById(req.params.policyId, req.body, req.permission);
// TODO: Audit log
return { policy };
}
});
server.route({
method: "DELETE",
url: "/:policyId",
config: {
rateLimit: writeLimit
},
schema: {
description: "Delete 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.deleteById(req.params.policyId, req.permission);
// TODO: Audit log
return { policy };
}
});
};

View File

@@ -0,0 +1,23 @@
import { ApprovalPolicyType } from "@app/services/approval-policy/approval-policy-enums";
import {
CreatePamAccessPolicySchema,
PamAccessPolicySchema,
UpdatePamAccessPolicySchema
} from "@app/services/approval-policy/pam-access/pam-access-policy-schemas";
import { registerApprovalPolicyEndpoints } from "./approval-policy-endpoints";
export const APPROVAL_POLICY_REGISTER_ROUTER_MAP: Record<
ApprovalPolicyType,
(server: FastifyZodProvider) => Promise<void>
> = {
[ApprovalPolicyType.PamAccess]: async (server: FastifyZodProvider) => {
registerApprovalPolicyEndpoints({
server,
policyType: ApprovalPolicyType.PamAccess,
createPolicySchema: CreatePamAccessPolicySchema,
updatePolicySchema: UpdatePamAccessPolicySchema,
policyResponseSchema: PamAccessPolicySchema
});
}
};

View File

@@ -7,6 +7,7 @@ import { registerDashboardRouter } from "@app/server/routes/v1/dashboard-router"
import { registerSecretSyncRouter, SECRET_SYNC_REGISTER_ROUTER_MAP } from "@app/server/routes/v1/secret-sync-routers";
import { registerAdminRouter } from "./admin-router";
import { APPROVAL_POLICY_REGISTER_ROUTER_MAP } from "./approval-policy-routers";
import { registerAuthRoutes } from "./auth-router";
import { registerProjectBotRouter } from "./bot-router";
import { registerCaRouter } from "./certificate-authority-router";
@@ -275,4 +276,14 @@ export const registerV1Routes = async (server: FastifyZodProvider) => {
await server.register(registerEventRouter, { prefix: "/events" });
await server.register(registerUpgradePathRouter, { prefix: "/upgrade-path" });
await server.register(
async (approvalPolicyRouter) => {
// Register policy type-specific endpoints
for await (const [type, router] of Object.entries(APPROVAL_POLICY_REGISTER_ROUTER_MAP)) {
await approvalPolicyRouter.register(router, { prefix: `/${type}` });
}
},
{ prefix: "/approval-policies" }
);
};

View File

@@ -72,11 +72,11 @@ import { checklyConnectionService } from "./checkly/checkly-connection-service";
import { ValidateCloudflareConnectionCredentialsSchema } from "./cloudflare/cloudflare-connection-schema";
import { cloudflareConnectionService } from "./cloudflare/cloudflare-connection-service";
import { ValidateDatabricksConnectionCredentialsSchema } from "./databricks";
import { ValidateDNSMadeEasyConnectionCredentialsSchema } from "./dns-made-easy/dns-made-easy-connection-schema";
import { dnsMadeEasyConnectionService } from "./dns-made-easy/dns-made-easy-connection-service";
import { databricksConnectionService } from "./databricks/databricks-connection-service";
import { ValidateDigitalOceanConnectionCredentialsSchema } from "./digital-ocean";
import { digitalOceanAppPlatformConnectionService } from "./digital-ocean/digital-ocean-connection-service";
import { ValidateDNSMadeEasyConnectionCredentialsSchema } from "./dns-made-easy/dns-made-easy-connection-schema";
import { dnsMadeEasyConnectionService } from "./dns-made-easy/dns-made-easy-connection-service";
import { ValidateFlyioConnectionCredentialsSchema } from "./flyio";
import { flyioConnectionService } from "./flyio/flyio-connection-service";
import { ValidateGcpConnectionCredentialsSchema } from "./gcp";

View File

@@ -15,8 +15,8 @@ import {
TOracleDBConnectionInput,
TValidateOracleDBConnectionCredentialsSchema
} from "@app/ee/services/app-connections/oracledb";
import { TGatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2-service";
import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
import { TGatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2-service";
import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal";
import { TSqlConnectionConfig } from "@app/services/app-connection/shared/sql/sql-connection-types";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";

View File

@@ -0,0 +1,34 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
// Approval Policy
export type TApprovalPolicyDALFactory = ReturnType<typeof approvalPolicyDALFactory>;
export const approvalPolicyDALFactory = (db: TDbClient) => {
const orm = ormify(db, TableName.ApprovalPolicies);
return {
...orm
};
};
// Approval Policy Steps
export type TApprovalPolicyStepsDALFactory = ReturnType<typeof approvalPolicyStepsDALFactory>;
export const approvalPolicyStepsDALFactory = (db: TDbClient) => {
const orm = ormify(db, TableName.ApprovalPolicySteps);
return orm;
};
// Approval Policy Step Approvers
export type TApprovalPolicyStepApproversDALFactory = ReturnType<typeof approvalPolicyStepApproversDALFactory>;
export const approvalPolicyStepApproversDALFactory = (db: TDbClient) => {
const orm = ormify(db, TableName.ApprovalPolicyStepApprovers);
return orm;
};
// Approval Policy Grants
export type TApprovalRequestGrantsDALFactory = ReturnType<typeof approvalRequestGrantsDALFactory>;
export const approvalRequestGrantsDALFactory = (db: TDbClient) => {
const orm = ormify(db, TableName.ApprovalRequestGrants);
return orm;
};

View File

@@ -0,0 +1,14 @@
export enum ApprovalPolicyType {
PamAccess = "pam-access"
}
export enum ApproverType {
Group = "group",
User = "user"
}
export enum ApprovalRequestGrantStatus {
Active = "active",
Expired = "expired",
Revoked = "revoked"
}

View File

@@ -0,0 +1,9 @@
import { ApprovalPolicyType } from "./approval-policy-enums";
import { TApprovalPolicy, TApprovalPolicyInputs, TApprovalResourceFactory } from "./approval-policy-types";
import { pamAccessPolicyFactory } from "./pam-access/pam-access-policy-factory";
type TApprovalPolicyFactoryImplementation = TApprovalResourceFactory<TApprovalPolicyInputs, TApprovalPolicy>;
export const APPROVAL_POLICY_FACTORY_MAP: Record<ApprovalPolicyType, TApprovalPolicyFactoryImplementation> = {
[ApprovalPolicyType.PamAccess]: pamAccessPolicyFactory as TApprovalPolicyFactoryImplementation
};

View File

@@ -0,0 +1,46 @@
import { z } from "zod";
import { ApprovalPoliciesSchema } from "@app/db/schemas";
import { ApproverType } from "./approval-policy-enums";
export const BaseApprovalPolicySchema = ApprovalPoliciesSchema;
export const BaseCreateApprovalPolicySchema = z.object({
projectId: z.string().uuid(),
organizationId: z.string().uuid(),
name: z.string().min(1).max(128),
maxRequestTtlSeconds: z.number().min(3600).max(2592000).nullable().optional(), // 1 hour to 30 days
steps: 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()
})
.array()
});
export const BaseUpdateApprovalPolicySchema = z.object({
name: z.string().min(1).max(128).optional(),
maxRequestTtlSeconds: z.number().min(3600).max(2592000).nullable().optional(), // 1 hour to 30 days
steps: 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()
})
.array()
.optional()
});

View File

@@ -0,0 +1,217 @@
import { ActionProjectType, ProjectMembershipRole, TApprovalPolicies } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { ForbiddenRequestError } from "@app/lib/errors";
import { OrgServiceActor } from "@app/lib/types";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import {
TApprovalPolicyDALFactory,
TApprovalPolicyStepApproversDALFactory,
TApprovalPolicyStepsDALFactory
} from "./approval-policy-dal";
import { ApprovalPolicyType, ApproverType } from "./approval-policy-enums";
import { TCreatePolicyDTO, TUpdatePolicyDTO } from "./approval-policy-types";
type TApprovalPolicyServiceFactoryDep = {
approvalPolicyDAL: TApprovalPolicyDALFactory;
approvalPolicyStepsDAL: TApprovalPolicyStepsDALFactory;
approvalPolicyStepApproversDAL: TApprovalPolicyStepApproversDALFactory;
projectDAL: TProjectDALFactory;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">;
};
export type TApprovalPolicyServiceFactory = ReturnType<typeof approvalPolicyServiceFactory>;
export const approvalPolicyServiceFactory = ({
approvalPolicyDAL,
approvalPolicyStepsDAL,
approvalPolicyStepApproversDAL,
permissionService
}: TApprovalPolicyServiceFactoryDep) => {
const create = async (
policyType: ApprovalPolicyType,
{ projectId, organizationId, name, maxRequestTtlSeconds, conditions, constraints, steps }: TCreatePolicyDTO,
actor: OrgServiceActor
) => {
const { hasRole } = await permissionService.getProjectPermission({
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorId: actor.id,
actorOrgId: actor.orgId,
projectId,
actionProjectType: ActionProjectType.Any
});
if (!hasRole(ProjectMembershipRole.Admin)) {
throw new ForbiddenRequestError({ message: "User has insufficient privileges" });
}
const policy = await approvalPolicyDAL.transaction(async (tx) => {
const newPolicy = await approvalPolicyDAL.create(
{
projectId,
organizationId,
name,
maxRequestTtlSeconds,
conditions: { version: 1, conditions },
constraints: { version: 1, constraints },
type: policyType
},
tx
);
// Create policy steps and their approvers
await Promise.all(
steps.map(async (step, i) => {
const newStep = await approvalPolicyStepsDAL.create(
{
policyId: newPolicy.id,
requiredApprovals: step.requiredApprovals,
stepNumber: i + 1,
name: step.name,
notifyApprovers: step.notifyApprovers
},
tx
);
if (step.approvers?.length) {
await Promise.all(
step.approvers.map((approver) =>
approvalPolicyStepApproversDAL.create(
{
policyStepId: newStep.id,
userId: approver.type === ApproverType.User ? approver.id : null,
groupId: approver.type === ApproverType.Group ? approver.id : null
},
tx
)
)
);
}
})
);
return newPolicy;
});
return {
policy
};
};
const updateById = async (
policyId: string,
{ name, maxRequestTtlSeconds, conditions, constraints, steps }: TUpdatePolicyDTO,
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 updatedPolicy = await approvalPolicyDAL.transaction(async (tx) => {
const updateDoc: Partial<TApprovalPolicies> = {};
if (name !== undefined) {
updateDoc.name = name;
}
if (maxRequestTtlSeconds !== undefined) {
updateDoc.maxRequestTtlSeconds = maxRequestTtlSeconds;
}
if (conditions !== undefined) {
updateDoc.conditions = { version: 1, conditions };
}
if (constraints !== undefined) {
updateDoc.constraints = { version: 1, constraints };
}
const updated = await approvalPolicyDAL.updateById(policyId, updateDoc, tx);
if (steps !== undefined) {
await approvalPolicyStepsDAL.delete({ policyId }, tx);
await Promise.all(
steps.map(async (step, i) => {
const newStep = await approvalPolicyStepsDAL.create(
{
policyId,
requiredApprovals: step.requiredApprovals,
stepNumber: i + 1,
name: step.name,
notifyApprovers: step.notifyApprovers
},
tx
);
if (step.approvers?.length) {
await Promise.all(
step.approvers.map((approver) =>
approvalPolicyStepApproversDAL.create(
{
policyStepId: newStep.id,
userId: approver.type === ApproverType.User ? approver.id : null,
groupId: approver.type === ApproverType.Group ? approver.id : null
},
tx
)
)
);
}
})
);
}
return updated;
});
return {
policy: updatedPolicy
};
};
const deleteById = 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 deletedPolicy = await approvalPolicyDAL.deleteById(policyId);
return {
policy: deletedPolicy
};
};
return {
create,
updateById,
deleteById
};
};

View File

@@ -0,0 +1,68 @@
import { TApprovalPolicyDALFactory, TApprovalRequestGrantsDALFactory } from "./approval-policy-dal";
import { ApprovalPolicyType, ApproverType } from "./approval-policy-enums";
import {
TPamAccessPolicy,
TPamAccessPolicyConditions,
TPamAccessPolicyConstraints,
TPamAccessPolicyInputs
} from "./pam-access/pam-access-policy-types";
export type TApprovalPolicy = TPamAccessPolicy;
export type TApprovalPolicyInputs = TPamAccessPolicyInputs;
export type TApprovalPolicyConditions = TPamAccessPolicyConditions;
export type TApprovalPolicyConstraints = TPamAccessPolicyConstraints;
// DTOs
export interface TCreatePolicyDTO {
projectId: TApprovalPolicy["projectId"];
organizationId: TApprovalPolicy["organizationId"];
name: TApprovalPolicy["name"];
maxRequestTtlSeconds?: TApprovalPolicy["maxRequestTtlSeconds"];
conditions: TApprovalPolicy["conditions"]["conditions"];
constraints: TApprovalPolicy["constraints"]["constraints"];
steps: {
name?: string | null;
requiredApprovals: number;
notifyApprovers?: boolean;
approvers: {
type: ApproverType.User | ApproverType.Group;
id: string;
}[];
}[];
}
export interface TUpdatePolicyDTO {
name?: TApprovalPolicy["name"];
maxRequestTtlSeconds?: TApprovalPolicy["maxRequestTtlSeconds"];
conditions?: TApprovalPolicy["conditions"]["conditions"];
constraints?: TApprovalPolicy["constraints"]["constraints"];
steps?: {
name?: string | null;
requiredApprovals: number;
notifyApprovers?: boolean;
approvers: {
type: ApproverType.User | ApproverType.Group;
id: string;
}[];
}[];
}
// Factory
export type TApprovalRequestFactoryMatchPolicy<I extends TApprovalPolicyInputs, P extends TApprovalPolicy> = (
approvalPolicyDAL: TApprovalPolicyDALFactory,
projectId: string,
inputs: I
) => Promise<P | null>;
export type TApprovalRequestFactoryCanAccess<I extends TApprovalPolicyInputs> = (
approvalRequestGrantsDAL: TApprovalRequestGrantsDALFactory,
projectId: string,
userId: string,
inputs: I
) => Promise<boolean>;
export type TApprovalResourceFactory<I extends TApprovalPolicyInputs, P extends TApprovalPolicy> = (
policyType: ApprovalPolicyType
) => {
matchPolicy: TApprovalRequestFactoryMatchPolicy<I, P>;
canAccess: TApprovalRequestFactoryCanAccess<I>;
};

View File

@@ -0,0 +1,86 @@
import picomatch from "picomatch";
import { ApprovalRequestGrantStatus } from "../approval-policy-enums";
import {
TApprovalRequestFactoryCanAccess,
TApprovalRequestFactoryMatchPolicy,
TApprovalResourceFactory
} from "../approval-policy-types";
import { TPamAccessPolicy, TPamAccessPolicyInputs } from "./pam-access-policy-types";
export const pamAccessPolicyFactory: TApprovalResourceFactory<TPamAccessPolicyInputs, TPamAccessPolicy> = (
policyType
) => {
const matchPolicy: TApprovalRequestFactoryMatchPolicy<TPamAccessPolicyInputs, TPamAccessPolicy> = async (
approvalPolicyDAL,
projectId,
inputs
) => {
const policies = await approvalPolicyDAL.find({
type: policyType,
projectId
});
let bestMatch: { policy: TPamAccessPolicy; wildcardCount: number; pathLength: number } | null = null;
for (const policy of policies) {
const p = policy as TPamAccessPolicy;
for (const c of p.conditions.conditions) {
if (!c.targetResources.some((r) => r === inputs.resourceId)) {
// eslint-disable-next-line no-continue
continue;
}
// Find the most specific path pattern
// TODO: 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;
const pathLength = pathPattern.length;
if (
!bestMatch ||
wildcardCount < bestMatch.wildcardCount ||
(wildcardCount === bestMatch.wildcardCount && pathLength > bestMatch.pathLength)
) {
bestMatch = { policy: p, wildcardCount, pathLength };
}
}
}
}
}
return bestMatch?.policy || null;
};
const canAccess: TApprovalRequestFactoryCanAccess<TPamAccessPolicyInputs> = async (
approvalRequestGrantsDAL,
projectId,
userId,
inputs
) => {
const grants = await approvalRequestGrantsDAL.find({
granteeUserId: userId,
type: policyType,
status: ApprovalRequestGrantStatus.Active,
projectId,
revokedAt: null
});
// TODO: 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);
return (
grantAttributes.resourceId === inputs.resourceId &&
isMatch(inputs.accountPath) &&
(!grant.expiresAt || grant.expiresAt > new Date())
);
});
};
return {
matchPolicy,
canAccess
};
};

View File

@@ -0,0 +1,52 @@
import { z } from "zod";
import {
BaseApprovalPolicySchema,
BaseCreateApprovalPolicySchema,
BaseUpdateApprovalPolicySchema
} from "../approval-policy-schemas";
// Inputs
export const PamAccessPolicyInputsSchema = z.object({
resourceId: z.string().uuid(),
accountPath: z.string()
});
// Conditions
export const PamAccessPolicyConditionsSchema = z
.object({
targetResources: z.string().uuid().array(),
accountPaths: z.string().array() // TODO: 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)
})
});
// Policy
export const PamAccessPolicySchema = BaseApprovalPolicySchema.extend({
conditions: z.object({
version: z.literal(1),
conditions: PamAccessPolicyConditionsSchema
}),
constraints: z.object({
version: z.literal(1),
constraints: PamAccessPolicyConstraintsSchema
})
});
export const CreatePamAccessPolicySchema = BaseCreateApprovalPolicySchema.extend({
conditions: PamAccessPolicyConditionsSchema,
constraints: PamAccessPolicyConstraintsSchema
});
export const UpdatePamAccessPolicySchema = BaseUpdateApprovalPolicySchema.extend({
conditions: PamAccessPolicyConditionsSchema.optional(),
constraints: PamAccessPolicyConstraintsSchema.optional()
});

View File

@@ -0,0 +1,13 @@
import { z } from "zod";
import {
PamAccessPolicyConditionsSchema,
PamAccessPolicyConstraintsSchema,
PamAccessPolicyInputsSchema,
PamAccessPolicySchema
} from "./pam-access-policy-schemas";
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>;