diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 02394de4d..be6f45943 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -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 diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 12e30eb38..d5023d9a8 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -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, diff --git a/backend/src/db/migrations/20251203002657_global-approvals.ts b/backend/src/db/migrations/20251203002657_global-approvals.ts index c5ceda98d..fa30cab2d 100644 --- a/backend/src/db/migrations/20251203002657_global-approvals.ts +++ b/backend/src/db/migrations/20251203002657_global-approvals.ts @@ -1,4 +1,5 @@ import { Knex } from "knex"; + import { TableName } from "../schemas"; import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 860020949..528582c59 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -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"; diff --git a/backend/src/ee/services/pam-account/pam-account-service.ts b/backend/src/ee/services/pam-account/pam-account-service.ts index 019df00ec..6a3bcf7da 100644 --- a/backend/src/ee/services/pam-account/pam-account-service.ts +++ b/backend/src/ee/services/pam-account/pam-account-service.ts @@ -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; + approvalPolicyDAL: TApprovalPolicyDALFactory; + approvalRequestGrantsDAL: TApprovalRequestGrantsDALFactory; }; export type TPamAccountServiceFactory = ReturnType; @@ -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, diff --git a/backend/src/lib/errors/index.ts b/backend/src/lib/errors/index.ts index dab9d3278..b5497ca21 100644 --- a/backend/src/lib/errors/index.ts +++ b/backend/src/lib/errors/index.ts @@ -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; + } +} diff --git a/backend/src/server/plugins/error-handler.ts b/backend/src/server/plugins/error-handler.ts index e703df5ef..df1988ce3 100644 --- a/backend/src/server/plugins/error-handler.ts +++ b/backend/src/server/plugins/error-handler.ts @@ -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, diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 914491d3c..ef3e83b6e 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -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[] = []; diff --git a/backend/src/server/routes/v1/app-connection-routers/index.ts b/backend/src/server/routes/v1/app-connection-routers/index.ts index d7a4065fd..4eef89128 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -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"; 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 new file mode 100644 index 000000000..44e56138d --- /dev/null +++ b/backend/src/server/routes/v1/approval-policy-routers/approval-policy-endpoints.ts @@ -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 =

