Merge branch 'main' into ENG-4111

This commit is contained in:
x032205
2025-11-10 09:25:15 -05:00
22 changed files with 317 additions and 25 deletions
@@ -371,6 +371,7 @@ export enum EventType {
SIGN_CERTIFICATE_FROM_PROFILE = "sign-certificate-from-profile",
ORDER_CERTIFICATE_FROM_PROFILE = "order-certificate-from-profile",
RENEW_CERTIFICATE = "renew-certificate",
GET_CERTIFICATE_PROFILE_LATEST_ACTIVE_BUNDLE = "get-certificate-profile-latest-active-bundle",
UPDATE_CERTIFICATE_RENEWAL_CONFIG = "update-certificate-renewal-config",
DISABLE_CERTIFICATE_RENEWAL_CONFIG = "disable-certificate-renewal-config",
ATTEMPT_CREATE_SLACK_INTEGRATION = "attempt-create-slack-integration",
@@ -2752,6 +2753,17 @@ interface OrderCertificateFromProfile {
};
}
interface GetCertificateProfileLatestActiveBundle {
type: EventType.GET_CERTIFICATE_PROFILE_LATEST_ACTIVE_BUNDLE;
metadata: {
certificateProfileId: string;
certificateId: string;
commonName: string;
profileName: string;
serialNumber: string;
};
}
interface RenewCertificate {
type: EventType.RENEW_CERTIFICATE;
metadata: {
@@ -4282,6 +4294,7 @@ export type Event =
| DeleteCertificateProfile
| GetCertificateProfile
| ListCertificateProfiles
| GetCertificateProfileLatestActiveBundle
| IssueCertificateFromProfile
| SignCertificateFromProfile
| OrderCertificateFromProfile
+4
View File
@@ -1178,6 +1178,10 @@ export const registerRoutes = async (
apiEnrollmentConfigDAL,
estEnrollmentConfigDAL,
acmeEnrollmentConfigDAL,
certificateBodyDAL,
certificateSecretDAL,
certificateAuthorityDAL,
certificateAuthorityCertDAL,
permissionService,
kmsService,
projectDAL
@@ -498,6 +498,71 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
}
});
server.route({
method: "GET",
url: "/:id/certificates/latest-active-bundle",
config: {
rateLimit: readLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificateProfiles],
description: "Get latest active certificate bundle for a profile",
params: z.object({
id: z.string().uuid()
}),
response: {
200: z.object({
certificate: z.string().nullable(),
certificateChain: z.string().nullable(),
privateKey: z.string().nullable(),
serialNumber: z.string().nullable()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const response = await server.services.certificateProfile.getLatestActiveCertificateBundle({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
profileId: req.params.id
});
if (!response) {
return {
certificate: null,
certificateChain: null,
privateKey: null,
serialNumber: null
};
}
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: response.certObj.projectId,
event: {
type: EventType.GET_CERTIFICATE_PROFILE_LATEST_ACTIVE_BUNDLE,
metadata: {
certificateProfileId: response.profile.id,
certificateId: response.certObj.id,
commonName: response.certObj.commonName,
profileName: response.profile.slug,
serialNumber: response.certObj.serialNumber
}
}
});
return {
certificate: response.certificate,
certificateChain: response.certificateChain,
privateKey: response.privateKey,
serialNumber: response.certObj.serialNumber
};
}
});
server.route({
method: "GET",
url: "/:id/acme/eab-secret/reveal",
+1 -1
View File
@@ -11,5 +11,5 @@ export const registerV3Routes = async (server: FastifyZodProvider) => {
await server.register(registerUserRouter, { prefix: "/users" });
await server.register(registerDeprecatedSecretRouter, { prefix: "/secrets" });
await server.register(registerExternalMigrationRouter, { prefix: "/external-migration" });
await server.register(registerCertificatesRouter, { prefix: "/certificates" });
await server.register(registerCertificatesRouter, { prefix: "/pki/certificates" });
};
@@ -462,6 +462,24 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
}
};
const getLatestActiveCertificateForProfile = async (profileId: string, tx?: Knex) => {
try {
const now = new Date();
const certificate = await (tx || db)(TableName.Certificate)
.where("profileId", profileId)
.where("status", "active")
.where("notAfter", ">", now)
.whereNull("revokedAt")
.orderBy("createdAt", "desc")
.first();
return certificate;
} catch (error) {
throw new DatabaseError({ error, name: "Get latest active certificate by profile" });
}
};
const isProfileInUse = async (profileId: string, tx?: Knex) => {
try {
const doc = await (tx || db)(TableName.Certificate).where("profileId", profileId).count("*").first();
@@ -485,6 +503,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
countByProjectId,
findByNameAndProjectId,
getCertificatesByProfile,
getLatestActiveCertificateForProfile,
isProfileInUse
};
};
@@ -9,6 +9,10 @@ import type { TPermissionServiceFactory } from "@app/ee/services/permission/perm
import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
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 { TAcmeEnrollmentConfigDALFactory } from "../enrollment-config/acme-enrollment-config-dal";
import type { TApiEnrollmentConfigDALFactory } from "../enrollment-config/api-enrollment-config-dal";
@@ -178,6 +182,54 @@ describe("CertificateProfileService", () => {
transaction: vi.fn()
} as unknown as Pick<TProjectDALFactory, "findProjectBySlug" | "findOne" | "updateById" | "findById" | "transaction">;
const mockCertificateBodyDAL = {
create: vi.fn(),
findById: vi.fn(),
updateById: vi.fn(),
deleteById: vi.fn(),
transaction: vi.fn(),
find: vi.fn(),
findOne: vi.fn(),
update: vi.fn(),
delete: vi.fn()
} as unknown as TCertificateBodyDALFactory;
const mockCertificateSecretDAL = {
create: vi.fn(),
findById: vi.fn(),
updateById: vi.fn(),
deleteById: vi.fn(),
transaction: vi.fn(),
find: vi.fn(),
findOne: vi.fn(),
update: vi.fn(),
delete: vi.fn()
} as unknown as TCertificateSecretDALFactory;
const mockCertificateAuthorityDAL = {
create: vi.fn(),
findById: vi.fn(),
updateById: vi.fn(),
deleteById: vi.fn(),
transaction: vi.fn(),
find: vi.fn(),
findOne: vi.fn(),
update: vi.fn(),
delete: vi.fn()
} as unknown as TCertificateAuthorityDALFactory;
const mockCertificateAuthorityCertDAL = {
create: vi.fn(),
findById: vi.fn(),
updateById: vi.fn(),
deleteById: vi.fn(),
transaction: vi.fn(),
find: vi.fn(),
findOne: vi.fn(),
update: vi.fn(),
delete: vi.fn()
} as unknown as TCertificateAuthorityCertDALFactory;
beforeEach(() => {
vi.spyOn(ForbiddenError, "from").mockReturnValue({
throwUnlessCan: vi.fn()
@@ -195,6 +247,10 @@ describe("CertificateProfileService", () => {
apiEnrollmentConfigDAL: mockApiEnrollmentConfigDAL,
estEnrollmentConfigDAL: mockEstEnrollmentConfigDAL,
acmeEnrollmentConfigDAL: mockAcmeEnrollmentConfigDAL,
certificateBodyDAL: mockCertificateBodyDAL,
certificateSecretDAL: mockCertificateSecretDAL,
certificateAuthorityDAL: mockCertificateAuthorityDAL,
certificateAuthorityCertDAL: mockCertificateAuthorityCertDAL,
permissionService: mockPermissionService,
kmsService: mockKmsService,
projectDAL: mockProjectDAL
@@ -4,6 +4,7 @@ import * as x509 from "@peculiar/x509";
import { ActionProjectType } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import {
ProjectPermissionCertificateActions,
ProjectPermissionCertificateProfileActions,
ProjectPermissionSub
} from "@app/ee/services/permission/project-permission";
@@ -15,6 +16,11 @@ import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/
import { ActorAuthMethod, ActorType } from "../auth/auth-type";
import { isCertChainValid } from "../certificate/certificate-fns";
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 { TAcmeEnrollmentConfigDALFactory } from "../enrollment-config/acme-enrollment-config-dal";
import { TApiEnrollmentConfigDALFactory } from "../enrollment-config/api-enrollment-config-dal";
@@ -142,6 +148,10 @@ type TCertificateProfileServiceFactoryDep = {
apiEnrollmentConfigDAL: TApiEnrollmentConfigDALFactory;
estEnrollmentConfigDAL: TEstEnrollmentConfigDALFactory;
acmeEnrollmentConfigDAL: TAcmeEnrollmentConfigDALFactory;
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "findOne">;
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "findOne">;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findById">;
certificateAuthorityCertDAL: Pick<TCertificateAuthorityCertDALFactory, "findById">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey">;
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug" | "findOne" | "updateById" | "findById" | "transaction">;
@@ -162,6 +172,8 @@ export const certificateProfileServiceFactory = ({
apiEnrollmentConfigDAL,
estEnrollmentConfigDAL,
acmeEnrollmentConfigDAL,
certificateBodyDAL,
certificateSecretDAL,
permissionService,
kmsService,
projectDAL
@@ -729,6 +741,106 @@ export const certificateProfileServiceFactory = ({
return certificates;
};
const getLatestActiveCertificateBundle = async ({
actor,
actorId,
actorAuthMethod,
actorOrgId,
profileId
}: {
actor: ActorType;
actorId: string;
actorAuthMethod: ActorAuthMethod;
actorOrgId: string;
profileId: string;
}) => {
const profile = await certificateProfileDAL.findById(profileId);
if (!profile) {
throw new NotFoundError({ message: "Certificate profile not found" });
}
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: profile.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionCertificateProfileActions.Read,
ProjectPermissionSub.CertificateProfiles
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionCertificateActions.Read,
ProjectPermissionSub.Certificates
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionCertificateActions.ReadPrivateKey,
ProjectPermissionSub.Certificates
);
const cert = await certificateProfileDAL.getLatestActiveCertificateForProfile(profileId);
if (!cert) {
return null;
}
const certBody = await certificateBodyDAL.findOne({ certId: cert.id });
const certificateManagerKeyId = await getProjectKmsCertificateKeyId({
projectId: cert.projectId,
projectDAL,
kmsService
});
const kmsDecryptor = await kmsService.decryptWithKmsKey({
kmsId: certificateManagerKeyId
});
const decryptedCert = await kmsDecryptor({
cipherTextBlob: certBody.encryptedCertificate
});
const certObj = new x509.X509Certificate(decryptedCert);
const certificate = certObj.toString("pem");
const decryptedCertChain = await kmsDecryptor({
cipherTextBlob: certBody.encryptedCertificateChain!
});
const certificateChain = decryptedCertChain.toString();
let privateKey = null;
try {
const { certPrivateKey } = await getCertificateCredentials({
certId: cert.id,
projectId: cert.projectId,
certificateSecretDAL,
projectDAL,
kmsService
});
privateKey = certPrivateKey;
} catch (error) {
// Private key might not exist for ACME certificates or other external workflows
// where the key is generated client-side
if (error instanceof NotFoundError) {
privateKey = null;
} else {
throw error;
}
}
return {
certificate,
certificateChain,
privateKey,
profile,
certObj: cert
};
};
const getEstConfigurationByProfile = async (
params:
| {
@@ -854,6 +966,7 @@ export const certificateProfileServiceFactory = ({
listProfiles,
deleteProfile,
getProfileCertificates,
getLatestActiveCertificateBundle,
getEstConfigurationByProfile,
revealAcmeEabSecret
};
@@ -675,7 +675,7 @@ export const certificateV3ServiceFactory = ({
status: CertificateOrderStatus.VALID
})),
authorizations: [],
finalize: `/api/v3/certificates/orders/${orderId}/completed`,
finalize: `/api/v3/pki/certificates/orders/${orderId}/completed`,
certificate: certificateResult.certificate,
projectId: certificateResult.projectId,
profileName: certificateResult.profileName