diff --git a/.github/workflows/run-backend-bdd-tests.yml b/.github/workflows/run-backend-bdd-tests.yml index 52330582e..bf2075864 100644 --- a/.github/workflows/run-backend-bdd-tests.yml +++ b/.github/workflows/run-backend-bdd-tests.yml @@ -45,12 +45,13 @@ jobs: run: npm install working-directory: backend - - name: Output .env file + - name: Output .env file and enable feature flags for BDD tests run: | cp .env.example .env echo "ACME_DEVELOPMENT_MODE=true" >> .env - echo "ACME_FEATURE_ENABLED=true" >> .env echo "ACME_DEVELOPMENT_HTTP01_CHALLENGE_HOST_OVERRIDES={\"localhost\": \"host.docker.internal:8087\"}" >> .env + # Enable ACME feature in license for BDD tests + sed -i 's/pkiAcme: .*/pkiAcme: true,/g' backend/src/ee/services/license/license-fns.ts - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 with: diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index ce05ea3b6..7ff9ec09a 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -1,5 +1,4 @@ import { registerProjectTemplateRouter } from "@app/ee/routes/v1/project-template-router"; -import { getConfig } from "@app/lib/config/env"; import { registerAccessApprovalPolicyRouter } from "./access-approval-policy-router"; import { registerAccessApprovalRequestRouter } from "./access-approval-request-router"; @@ -109,10 +108,7 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { await server.register( async (pkiRouter) => { await pkiRouter.register(registerCaCrlRouter, { prefix: "/crl" }); - // Notice: current this feature is still in development and is not yet ready for production. - if (getConfig().isAcmeFeatureEnabled === true) { - await pkiRouter.register(registerPkiAcmeRouter, { prefix: "/acme" }); - } + await pkiRouter.register(registerPkiAcmeRouter, { prefix: "/acme" }); }, { prefix: "/pki" } ); diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index 97061e3ca..14b7bcfbd 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -56,6 +56,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ secretsLimit: 40 }, pkiEst: false, + pkiAcme: false, enforceMfa: false, projectTemplates: false, kmip: false, diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index c4ff6a8fa..5157b0730 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -78,6 +78,7 @@ export type TFeatureSet = { secretsLimit: number; }; pkiEst: boolean; + pkiAcme: false; enforceMfa: boolean; projectTemplates: false; kmip: false; diff --git a/backend/src/ee/services/pki-acme/pki-acme-service.ts b/backend/src/ee/services/pki-acme/pki-acme-service.ts index fa0d0ff66..55e9cc43f 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -29,6 +29,7 @@ import { TProjectDALFactory } from "@app/services/project/project-dal"; import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; import { getConfig } from "@app/lib/config/env"; +import { TLicenseServiceFactory } from "../license/license-service"; import { TPkiAcmeAccountDALFactory } from "./pki-acme-account-dal"; import { TPkiAcmeAuthDALFactory } from "./pki-acme-auth-dal"; import { TPkiAcmeChallengeDALFactory } from "./pki-acme-challenge-dal"; @@ -42,6 +43,7 @@ import { AcmeMalformedError, AcmeOrderNotReadyError, AcmeServerInternalError, + AcmeUnauthorizedError, AcmeUnsupportedIdentifierError } from "./pki-acme-errors"; import { buildUrl, extractAccountIdFromKid, validateDnsIdentifier } from "./pki-acme-fns"; @@ -101,6 +103,7 @@ type TPkiAcmeServiceFactoryDep = { >; keyStore: Pick; kmsService: Pick; + licenseService: Pick; certificateV3Service: Pick; acmeChallengeService: TPkiAcmeChallengeServiceFactory; }; @@ -116,6 +119,7 @@ export const pkiAcmeServiceFactory = ({ acmeChallengeDAL, keyStore, kmsService, + licenseService, certificateV3Service, acmeChallengeService }: TPkiAcmeServiceFactoryDep): TPkiAcmeServiceFactory => { @@ -127,6 +131,12 @@ export const pkiAcmeServiceFactory = ({ if (profile.enrollmentType !== EnrollmentType.ACME) { throw new NotFoundError({ message: "Certificate profile is not configured for ACME enrollment" }); } + const orgLicensePlan = await licenseService.getPlan(profile.project!.orgId); + if (!orgLicensePlan.pkiAcme) { + throw new AcmeUnauthorizedError({ + message: "Failed to validate ACME profile: Plan restriction. Upgrade plan to continue" + }); + } return profile; }; diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index b60971b1f..6f0502184 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -106,10 +106,6 @@ const envSchema = z HTTPS_ENABLED: zodStrBool, ROTATION_DEVELOPMENT_MODE: zodStrBool.default("false").optional(), DAILY_RESOURCE_CLEAN_UP_DEVELOPMENT_MODE: zodStrBool.default("false").optional(), - // Note: The ACME feature is still in development and is not yet ready for production. - // This is the feature flag to enable/disable the ACME feature. - // It's not intended to be used by users outside of the development team yet. - ACME_FEATURE_ENABLED: zodStrBool.default("false").optional(), ACME_DEVELOPMENT_MODE: zodStrBool.default("false").optional(), ACME_DEVELOPMENT_HTTP01_CHALLENGE_HOST_OVERRIDES: zpStr( z @@ -399,7 +395,6 @@ const envSchema = z (data.NODE_ENV === "development" && data.ROTATION_DEVELOPMENT_MODE) || data.NODE_ENV === "test", isDailyResourceCleanUpDevelopmentMode: data.NODE_ENV === "development" && data.DAILY_RESOURCE_CLEAN_UP_DEVELOPMENT_MODE, - isAcmeFeatureEnabled: data.NODE_ENV === "development" && data.ACME_FEATURE_ENABLED === true, isAcmeDevelopmentMode: data.NODE_ENV === "development" && data.ACME_DEVELOPMENT_MODE, isProductionMode: data.NODE_ENV === "production" || IS_PACKAGED, isRedisSentinelMode: Boolean(data.REDIS_SENTINEL_HOSTS), diff --git a/backend/src/server/plugins/serve-ui.ts b/backend/src/server/plugins/serve-ui.ts index 4330f9397..b71451b6e 100644 --- a/backend/src/server/plugins/serve-ui.ts +++ b/backend/src/server/plugins/serve-ui.ts @@ -31,10 +31,7 @@ export const registerServeUI = async ( CAPTCHA_SITE_KEY: appCfg.CAPTCHA_SITE_KEY, POSTHOG_API_KEY: appCfg.POSTHOG_PROJECT_API_KEY, INTERCOM_ID: appCfg.INTERCOM_ID, - TELEMETRY_CAPTURING_ENABLED: appCfg.TELEMETRY_ENABLED, - // The feature flag to enable/disable the ACME feature. - // Will be removed once the feature is ready for production. - ACME_FEATURE_ENABLED: appCfg.isAcmeFeatureEnabled + TELEMETRY_CAPTURING_ENABLED: appCfg.TELEMETRY_ENABLED }; const js = `window.__INFISICAL_RUNTIME_ENV__ = Object.freeze(${JSON.stringify(config)});`; return res.send(js); diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 7c2e3b326..067d296a3 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1184,6 +1184,7 @@ export const registerRoutes = async ( certificateAuthorityDAL, certificateAuthorityCertDAL, permissionService, + licenseService, kmsService, projectDAL }); @@ -2238,6 +2239,7 @@ export const registerRoutes = async ( acmeChallengeDAL, keyStore, kmsService, + licenseService, certificateV3Service, acmeChallengeService }); diff --git a/backend/src/services/certificate-profile/certificate-profile-dal.ts b/backend/src/services/certificate-profile/certificate-profile-dal.ts index 6415145ce..53172b744 100644 --- a/backend/src/services/certificate-profile/certificate-profile-dal.ts +++ b/backend/src/services/certificate-profile/certificate-profile-dal.ts @@ -85,6 +85,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => { const findByIdWithConfigs = async (id: string, tx?: Knex): Promise => { try { const query = (tx || db)(TableName.PkiCertificateProfile) + .leftJoin(TableName.Project, `${TableName.PkiCertificateProfile}.projectId`, `${TableName.Project}.id`) .leftJoin( TableName.CertificateAuthority, `${TableName.PkiCertificateProfile}.caId`, @@ -112,6 +113,8 @@ export const certificateProfileDALFactory = (db: TDbClient) => { ) .select(selectAllTableCols(TableName.PkiCertificateProfile)) .select( + db.ref("id").withSchema(TableName.Project).as("projectId"), + db.ref("orgId").withSchema(TableName.Project).as("orgId"), db.ref("id").withSchema(TableName.CertificateAuthority).as("caId"), db.ref("projectId").withSchema(TableName.CertificateAuthority).as("caProjectId"), db.ref("status").withSchema(TableName.CertificateAuthority).as("caStatus"), @@ -185,6 +188,11 @@ export const certificateProfileDALFactory = (db: TDbClient) => { } as TCertificateProfileWithConfigs["certificateTemplate"]) : undefined; + const project = { + id: result.projectId, + orgId: result.orgId + } as TCertificateProfileWithConfigs["project"]; + const transformedResult: TCertificateProfileWithConfigs = { id: result.id, projectId: result.projectId, @@ -201,6 +209,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => { estConfig, apiConfig, acmeConfig, + project, certificateAuthority, certificateTemplate }; diff --git a/backend/src/services/certificate-profile/certificate-profile-service.test.ts b/backend/src/services/certificate-profile/certificate-profile-service.test.ts index 9d9ab5947..8865ffb9b 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.test.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.test.ts @@ -6,14 +6,15 @@ import { ForbiddenError } from "@casl/ability"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; -import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { ActorType, AuthMethod } from "../auth/auth-type"; -import type { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal"; -import type { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal"; import type { TCertificateAuthorityCertDALFactory } from "../certificate-authority/certificate-authority-cert-dal"; import type { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal"; import type { TCertificateTemplateV2DALFactory } from "../certificate-template-v2/certificate-template-v2-dal"; +import type { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal"; +import type { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal"; import { TAcmeEnrollmentConfigDALFactory } from "../enrollment-config/acme-enrollment-config-dal"; import type { TApiEnrollmentConfigDALFactory } from "../enrollment-config/api-enrollment-config-dal"; import type { TEstEnrollmentConfigDALFactory } from "../enrollment-config/est-enrollment-config-dal"; @@ -166,6 +167,10 @@ describe("CertificateProfileService", () => { }) } as unknown as Pick; + const mockLicenseService = { + getPlan: vi.fn() + } as unknown as Pick; + const mockKmsService = { encryptWithKmsKey: vi .fn() @@ -252,6 +257,7 @@ describe("CertificateProfileService", () => { certificateAuthorityDAL: mockCertificateAuthorityDAL, certificateAuthorityCertDAL: mockCertificateAuthorityCertDAL, permissionService: mockPermissionService, + licenseService: mockLicenseService, kmsService: mockKmsService, projectDAL: mockProjectDAL }); @@ -275,6 +281,13 @@ describe("CertificateProfileService", () => { }; beforeEach(() => { + (mockProjectDAL.findById as any).mockResolvedValue({ + id: "project-123", + orgId: "org-123" + }); + (mockLicenseService.getPlan as any).mockResolvedValue({ + pkiAcme: true + }); (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate); (mockCertificateProfileDAL.findByNameAndProjectId as any).mockResolvedValue(null); (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null); @@ -405,6 +418,24 @@ describe("CertificateProfileService", () => { expect(result).toEqual(sampleProfile); expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("template-123"); }); + + it("should throw BadRequestError when plan does not support ACME", async () => { + (mockLicenseService.getPlan as any).mockResolvedValue({ + pkiAcme: false + }); + + await expect( + service.createProfile({ + ...mockActor, + projectId: "project-123", + data: validProfileData + }) + ).rejects.toThrowError( + new BadRequestError({ + message: "Failed to create certificate profile: Plan restriction. Upgrade plan to continue" + }) + ); + }); }); describe("updateProfile", () => { @@ -699,6 +730,13 @@ describe("CertificateProfileService", () => { } }; + (mockProjectDAL.findById as any).mockResolvedValue({ + id: "project-123", + orgId: "org-123" + }); + (mockLicenseService.getPlan as any).mockResolvedValue({ + pkiAcme: true + }); (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate); (mockCertificateProfileDAL.findByNameAndProjectId as any).mockResolvedValue(null); (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null); diff --git a/backend/src/services/certificate-profile/certificate-profile-service.ts b/backend/src/services/certificate-profile/certificate-profile-service.ts index 66a23a0e7..fe58f958f 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.ts @@ -14,13 +14,14 @@ import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { ActorAuthMethod, ActorType } from "../auth/auth-type"; -import { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal"; -import { getCertificateCredentials, isCertChainValid } from "../certificate/certificate-fns"; -import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal"; import { TCertificateAuthorityCertDALFactory } from "../certificate-authority/certificate-authority-cert-dal"; import { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal"; import { TCertificateTemplateV2DALFactory } from "../certificate-template-v2/certificate-template-v2-dal"; +import { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal"; +import { getCertificateCredentials, isCertChainValid } from "../certificate/certificate-fns"; +import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal"; import { TAcmeEnrollmentConfigDALFactory } from "../enrollment-config/acme-enrollment-config-dal"; import { TApiEnrollmentConfigDALFactory } from "../enrollment-config/api-enrollment-config-dal"; import { TAcmeConfigData, TApiConfigData, TEstConfigData } from "../enrollment-config/enrollment-config-types"; @@ -152,6 +153,7 @@ type TCertificateProfileServiceFactoryDep = { certificateAuthorityDAL: Pick; certificateAuthorityCertDAL: Pick; permissionService: Pick; + licenseService: Pick; kmsService: Pick; projectDAL: Pick; }; @@ -174,6 +176,7 @@ export const certificateProfileServiceFactory = ({ certificateBodyDAL, certificateSecretDAL, permissionService, + licenseService, kmsService, projectDAL }: TCertificateProfileServiceFactoryDep) => { @@ -205,6 +208,17 @@ export const certificateProfileServiceFactory = ({ ProjectPermissionSub.CertificateProfiles ); + const project = await projectDAL.findById(projectId); + if (!project) { + throw new NotFoundError({ message: "Project not found" }); + } + const plan = await licenseService.getPlan(project.orgId); + if (!plan.pkiAcme) { + throw new BadRequestError({ + message: "Failed to create certificate profile: Plan restriction. Upgrade plan to continue" + }); + } + // Validate that certificate template exists and belongs to the same project if (data.certificateTemplateId) { const template = await certificateTemplateV2DAL.findById(data.certificateTemplateId); diff --git a/backend/src/services/certificate-profile/certificate-profile-types.ts b/backend/src/services/certificate-profile/certificate-profile-types.ts index 4a3339857..030548e97 100644 --- a/backend/src/services/certificate-profile/certificate-profile-types.ts +++ b/backend/src/services/certificate-profile/certificate-profile-types.ts @@ -33,6 +33,10 @@ export type TCertificateProfileUpdate = Omit { const [selectedProfile, setSelectedProfile] = useState( null ); + const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"] as const); const deleteProfile = useDeleteCertificateProfile(); @@ -99,7 +102,17 @@ export const CertificateProfilesTab = () => { onDeleteProfile={handleDeleteProfile} /> - setIsCreateModalOpen(false)} /> + setIsCreateModalOpen(false)} + handlePopUpOpen={handlePopUpOpen} + /> + handlePopUpToggle("upgradePlan", isOpen)} + isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature} + text="Your current plan does not include access to managing template enrollment options for ACME. To unlock this feature, please upgrade to Infisical Enterprise plan." + /> {selectedProfile && ( <> @@ -109,6 +122,7 @@ export const CertificateProfilesTab = () => { setIsEditModalOpen(false); setSelectedProfile(null); }} + handlePopUpOpen={handlePopUpOpen} profile={selectedProfile} mode="edit" /> diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx index f53084a4e..712455ce9 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx @@ -1,8 +1,8 @@ -import { useEffect } from "react"; -import { Controller, useForm } from "react-hook-form"; import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { zodResolver } from "@hookform/resolvers/zod"; +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; @@ -18,8 +18,7 @@ import { TextArea, Tooltip } from "@app/components/v2"; -import { envConfig } from "@app/config/env"; -import { useProject } from "@app/context"; +import { useProject, useSubscription } from "@app/context"; import { useListCasByProjectId } from "@app/hooks/api/ca/queries"; import { TCertificateProfileWithDetails, @@ -29,6 +28,7 @@ import { useUpdateCertificateProfile } from "@app/hooks/api/certificateProfiles"; import { useListCertificateTemplatesV2 } from "@app/hooks/api/certificateTemplates/queries"; +import { UsePopUpState } from "@app/hooks/usePopUp"; const createSchema = z .object({ @@ -151,12 +151,25 @@ export type FormData = z.infer; interface Props { isOpen: boolean; onClose: () => void; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["upgradePlan"]>, + data?: { + isEnterpriseFeature?: boolean; + } + ) => void; profile?: TCertificateProfileWithDetails; mode?: "create" | "edit"; } -export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }: Props) => { +export const CreateProfileModal = ({ + isOpen, + onClose, + handlePopUpOpen, + profile, + mode = "create" +}: Props) => { const { currentProject } = useProject(); + const { subscription } = useSubscription(); const { data: caData } = useListCasByProjectId(currentProject?.id || ""); const { data: templateData } = useListCertificateTemplatesV2({ @@ -248,6 +261,15 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } }, [isEdit, profile, reset]); const onFormSubmit = async (data: FormData) => { + if (!isEdit && !subscription?.pkiAcme && data.enrollmentType === "acme") { + reset(); + onClose(); + handlePopUpOpen("upgradePlan", { + isEnterpriseFeature: true + }); + return; + } + if (!currentProject?.id && !isEdit) return; if (isEdit) { @@ -467,7 +489,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } > API EST - {envConfig.ACME_FEATURE_ENABLED && ACME} + ACME )}