({ + 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 }; + } + }); +}; diff --git a/backend/src/server/routes/v1/approval-policy-routers/index.ts b/backend/src/server/routes/v1/approval-policy-routers/index.ts new file mode 100644 index 000000000..a60b20884 --- /dev/null +++ b/backend/src/server/routes/v1/approval-policy-routers/index.ts @@ -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 +> = { + [ApprovalPolicyType.PamAccess]: async (server: FastifyZodProvider) => { + registerApprovalPolicyEndpoints({ + server, + policyType: ApprovalPolicyType.PamAccess, + createPolicySchema: CreatePamAccessPolicySchema, + updatePolicySchema: UpdatePamAccessPolicySchema, + policyResponseSchema: PamAccessPolicySchema + }); + } +}; diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index c27399453..3b4b10b88 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -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" } + ); }; diff --git a/backend/src/services/app-connection/app-connection-service.ts b/backend/src/services/app-connection/app-connection-service.ts index cd9bc6d9e..07e043c62 100644 --- a/backend/src/services/app-connection/app-connection-service.ts +++ b/backend/src/services/app-connection/app-connection-service.ts @@ -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"; diff --git a/backend/src/services/app-connection/app-connection-types.ts b/backend/src/services/app-connection/app-connection-types.ts index e26df87f5..ed1e145d9 100644 --- a/backend/src/services/app-connection/app-connection-types.ts +++ b/backend/src/services/app-connection/app-connection-types.ts @@ -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"; diff --git a/backend/src/services/approval-policy/approval-policy-dal.ts b/backend/src/services/approval-policy/approval-policy-dal.ts new file mode 100644 index 000000000..795e72d02 --- /dev/null +++ b/backend/src/services/approval-policy/approval-policy-dal.ts @@ -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; +export const approvalPolicyDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.ApprovalPolicies); + + return { + ...orm + }; +}; + +// Approval Policy Steps +export type TApprovalPolicyStepsDALFactory = ReturnType; +export const approvalPolicyStepsDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.ApprovalPolicySteps); + return orm; +}; + +// Approval Policy Step Approvers +export type TApprovalPolicyStepApproversDALFactory = ReturnType; +export const approvalPolicyStepApproversDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.ApprovalPolicyStepApprovers); + return orm; +}; + +// Approval Policy Grants +export type TApprovalRequestGrantsDALFactory = ReturnType; +export const approvalRequestGrantsDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.ApprovalRequestGrants); + return orm; +}; diff --git a/backend/src/services/approval-policy/approval-policy-enums.ts b/backend/src/services/approval-policy/approval-policy-enums.ts new file mode 100644 index 000000000..91e7a58f9 --- /dev/null +++ b/backend/src/services/approval-policy/approval-policy-enums.ts @@ -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" +} diff --git a/backend/src/services/approval-policy/approval-policy-factory.ts b/backend/src/services/approval-policy/approval-policy-factory.ts new file mode 100644 index 000000000..ed5fb7579 --- /dev/null +++ b/backend/src/services/approval-policy/approval-policy-factory.ts @@ -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; + +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 new file mode 100644 index 000000000..b724b6e54 --- /dev/null +++ b/backend/src/services/approval-policy/approval-policy-schemas.ts @@ -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() +}); diff --git a/backend/src/services/approval-policy/approval-policy-service.ts b/backend/src/services/approval-policy/approval-policy-service.ts new file mode 100644 index 000000000..e2fa5d117 --- /dev/null +++ b/backend/src/services/approval-policy/approval-policy-service.ts @@ -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; +}; +export type TApprovalPolicyServiceFactory = ReturnType; + +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 = {}; + + 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 + }; +}; diff --git a/backend/src/services/approval-policy/approval-policy-types.ts b/backend/src/services/approval-policy/approval-policy-types.ts new file mode 100644 index 000000000..ecfa87470 --- /dev/null +++ b/backend/src/services/approval-policy/approval-policy-types.ts @@ -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 = ( + approvalPolicyDAL: TApprovalPolicyDALFactory, + projectId: string, + inputs: I +) => Promise

; +export type TApprovalRequestFactoryCanAccess = ( + approvalRequestGrantsDAL: TApprovalRequestGrantsDALFactory, + projectId: string, + userId: string, + inputs: I +) => Promise; + +export type TApprovalResourceFactory = ( + policyType: ApprovalPolicyType +) => { + matchPolicy: TApprovalRequestFactoryMatchPolicy; + canAccess: TApprovalRequestFactoryCanAccess; +}; 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 new file mode 100644 index 000000000..4bc20fa0c --- /dev/null +++ b/backend/src/services/approval-policy/pam-access/pam-access-policy-factory.ts @@ -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 = ( + policyType +) => { + const matchPolicy: TApprovalRequestFactoryMatchPolicy = 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 = 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 + }; +}; 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 new file mode 100644 index 000000000..b2b4cc440 --- /dev/null +++ b/backend/src/services/approval-policy/pam-access/pam-access-policy-schemas.ts @@ -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() +}); 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 new file mode 100644 index 000000000..48dd98dbc --- /dev/null +++ b/backend/src/services/approval-policy/pam-access/pam-access-policy-types.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; + +import { + PamAccessPolicyConditionsSchema, + PamAccessPolicyConstraintsSchema, + PamAccessPolicyInputsSchema, + PamAccessPolicySchema +} from "./pam-access-policy-schemas"; + +export type TPamAccessPolicy = z.infer; +export type TPamAccessPolicyInputs = z.infer; +export type TPamAccessPolicyConditions = z.infer; +export type TPamAccessPolicyConstraints = z.infer;