Merge pull request #4848 from Infisical/PKI-34-pki-acme-license-check

[PKI-34] Add license check for PKI ACME feature
This commit is contained in:
Fang-Pen Lin
2025-11-12 12:31:42 -08:00
committed by GitHub
17 changed files with 135 additions and 34 deletions

View File

@@ -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:

View File

@@ -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" }
);

View File

@@ -56,6 +56,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
secretsLimit: 40
},
pkiEst: false,
pkiAcme: false,
enforceMfa: false,
projectTemplates: false,
kmip: false,

View File

@@ -78,6 +78,7 @@ export type TFeatureSet = {
secretsLimit: number;
};
pkiEst: boolean;
pkiAcme: false;
enforceMfa: boolean;
projectTemplates: false;
kmip: false;

View File

@@ -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<TKeyStoreFactory, "getItem" | "setItemWithExpiry" | "deleteItem">;
kmsService: Pick<TKmsServiceFactory, "decryptWithKmsKey" | "generateKmsKey">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
certificateV3Service: Pick<TCertificateV3ServiceFactory, "signCertificateFromProfile">;
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;
};

View File

@@ -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),

View File

@@ -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);

View File

@@ -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
});

View File

@@ -85,6 +85,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
const findByIdWithConfigs = async (id: string, tx?: Knex): Promise<TCertificateProfileWithConfigs | undefined> => {
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
};

View File

@@ -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<TPermissionServiceFactory, "getProjectPermission">;
const mockLicenseService = {
getPlan: vi.fn()
} as unknown as Pick<TLicenseServiceFactory, "getPlan">;
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);

View File

@@ -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<TCertificateAuthorityDALFactory, "findById">;
certificateAuthorityCertDAL: Pick<TCertificateAuthorityCertDALFactory, "findById">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey">;
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug" | "findOne" | "updateById" | "findById" | "transaction">;
};
@@ -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);

View File

@@ -33,6 +33,10 @@ export type TCertificateProfileUpdate = Omit<TPkiCertificateProfilesUpdate, "enr
};
export type TCertificateProfileWithConfigs = TCertificateProfile & {
project?: {
id: string;
orgId: string;
};
certificateAuthority?: {
id: string;
projectId: string;

View File

@@ -26,9 +26,6 @@ export const envConfig = {
import.meta.env.VITE_TELEMETRY_CAPTURING_ENABLED === true
);
},
get ACME_FEATURE_ENABLED() {
return window?.__INFISICAL_RUNTIME_ENV__?.ACME_FEATURE_ENABLED ?? false;
},
get PLATFORM_VERSION() {
return import.meta.env.VITE_INFISICAL_PLATFORM_VERSION;

View File

@@ -7,7 +7,6 @@ declare global {
POSTHOG_API_KEY?: string;
INTERCOM_ID?: string;
TELEMETRY_CAPTURING_ENABLED: string;
ACME_FEATURE_ENABLED?: boolean;
};
}
}

View File

@@ -48,6 +48,7 @@ export type SubscriptionPlan = {
gateway: boolean;
externalKms: boolean;
pkiEst: boolean;
pkiAcme: boolean;
pkiLegacyTemplates: boolean;
enforceMfa: boolean;
enforceGoogleSSO: boolean;

View File

@@ -1,6 +1,6 @@
import { useState } from "react";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useState } from "react";
import { createNotification } from "@app/components/notifications";
import { Button, DeleteActionModal } from "@app/components/v2";
@@ -14,6 +14,8 @@ import {
useDeleteCertificateProfile
} from "@app/hooks/api/certificateProfiles";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { usePopUp } from "@app/hooks";
import { CreateProfileModal } from "./CreateProfileModal";
import { ProfileList } from "./ProfileList";
import { RevealAcmeEabSecretModal } from "./RevealAcmeEabSecretModal";
@@ -29,6 +31,7 @@ export const CertificateProfilesTab = () => {
const [selectedProfile, setSelectedProfile] = useState<TCertificateProfileWithDetails | null>(
null
);
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"] as const);
const deleteProfile = useDeleteCertificateProfile();
@@ -99,7 +102,17 @@ export const CertificateProfilesTab = () => {
onDeleteProfile={handleDeleteProfile}
/>
<CreateProfileModal isOpen={isCreateModalOpen} onClose={() => setIsCreateModalOpen(false)} />
<CreateProfileModal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
handlePopUpOpen={handlePopUpOpen}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => 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"
/>

View File

@@ -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<typeof createSchema>;
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" }
>
<SelectItem value="api">API</SelectItem>
<SelectItem value="est">EST</SelectItem>
{envConfig.ACME_FEATURE_ENABLED && <SelectItem value="acme">ACME</SelectItem>}
<SelectItem value="acme">ACME</SelectItem>
</Select>
</FormControl>
)}