Merge pull request #4777 from Infisical/feat/pki-sync-v2

PKI Syncs: Certificate Syncs and AWS ARN option
This commit is contained in:
carlosmonastyrski
2025-11-03 11:55:47 -03:00
committed by GitHub
102 changed files with 4150 additions and 1183 deletions
+8
View File
@@ -62,6 +62,9 @@ import {
TCertificateSecretsUpdate, TCertificateSecretsUpdate,
TCertificatesInsert, TCertificatesInsert,
TCertificatesUpdate, TCertificatesUpdate,
TCertificateSyncs,
TCertificateSyncsInsert,
TCertificateSyncsUpdate,
TCertificateTemplateEstConfigs, TCertificateTemplateEstConfigs,
TCertificateTemplateEstConfigsInsert, TCertificateTemplateEstConfigsInsert,
TCertificateTemplateEstConfigsUpdate, TCertificateTemplateEstConfigsUpdate,
@@ -738,6 +741,11 @@ declare module "knex/types/tables" {
TPkiSubscribersUpdate TPkiSubscribersUpdate
>; >;
[TableName.PkiSync]: KnexOriginal.CompositeTableType<TPkiSyncs, TPkiSyncsInsert, TPkiSyncsUpdate>; [TableName.PkiSync]: KnexOriginal.CompositeTableType<TPkiSyncs, TPkiSyncsInsert, TPkiSyncsUpdate>;
[TableName.CertificateSync]: KnexOriginal.CompositeTableType<
TCertificateSyncs,
TCertificateSyncsInsert,
TCertificateSyncsUpdate
>;
[TableName.UserGroupMembership]: KnexOriginal.CompositeTableType< [TableName.UserGroupMembership]: KnexOriginal.CompositeTableType<
TUserGroupMembership, TUserGroupMembership,
TUserGroupMembershipInsert, TUserGroupMembershipInsert,
@@ -0,0 +1,35 @@
import { Knex } from "knex";
import { TableName } from "@app/db/schemas";
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "@app/db/utils";
import { CertificateSyncStatus } from "@app/services/certificate-sync/certificate-sync-enums";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasTable(TableName.CertificateSync))) {
await knex.schema.createTable(TableName.CertificateSync, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.uuid("pkiSyncId").notNullable();
t.foreign("pkiSyncId").references("id").inTable(TableName.PkiSync).onDelete("CASCADE");
t.uuid("certificateId").notNullable();
t.foreign("certificateId").references("id").inTable(TableName.Certificate).onDelete("CASCADE");
t.string("syncStatus").defaultTo(CertificateSyncStatus.Pending);
t.text("lastSyncMessage");
t.datetime("lastSyncedAt");
t.timestamps(true, true, true);
// Ensure unique combination of pki sync and certificate
t.unique(["pkiSyncId", "certificateId"]);
t.index("pkiSyncId");
t.index("certificateId");
t.index("syncStatus");
});
await createOnUpdateTrigger(knex, TableName.CertificateSync);
}
}
export async function down(knex: Knex): Promise<void> {
await knex.schema.dropTableIfExists(TableName.CertificateSync);
await dropOnUpdateTrigger(knex, TableName.CertificateSync);
}
@@ -0,0 +1,21 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasColumn(TableName.CertificateSync, "externalIdentifier"))) {
await knex.schema.alterTable(TableName.CertificateSync, (t) => {
t.text("externalIdentifier").nullable();
t.index("externalIdentifier");
});
}
}
export async function down(knex: Knex): Promise<void> {
if (await knex.schema.hasColumn(TableName.CertificateSync, "externalIdentifier")) {
await knex.schema.alterTable(TableName.CertificateSync, (t) => {
t.dropIndex("externalIdentifier");
t.dropColumn("externalIdentifier");
});
}
}
@@ -0,0 +1,24 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const CertificateSyncsSchema = z.object({
id: z.string().uuid(),
pkiSyncId: z.string().uuid(),
certificateId: z.string().uuid(),
syncStatus: z.string().default("pending").nullable().optional(),
lastSyncMessage: z.string().nullable().optional(),
lastSyncedAt: z.date().nullable().optional(),
createdAt: z.date(),
updatedAt: z.date(),
externalIdentifier: z.string().nullable().optional()
});
export type TCertificateSyncs = z.infer<typeof CertificateSyncsSchema>;
export type TCertificateSyncsInsert = Omit<z.input<typeof CertificateSyncsSchema>, TImmutableDBKeys>;
export type TCertificateSyncsUpdate = Partial<Omit<z.input<typeof CertificateSyncsSchema>, TImmutableDBKeys>>;
+1
View File
@@ -17,6 +17,7 @@ export * from "./certificate-authority-crl";
export * from "./certificate-authority-secret"; export * from "./certificate-authority-secret";
export * from "./certificate-bodies"; export * from "./certificate-bodies";
export * from "./certificate-secrets"; export * from "./certificate-secrets";
export * from "./certificate-syncs";
export * from "./certificate-template-est-configs"; export * from "./certificate-template-est-configs";
export * from "./certificate-templates"; export * from "./certificate-templates";
export * from "./certificates"; export * from "./certificates";
+1
View File
@@ -161,6 +161,7 @@ export enum TableName {
AppConnection = "app_connections", AppConnection = "app_connections",
SecretSync = "secret_syncs", SecretSync = "secret_syncs",
PkiSync = "pki_syncs", PkiSync = "pki_syncs",
CertificateSync = "certificate_syncs",
KmipClient = "kmip_clients", KmipClient = "kmip_clients",
KmipOrgConfig = "kmip_org_configs", KmipOrgConfig = "kmip_org_configs",
KmipOrgServerCertificates = "kmip_org_server_certificates", KmipOrgServerCertificates = "kmip_org_server_certificates",
@@ -426,6 +426,7 @@ export enum EventType {
SECRET_SYNC_REMOVE_SECRETS = "secret-sync-remove-secrets", SECRET_SYNC_REMOVE_SECRETS = "secret-sync-remove-secrets",
GET_PKI_SYNCS = "get-pki-syncs", GET_PKI_SYNCS = "get-pki-syncs",
GET_PKI_SYNC = "get-pki-sync", GET_PKI_SYNC = "get-pki-sync",
GET_PKI_SYNC_CERTIFICATES = "get-pki-sync-certificates",
CREATE_PKI_SYNC = "create-pki-sync", CREATE_PKI_SYNC = "create-pki-sync",
UPDATE_PKI_SYNC = "update-pki-sync", UPDATE_PKI_SYNC = "update-pki-sync",
DELETE_PKI_SYNC = "delete-pki-sync", DELETE_PKI_SYNC = "delete-pki-sync",
@@ -3161,6 +3162,16 @@ interface GetPkiSyncEvent {
}; };
} }
interface GetPkiSyncCertificatesEvent {
type: EventType.GET_PKI_SYNC_CERTIFICATES;
metadata: {
syncId: string;
count: number;
certificateIds: string[];
destination: string;
};
}
interface CreatePkiSyncEvent { interface CreatePkiSyncEvent {
type: EventType.CREATE_PKI_SYNC; type: EventType.CREATE_PKI_SYNC;
metadata: { metadata: {
@@ -4329,6 +4340,7 @@ export type Event =
| SecretSyncRemoveSecretsEvent | SecretSyncRemoveSecretsEvent
| GetPkiSyncsEvent | GetPkiSyncsEvent
| GetPkiSyncEvent | GetPkiSyncEvent
| GetPkiSyncCertificatesEvent
| CreatePkiSyncEvent | CreatePkiSyncEvent
| UpdatePkiSyncEvent | UpdatePkiSyncEvent
| DeletePkiSyncEvent | DeletePkiSyncEvent
+11 -2
View File
@@ -172,6 +172,7 @@ import { internalCertificateAuthorityServiceFactory } from "@app/services/certif
import { certificateEstV3ServiceFactory } from "@app/services/certificate-est-v3/certificate-est-v3-service"; import { certificateEstV3ServiceFactory } from "@app/services/certificate-est-v3/certificate-est-v3-service";
import { certificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; import { certificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal";
import { certificateProfileServiceFactory } from "@app/services/certificate-profile/certificate-profile-service"; import { certificateProfileServiceFactory } from "@app/services/certificate-profile/certificate-profile-service";
import { certificateSyncDALFactory } from "@app/services/certificate-sync/certificate-sync-dal";
import { certificateTemplateDALFactory } from "@app/services/certificate-template/certificate-template-dal"; import { certificateTemplateDALFactory } from "@app/services/certificate-template/certificate-template-dal";
import { certificateTemplateEstConfigDALFactory } from "@app/services/certificate-template/certificate-template-est-config-dal"; import { certificateTemplateEstConfigDALFactory } from "@app/services/certificate-template/certificate-template-est-config-dal";
import { certificateTemplateServiceFactory } from "@app/services/certificate-template/certificate-template-service"; import { certificateTemplateServiceFactory } from "@app/services/certificate-template/certificate-template-service";
@@ -1064,6 +1065,7 @@ export const registerRoutes = async (
const certificateDAL = certificateDALFactory(db); const certificateDAL = certificateDALFactory(db);
const certificateBodyDAL = certificateBodyDALFactory(db); const certificateBodyDAL = certificateBodyDALFactory(db);
const certificateSecretDAL = certificateSecretDALFactory(db); const certificateSecretDAL = certificateSecretDALFactory(db);
const certificateSyncDAL = certificateSyncDALFactory(db);
const pkiAlertDAL = pkiAlertDALFactory(db); const pkiAlertDAL = pkiAlertDALFactory(db);
const pkiCollectionDAL = pkiCollectionDALFactory(db); const pkiCollectionDAL = pkiCollectionDALFactory(db);
@@ -2027,7 +2029,8 @@ export const registerRoutes = async (
certificateBodyDAL, certificateBodyDAL,
certificateSecretDAL, certificateSecretDAL,
certificateAuthorityDAL, certificateAuthorityDAL,
certificateAuthorityCertDAL certificateAuthorityCertDAL,
certificateSyncDAL
}); });
const pkiSyncCleanup = pkiSyncCleanupQueueServiceFactory({ const pkiSyncCleanup = pkiSyncCleanupQueueServiceFactory({
@@ -2138,6 +2141,7 @@ export const registerRoutes = async (
permissionService, permissionService,
pkiCollectionDAL, pkiCollectionDAL,
pkiCollectionItemDAL, pkiCollectionItemDAL,
certificateSyncDAL,
pkiSyncDAL, pkiSyncDAL,
pkiSyncQueue pkiSyncQueue
}); });
@@ -2149,7 +2153,10 @@ export const registerRoutes = async (
certificateProfileDAL, certificateProfileDAL,
certificateTemplateV2Service, certificateTemplateV2Service,
internalCaService: internalCertificateAuthorityService, internalCaService: internalCertificateAuthorityService,
permissionService permissionService,
certificateSyncDAL,
pkiSyncDAL,
pkiSyncQueue
}); });
const certificateV3Queue = certificateV3QueueServiceFactory({ const certificateV3Queue = certificateV3QueueServiceFactory({
@@ -2191,6 +2198,8 @@ export const registerRoutes = async (
const pkiSyncService = pkiSyncServiceFactory({ const pkiSyncService = pkiSyncServiceFactory({
pkiSyncDAL, pkiSyncDAL,
certificateDAL,
certificateSyncDAL,
pkiSubscriberDAL, pkiSubscriberDAL,
appConnectionService, appConnectionService,
permissionService, permissionService,
@@ -121,9 +121,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
limit: z.coerce.number().min(1).max(100).default(20), limit: z.coerce.number().min(1).max(100).default(20),
search: z.string().optional(), search: z.string().optional(),
enrollmentType: z.nativeEnum(EnrollmentType).optional(), enrollmentType: z.nativeEnum(EnrollmentType).optional(),
caId: z.string().uuid().optional(), caId: z.string().uuid().optional()
includeMetrics: z.coerce.boolean().optional().default(false),
expiringDays: z.coerce.number().min(1).max(365).optional().default(7)
}), }),
response: { response: {
200: z.object({ 200: z.object({
@@ -195,10 +193,6 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
params: z.object({ params: z.object({
id: z.string().uuid() id: z.string().uuid()
}), }),
querystring: z.object({
includeMetrics: z.coerce.boolean().optional().default(false),
expiringDays: z.coerce.number().min(1).max(365).optional().default(7)
}),
response: { response: {
200: z.object({ 200: z.object({
certificateProfile: PkiCertificateProfilesSchema.extend({ certificateProfile: PkiCertificateProfilesSchema.extend({
@@ -232,16 +226,6 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
autoRenew: z.boolean(), autoRenew: z.boolean(),
renewBeforeDays: z.number().optional() renewBeforeDays: z.number().optional()
}) })
.optional(),
metrics: z
.object({
profileId: z.string(),
totalCertificates: z.number(),
activeCertificates: z.number(),
expiredCertificates: z.number(),
expiringCertificates: z.number(),
revokedCertificates: z.number()
})
.optional() .optional()
}) })
}) })
@@ -257,20 +241,6 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
profileId: req.params.id profileId: req.params.id
}); });
let result = certificateProfile;
if (req.query.includeMetrics) {
const metrics = await server.services.certificateProfile.getProfileMetrics({
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId,
profileId: req.params.id,
expiringDays: req.query.expiringDays
});
result = { ...certificateProfile, metrics };
}
await server.services.auditLog.createAuditLog({ await server.services.auditLog.createAuditLog({
...req.auditLogInfo, ...req.auditLogInfo,
projectId: certificateProfile.projectId, projectId: certificateProfile.projectId,
@@ -283,7 +253,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
} }
}); });
return { certificateProfile: result }; return { certificateProfile };
} }
}); });
@@ -26,7 +26,7 @@ export const registerSyncPkiEndpoints = ({
syncOptions?: Record<string, unknown>; syncOptions?: Record<string, unknown>;
description?: string; description?: string;
isAutoSyncEnabled?: boolean; isAutoSyncEnabled?: boolean;
subscriberId?: string; subscriberId?: string | null;
}>; }>;
updateSchema: z.ZodType<{ updateSchema: z.ZodType<{
connectionId?: string; connectionId?: string;
@@ -35,7 +35,7 @@ export const registerSyncPkiEndpoints = ({
syncOptions?: Record<string, unknown>; syncOptions?: Record<string, unknown>;
description?: string; description?: string;
isAutoSyncEnabled?: boolean; isAutoSyncEnabled?: boolean;
subscriberId?: string; subscriberId?: string | null;
}>; }>;
responseSchema: z.ZodTypeAny; responseSchema: z.ZodTypeAny;
syncOptions: { syncOptions: {
@@ -2,10 +2,11 @@ import { z } from "zod";
import { EventType } from "@app/ee/services/audit-log/audit-log-types"; import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { ApiDocsTags } from "@app/lib/api-docs"; import { ApiDocsTags } from "@app/lib/api-docs";
import { readLimit } from "@app/server/config/rateLimiter"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { AuthMode } from "@app/services/auth/auth-type"; import { AuthMode } from "@app/services/auth/auth-type";
import { CertificateSyncStatus } from "@app/services/certificate-sync/certificate-sync-enums";
import { PkiSync } from "@app/services/pki-sync/pki-sync-enums"; import { PkiSync } from "@app/services/pki-sync/pki-sync-enums";
const PkiSyncSchema = z.object({ const PkiSyncSchema = z.object({
@@ -60,7 +61,8 @@ const PkiSyncSchema = z.object({
name: z.string() name: z.string()
}) })
.nullable() .nullable()
.optional() .optional(),
hasCertificate: z.boolean().optional()
}); });
const PkiSyncOptionsSchema = z.object({ const PkiSyncOptionsSchema = z.object({
@@ -76,6 +78,27 @@ const PkiSyncOptionsSchema = z.object({
minCertificateNameLength: z.number().optional() minCertificateNameLength: z.number().optional()
}); });
const PkiSyncCertificateSchema = z.object({
id: z.string().uuid(),
pkiSyncId: z.string().uuid(),
certificateId: z.string().uuid(),
syncStatus: z.nativeEnum(CertificateSyncStatus),
lastSyncMessage: z.string().nullable().optional(),
lastSyncedAt: z.date().nullable().optional(),
createdAt: z.date(),
updatedAt: z.date(),
certificateSerialNumber: z.string().optional(),
certificateCommonName: z.string().optional(),
certificateAltNames: z.string().optional(),
certificateStatus: z.string().optional(),
certificateNotBefore: z.date().optional(),
certificateNotAfter: z.date().optional(),
certificateRenewBeforeDays: z.number().nullish(),
certificateRenewalError: z.string().nullish(),
pkiSyncName: z.string().optional(),
pkiSyncDestination: z.string().optional()
});
export const registerPkiSyncRouter = async (server: FastifyZodProvider) => { export const registerPkiSyncRouter = async (server: FastifyZodProvider) => {
server.route({ server.route({
method: "GET", method: "GET",
@@ -111,7 +134,8 @@ export const registerPkiSyncRouter = async (server: FastifyZodProvider) => {
tags: [ApiDocsTags.PkiSyncs], tags: [ApiDocsTags.PkiSyncs],
description: "List all the PKI Syncs for the specified project.", description: "List all the PKI Syncs for the specified project.",
querystring: z.object({ querystring: z.object({
projectId: z.string().trim().min(1) projectId: z.string().trim().min(1),
certificateId: z.string().uuid().optional()
}), }),
response: { response: {
200: z.object({ pkiSyncs: PkiSyncSchema.array() }) 200: z.object({ pkiSyncs: PkiSyncSchema.array() })
@@ -120,11 +144,11 @@ export const registerPkiSyncRouter = async (server: FastifyZodProvider) => {
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => { handler: async (req) => {
const { const {
query: { projectId }, query: { projectId, certificateId },
permission permission
} = req; } = req;
const pkiSyncs = await server.services.pkiSync.listPkiSyncsByProjectId({ projectId }, permission); const pkiSyncs = await server.services.pkiSync.listPkiSyncsByProjectId({ projectId, certificateId }, permission);
await server.services.auditLog.createAuditLog({ await server.services.auditLog.createAuditLog({
...req.auditLogInfo, ...req.auditLogInfo,
@@ -179,4 +203,163 @@ export const registerPkiSyncRouter = async (server: FastifyZodProvider) => {
return pkiSync; return pkiSync;
} }
}); });
server.route({
method: "GET",
url: "/:pkiSyncId/certificates",
config: {
rateLimit: readLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiSyncs],
description: "List all certificates associated with a PKI Sync.",
params: z.object({
pkiSyncId: z.string().uuid()
}),
querystring: z.object({
offset: z.coerce.number().min(0).default(0),
limit: z.coerce.number().min(1).max(100).default(20)
}),
response: {
200: z.object({
certificates: PkiSyncCertificateSchema.array(),
totalCount: z.number()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { pkiSyncId } = req.params;
const { offset, limit } = req.query;
const { certificates, totalCount, pkiSyncInfo } = await server.services.pkiSync.listPkiSyncCertificates(
{ pkiSyncId, offset, limit },
req.permission
);
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: pkiSyncInfo.projectId,
event: {
type: EventType.GET_PKI_SYNC_CERTIFICATES,
metadata: {
syncId: pkiSyncId,
destination: pkiSyncInfo.destination,
count: certificates.length,
certificateIds: certificates.map((c) => c.certificateId)
}
}
});
return { certificates, totalCount };
}
});
server.route({
method: "POST",
url: "/:pkiSyncId/certificates",
config: {
rateLimit: writeLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiSyncs],
description: "Add certificates to a PKI Sync.",
params: z.object({
pkiSyncId: z.string().uuid()
}),
body: z.object({
certificateIds: z.array(z.string().uuid()).min(1, "At least one certificate ID is required")
}),
response: {
200: z.object({
addedCertificates: z.array(
z.object({
id: z.string().uuid(),
pkiSyncId: z.string().uuid(),
certificateId: z.string().uuid(),
syncStatus: z.string().default("pending").optional().nullable(),
lastSyncMessage: z.string().optional().nullable(),
lastSyncedAt: z.date().optional().nullable(),
createdAt: z.date(),
updatedAt: z.date()
})
)
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { pkiSyncId } = req.params;
const { certificateIds } = req.body;
const { addedCertificates, pkiSyncInfo } = await server.services.pkiSync.addCertificatesToPkiSync(
{ pkiSyncId, certificateIds },
req.permission
);
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: pkiSyncInfo.projectId,
event: {
type: EventType.UPDATE_PKI_SYNC,
metadata: {
pkiSyncId,
name: pkiSyncInfo.name
}
}
});
return { addedCertificates };
}
});
server.route({
method: "DELETE",
url: "/:pkiSyncId/certificates",
config: {
rateLimit: writeLimit
},
schema: {
hide: false,
tags: [ApiDocsTags.PkiSyncs],
description: "Remove certificates from a PKI Sync.",
params: z.object({
pkiSyncId: z.string().uuid()
}),
body: z.object({
certificateIds: z.array(z.string().uuid()).min(1, "At least one certificate ID is required")
}),
response: {
200: z.object({
removedCount: z.number()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const { pkiSyncId } = req.params;
const { certificateIds } = req.body;
const { removedCount, pkiSyncInfo } = await server.services.pkiSync.removeCertificatesFromPkiSync(
{ pkiSyncId, certificateIds },
req.permission
);
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: pkiSyncInfo.projectId,
event: {
type: EventType.UPDATE_PKI_SYNC,
metadata: {
pkiSyncId,
name: pkiSyncInfo.name
}
}
});
return { removedCount };
}
});
}; };
@@ -1195,8 +1195,13 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
querystring: z.object({ querystring: z.object({
friendlyName: z.string().optional().describe(PROJECTS.LIST_CERTIFICATES.friendlyName), friendlyName: z.string().optional().describe(PROJECTS.LIST_CERTIFICATES.friendlyName),
commonName: z.string().optional().describe(PROJECTS.LIST_CERTIFICATES.commonName), commonName: z.string().optional().describe(PROJECTS.LIST_CERTIFICATES.commonName),
offset: z.coerce.number().min(0).max(100).default(0).describe(PROJECTS.LIST_CERTIFICATES.offset), offset: z.coerce.number().min(0).default(0).describe(PROJECTS.LIST_CERTIFICATES.offset),
limit: z.coerce.number().min(1).max(100).default(25).describe(PROJECTS.LIST_CERTIFICATES.limit) limit: z.coerce.number().min(1).max(100).default(25).describe(PROJECTS.LIST_CERTIFICATES.limit),
forPkiSync: z.coerce
.boolean()
.default(false)
.optional()
.describe("Retrieve only certificates available for PKI sync")
}), }),
response: { response: {
200: z.object({ 200: z.object({
@@ -192,7 +192,7 @@ export const castDbEntryToAzureAdCsCertificateAuthority = (
ca: Awaited<ReturnType<TCertificateAuthorityDALFactory["findByIdWithAssociatedCa"]>> ca: Awaited<ReturnType<TCertificateAuthorityDALFactory["findByIdWithAssociatedCa"]>>
): TAzureAdCsCertificateAuthority & { credentials: unknown } => { ): TAzureAdCsCertificateAuthority & { credentials: unknown } => {
if (!ca.externalCa?.id) { if (!ca.externalCa?.id) {
throw new BadRequestError({ message: "Malformed Azure AD Certificate Service certificate authority" }); throw new BadRequestError({ message: "Malformed Active Directory Certificate Service certificate authority" });
} }
if (!ca.externalCa.dnsAppConnectionId) { if (!ca.externalCa.dnsAppConnectionId) {
@@ -776,7 +776,7 @@ export const AzureAdCsCertificateAuthorityFns = ({
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(subscriber.caId); const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(subscriber.caId);
if (!ca.externalCa || ca.externalCa.type !== CaType.AZURE_AD_CS) { if (!ca.externalCa || ca.externalCa.type !== CaType.AZURE_AD_CS) {
throw new BadRequestError({ message: "CA is not an Azure AD Certificate Service CA" }); throw new BadRequestError({ message: "CA is not an Active Directory Certificate Service CA" });
} }
const azureCa = castDbEntryToAzureAdCsCertificateAuthority(ca); const azureCa = castDbEntryToAzureAdCsCertificateAuthority(ca);
@@ -2,8 +2,8 @@ import { CaCapability, CaType } from "./certificate-authority-enums";
export const CERTIFICATE_AUTHORITIES_TYPE_MAP: Record<CaType, string> = { export const CERTIFICATE_AUTHORITIES_TYPE_MAP: Record<CaType, string> = {
[CaType.INTERNAL]: "Internal", [CaType.INTERNAL]: "Internal",
[CaType.ACME]: "ACME", [CaType.ACME]: "ACME-compatible CA",
[CaType.AZURE_AD_CS]: "Azure AD Certificate Service" [CaType.AZURE_AD_CS]: "Active Directory Certificate Service"
}; };
export const CERTIFICATE_AUTHORITIES_CAPABILITIES_MAP: Record<CaType, CaCapability[]> = { export const CERTIFICATE_AUTHORITIES_CAPABILITIES_MAP: Record<CaType, CaCapability[]> = {
@@ -10,10 +10,8 @@ import {
TCertificateProfile, TCertificateProfile,
TCertificateProfileCertificate, TCertificateProfileCertificate,
TCertificateProfileInsert, TCertificateProfileInsert,
TCertificateProfileMetrics,
TCertificateProfileUpdate, TCertificateProfileUpdate,
TCertificateProfileWithConfigs, TCertificateProfileWithConfigs
TCertificateProfileWithRawMetrics
} from "./certificate-profile-types"; } from "./certificate-profile-types";
export type TCertificateProfileDALFactory = ReturnType<typeof certificateProfileDALFactory>; export type TCertificateProfileDALFactory = ReturnType<typeof certificateProfileDALFactory>;
@@ -203,21 +201,11 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
search?: string; search?: string;
enrollmentType?: EnrollmentType; enrollmentType?: EnrollmentType;
caId?: string; caId?: string;
includeMetrics?: boolean;
expiringDays?: number;
} = {}, } = {},
tx?: Knex tx?: Knex
): Promise<TCertificateProfile[] | TCertificateProfileWithRawMetrics[] | TCertificateProfileWithConfigs[]> => { ): Promise<TCertificateProfile[] | TCertificateProfileWithConfigs[]> => {
try { try {
const { const { offset = 0, limit = 20, search, enrollmentType, caId } = options;
offset = 0,
limit = 20,
search,
enrollmentType,
caId,
includeMetrics = false,
expiringDays = 7
} = options;
let baseQuery = (tx || db)(TableName.PkiCertificateProfile).where( let baseQuery = (tx || db)(TableName.PkiCertificateProfile).where(
`${TableName.PkiCertificateProfile}.projectId`, `${TableName.PkiCertificateProfile}.projectId`,
@@ -242,7 +230,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
baseQuery = baseQuery.where(`${TableName.PkiCertificateProfile}.caId`, caId); baseQuery = baseQuery.where(`${TableName.PkiCertificateProfile}.caId`, caId);
} }
let query = baseQuery const query = baseQuery
.leftJoin( .leftJoin(
TableName.PkiEstEnrollmentConfig, TableName.PkiEstEnrollmentConfig,
`${TableName.PkiCertificateProfile}.estConfigId`, `${TableName.PkiCertificateProfile}.estConfigId`,
@@ -267,52 +255,6 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiRenewBeforeDays") db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiRenewBeforeDays")
); );
if (includeMetrics) {
query = query.leftJoin(
TableName.Certificate,
`${TableName.PkiCertificateProfile}.id`,
`${TableName.Certificate}.profileId`
);
const now = new Date();
const expiringDate = new Date();
expiringDate.setDate(now.getDate() + expiringDays);
query = query
.select(
selectAllTableCols(TableName.PkiCertificateProfile),
db.ref("id").withSchema(TableName.PkiEstEnrollmentConfig).as("estId"),
db
.ref("disableBootstrapCaValidation")
.withSchema(TableName.PkiEstEnrollmentConfig)
.as("estDisableBootstrapCaValidation"),
db.ref("hashedPassphrase").withSchema(TableName.PkiEstEnrollmentConfig).as("estHashedPassphrase"),
db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estEncryptedCaChain"),
db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiId"),
db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenew"),
db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiRenewBeforeDays"),
db.raw("COUNT(certificates.id) as total_certificates"),
db.raw(
'COUNT(CASE WHEN certificates."revokedAt" IS NULL AND certificates."notAfter" > ? THEN 1 END) as active_certificates',
[expiringDate]
),
db.raw(
'COUNT(CASE WHEN certificates."revokedAt" IS NULL AND certificates."notAfter" <= ? THEN 1 END) as expired_certificates',
[now]
),
db.raw(
'COUNT(CASE WHEN certificates."revokedAt" IS NULL AND certificates."notAfter" > ? AND certificates."notAfter" <= ? THEN 1 END) as expiring_certificates',
[now, expiringDate]
),
db.raw('COUNT(CASE WHEN certificates."revokedAt" IS NOT NULL THEN 1 END) as revoked_certificates')
)
.groupBy(
`${TableName.PkiCertificateProfile}.id`,
`${TableName.PkiEstEnrollmentConfig}.id`,
`${TableName.PkiApiEnrollmentConfig}.id`
);
}
const results = (await query const results = (await query
.orderBy(`${TableName.PkiCertificateProfile}.createdAt`, "desc") .orderBy(`${TableName.PkiCertificateProfile}.createdAt`, "desc")
.offset(offset) .offset(offset)
@@ -353,17 +295,6 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
apiConfig apiConfig
}; };
if (includeMetrics) {
return {
...baseProfile,
total_certificates: result.total_certificates,
active_certificates: result.active_certificates,
expired_certificates: result.expired_certificates,
expiring_certificates: result.expiring_certificates,
revoked_certificates: result.revoked_certificates
} as TCertificateProfileWithRawMetrics & TCertificateProfileWithConfigs;
}
return baseProfile as TCertificateProfileWithConfigs; return baseProfile as TCertificateProfileWithConfigs;
}); });
} catch (error) { } catch (error) {
@@ -485,45 +416,6 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
} }
}; };
const getProfileMetrics = async (
profileId: string,
expiringDays: number = 7,
tx?: Knex
): Promise<TCertificateProfileMetrics> => {
try {
const now = new Date();
const expiringDate = new Date();
expiringDate.setDate(now.getDate() + expiringDays);
const metrics = await (tx || db)(TableName.Certificate)
.where("profileId", profileId)
.select(
db.raw("COUNT(*) as total_certificates"),
db.raw('COUNT(CASE WHEN "revokedAt" IS NULL AND "notAfter" > ? THEN 1 END) as active_certificates', [
expiringDate
]),
db.raw('COUNT(CASE WHEN "revokedAt" IS NULL AND "notAfter" <= ? THEN 1 END) as expired_certificates', [now]),
db.raw(
'COUNT(CASE WHEN "revokedAt" IS NULL AND "notAfter" > ? AND "notAfter" <= ? THEN 1 END) as expiring_certificates',
[now, expiringDate]
),
db.raw('COUNT(CASE WHEN "revokedAt" IS NOT NULL THEN 1 END) as revoked_certificates')
)
.first();
return {
profileId,
totalCertificates: parseInt(String((metrics as Record<string, unknown>)?.total_certificates || 0), 10),
activeCertificates: parseInt(String((metrics as Record<string, unknown>)?.active_certificates || 0), 10),
expiredCertificates: parseInt(String((metrics as Record<string, unknown>)?.expired_certificates || 0), 10),
expiringCertificates: parseInt(String((metrics as Record<string, unknown>)?.expiring_certificates || 0), 10),
revokedCertificates: parseInt(String((metrics as Record<string, unknown>)?.revoked_certificates || 0), 10)
};
} catch (error) {
throw new DatabaseError({ error, name: "Get certificate profile metrics" });
}
};
const isProfileInUse = async (profileId: string, tx?: Knex) => { const isProfileInUse = async (profileId: string, tx?: Knex) => {
try { try {
const doc = await (tx || db)(TableName.Certificate).where("profileId", profileId).count("*").first(); const doc = await (tx || db)(TableName.Certificate).where("profileId", profileId).count("*").first();
@@ -546,7 +438,6 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
countByProjectId, countByProjectId,
findByNameAndProjectId, findByNameAndProjectId,
getCertificatesByProfile, getCertificatesByProfile,
getProfileMetrics,
isProfileInUse isProfileInUse
}; };
}; };
@@ -127,8 +127,3 @@ export const listCertificatesByProfileSchema = z.object({
status: z.enum(["active", "expired", "revoked"]).optional(), status: z.enum(["active", "expired", "revoked"]).optional(),
search: z.string().optional() search: z.string().optional()
}); });
export const getCertificateProfileMetricsSchema = z.object({
profileId: z.string().uuid(),
expiringDays: z.coerce.number().min(1).max(365).default(30)
});
@@ -47,7 +47,6 @@ describe("CertificateProfileService", () => {
findByNameAndProjectId: vi.fn(), findByNameAndProjectId: vi.fn(),
findByIdWithConfigs: vi.fn(), findByIdWithConfigs: vi.fn(),
getCertificatesByProfile: vi.fn(), getCertificatesByProfile: vi.fn(),
getProfileMetrics: vi.fn(),
isProfileInUse: vi.fn(), isProfileInUse: vi.fn(),
transaction: vi.fn(), transaction: vi.fn(),
find: vi.fn(), find: vi.fn(),
@@ -493,9 +492,7 @@ describe("CertificateProfileService", () => {
limit: 20, limit: 20,
search: undefined, search: undefined,
enrollmentType: undefined, enrollmentType: undefined,
caId: undefined, caId: undefined
includeMetrics: false,
expiringDays: 30
}); });
}); });
@@ -515,51 +512,7 @@ describe("CertificateProfileService", () => {
limit: 5, limit: 5,
search: "test", search: "test",
enrollmentType: EnrollmentType.API, enrollmentType: EnrollmentType.API,
caId: "ca-123", caId: "ca-123"
includeMetrics: false,
expiringDays: 30
});
});
it("should list profiles with metrics when includeMetrics is true", async () => {
const mockProfilesWithMetrics = [
{
...sampleProfile,
total_certificates: 10,
active_certificates: 8,
expired_certificates: 1,
expiring_certificates: 1,
revoked_certificates: 0
}
];
(mockCertificateProfileDAL.findByProjectId as any).mockResolvedValue(mockProfilesWithMetrics);
const result = await service.listProfiles({
...mockActor,
projectId: "project-123",
includeMetrics: true,
expiringDays: 15
});
expect(result.profiles).toHaveLength(1);
expect(result.profiles[0]).toHaveProperty("metrics");
expect(result.profiles[0].metrics).toEqual({
profileId: sampleProfile.id,
totalCertificates: 10,
activeCertificates: 8,
expiredCertificates: 1,
expiringCertificates: 1,
revokedCertificates: 0
});
expect(mockCertificateProfileDAL.findByProjectId).toHaveBeenCalledWith("project-123", {
offset: 0,
limit: 20,
search: undefined,
enrollmentType: undefined,
caId: undefined,
includeMetrics: true,
expiringDays: 15
}); });
}); });
}); });
@@ -659,54 +612,6 @@ describe("CertificateProfileService", () => {
}); });
}); });
describe("getProfileMetrics", () => {
const mockMetrics = {
profileId: "profile-123",
totalCertificates: 10,
activeCertificates: 8,
expiredCertificates: 1,
expiringCertificates: 2,
revokedCertificates: 1
};
beforeEach(() => {
(mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile);
(mockCertificateProfileDAL.getProfileMetrics as any).mockResolvedValue(mockMetrics);
});
it("should get profile metrics successfully", async () => {
const result = await service.getProfileMetrics({
...mockActor,
profileId: "profile-123"
});
expect(result).toEqual(mockMetrics);
expect(mockCertificateProfileDAL.findById).toHaveBeenCalledWith("profile-123");
expect(mockCertificateProfileDAL.getProfileMetrics).toHaveBeenCalledWith("profile-123", 30);
});
it("should get profile metrics with custom expiring days", async () => {
await service.getProfileMetrics({
...mockActor,
profileId: "profile-123",
expiringDays: 60
});
expect(mockCertificateProfileDAL.getProfileMetrics).toHaveBeenCalledWith("profile-123", 60);
});
it("should throw NotFoundError when profile not found", async () => {
(mockCertificateProfileDAL.findById as any).mockResolvedValue(null);
await expect(
service.getProfileMetrics({
...mockActor,
profileId: "profile-123"
})
).rejects.toThrow(NotFoundError);
});
});
describe("comprehensive certificate profile scenarios", () => { describe("comprehensive certificate profile scenarios", () => {
describe("profile configuration validation", () => { describe("profile configuration validation", () => {
it("should validate EST enrollment configuration", async () => { it("should validate EST enrollment configuration", async () => {
@@ -929,53 +834,6 @@ describe("CertificateProfileService", () => {
}); });
}); });
describe("metrics and monitoring", () => {
it("should calculate profile metrics correctly", async () => {
const detailedMetrics = {
profileId: "profile-123",
totalCertificates: 50,
activeCertificates: 40,
expiredCertificates: 5,
expiringCertificates: 3,
revokedCertificates: 2
};
(mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile);
(mockCertificateProfileDAL.getProfileMetrics as any).mockResolvedValue(detailedMetrics);
const result = await service.getProfileMetrics({
...mockActor,
profileId: "profile-123",
expiringDays: 14
});
expect(result).toEqual(detailedMetrics);
expect(mockCertificateProfileDAL.getProfileMetrics).toHaveBeenCalledWith("profile-123", 14);
});
it("should handle zero certificate metrics", async () => {
const emptyMetrics = {
profileId: "profile-123",
totalCertificates: 0,
activeCertificates: 0,
expiredCertificates: 0,
expiringCertificates: 0,
revokedCertificates: 0
};
(mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile);
(mockCertificateProfileDAL.getProfileMetrics as any).mockResolvedValue(emptyMetrics);
const result = await service.getProfileMetrics({
...mockActor,
profileId: "profile-123"
});
expect(result.totalCertificates).toBe(0);
expect(result.activeCertificates).toBe(0);
});
});
describe("error scenarios", () => { describe("error scenarios", () => {
it("should handle database connection errors gracefully", async () => { it("should handle database connection errors gracefully", async () => {
(mockCertificateProfileDAL.findById as any).mockRejectedValue(new Error("Database connection failed")); (mockCertificateProfileDAL.findById as any).mockRejectedValue(new Error("Database connection failed"));
@@ -27,10 +27,8 @@ import {
TCertificateProfile, TCertificateProfile,
TCertificateProfileCertificate, TCertificateProfileCertificate,
TCertificateProfileInsert, TCertificateProfileInsert,
TCertificateProfileMetrics,
TCertificateProfileUpdate, TCertificateProfileUpdate,
TCertificateProfileWithConfigs, TCertificateProfileWithConfigs
TCertificateProfileWithRawMetrics
} from "./certificate-profile-types"; } from "./certificate-profile-types";
const validateAndEncryptPemCaChain = async ( const validateAndEncryptPemCaChain = async (
@@ -361,18 +359,14 @@ export const certificateProfileServiceFactory = ({
actorId, actorId,
actorAuthMethod, actorAuthMethod,
actorOrgId, actorOrgId,
profileId, profileId
includeMetrics = false,
expiringDays = 30
}: { }: {
actor: ActorType; actor: ActorType;
actorId: string; actorId: string;
actorAuthMethod: ActorAuthMethod; actorAuthMethod: ActorAuthMethod;
actorOrgId: string; actorOrgId: string;
profileId: string; profileId: string;
includeMetrics?: boolean; }): Promise<TCertificateProfile> => {
expiringDays?: number;
}): Promise<TCertificateProfile & { metrics?: TCertificateProfileMetrics }> => {
const profile = await certificateProfileDAL.findById(profileId); const profile = await certificateProfileDAL.findById(profileId);
if (!profile) { if (!profile) {
throw new NotFoundError({ message: "Certificate profile not found" }); throw new NotFoundError({ message: "Certificate profile not found" });
@@ -393,14 +387,6 @@ export const certificateProfileServiceFactory = ({
const converted = convertDalToService(profile); const converted = convertDalToService(profile);
if (includeMetrics) {
const metrics = await certificateProfileDAL.getProfileMetrics(profileId, expiringDays);
return {
...converted,
metrics
};
}
return converted; return converted;
}; };
@@ -506,9 +492,7 @@ export const certificateProfileServiceFactory = ({
limit = 20, limit = 20,
search, search,
enrollmentType, enrollmentType,
caId, caId
includeMetrics = false,
expiringDays = 30
}: { }: {
actor: ActorType; actor: ActorType;
actorId: string; actorId: string;
@@ -520,10 +504,8 @@ export const certificateProfileServiceFactory = ({
search?: string; search?: string;
enrollmentType?: EnrollmentType; enrollmentType?: EnrollmentType;
caId?: string; caId?: string;
includeMetrics?: boolean;
expiringDays?: number;
}): Promise<{ }): Promise<{
profiles: (TCertificateProfileWithConfigs & { metrics?: TCertificateProfileMetrics })[]; profiles: TCertificateProfileWithConfigs[];
totalCount: number; totalCount: number;
}> => { }> => {
const { permission } = await permissionService.getProjectPermission({ const { permission } = await permissionService.getProjectPermission({
@@ -544,9 +526,7 @@ export const certificateProfileServiceFactory = ({
limit, limit,
search, search,
enrollmentType, enrollmentType,
caId, caId
includeMetrics,
expiringDays
}); });
const totalCount = await certificateProfileDAL.countByProjectId(projectId, { const totalCount = await certificateProfileDAL.countByProjectId(projectId, {
@@ -591,27 +571,12 @@ export const certificateProfileServiceFactory = ({
} }
const converted = convertDalToService(profileWithConfigs); const converted = convertDalToService(profileWithConfigs);
let result: TCertificateProfileWithConfigs & { metrics?: TCertificateProfileMetrics } = { const result: TCertificateProfileWithConfigs = {
...converted, ...converted,
estConfig: decryptedEstConfig, estConfig: decryptedEstConfig,
apiConfig: profileWithConfigs.apiConfig apiConfig: profileWithConfigs.apiConfig
}; };
if (includeMetrics) {
const profileWithMetrics = profile as TCertificateProfileWithRawMetrics;
result = {
...result,
metrics: {
profileId: converted.id,
totalCertificates: parseInt(String(profileWithMetrics.total_certificates || 0), 10),
activeCertificates: parseInt(String(profileWithMetrics.active_certificates || 0), 10),
expiredCertificates: parseInt(String(profileWithMetrics.expired_certificates || 0), 10),
expiringCertificates: parseInt(String(profileWithMetrics.expiring_certificates || 0), 10),
revokedCertificates: parseInt(String(profileWithMetrics.revoked_certificates || 0), 10)
}
};
}
return result; return result;
}) })
); );
@@ -709,43 +674,6 @@ export const certificateProfileServiceFactory = ({
return certificates; return certificates;
}; };
const getProfileMetrics = async ({
actor,
actorId,
actorAuthMethod,
actorOrgId,
profileId,
expiringDays = 30
}: {
actor: ActorType;
actorId: string;
actorAuthMethod: ActorAuthMethod;
actorOrgId: string;
profileId: string;
expiringDays?: number;
}): Promise<TCertificateProfileMetrics> => {
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
);
const metrics = await certificateProfileDAL.getProfileMetrics(profileId, expiringDays);
return metrics;
};
const getEstConfigurationByProfile = async ( const getEstConfigurationByProfile = async (
params: params:
| { | {
@@ -818,7 +746,6 @@ export const certificateProfileServiceFactory = ({
listProfiles, listProfiles,
deleteProfile, deleteProfile,
getProfileCertificates, getProfileCertificates,
getProfileMetrics,
getEstConfigurationByProfile getEstConfigurationByProfile
}; };
}; };
@@ -54,18 +54,8 @@ export type TCertificateProfileWithConfigs = TCertificateProfile & {
autoRenew: boolean; autoRenew: boolean;
renewBeforeDays?: number; renewBeforeDays?: number;
}; };
metrics?: TCertificateProfileMetrics;
}; };
export interface TCertificateProfileMetrics {
profileId: string;
totalCertificates: number;
activeCertificates: number;
expiredCertificates: number;
expiringCertificates: number;
revokedCertificates: number;
}
export interface TCertificateProfileCertificate { export interface TCertificateProfileCertificate {
id: string; id: string;
serialNumber: string; serialNumber: string;
@@ -76,11 +66,3 @@ export interface TCertificateProfileCertificate {
revokedAt: Date | null; revokedAt: Date | null;
createdAt: Date; createdAt: Date;
} }
export type TCertificateProfileWithRawMetrics = TCertificateProfile & {
total_certificates?: string;
active_certificates?: string;
expired_certificates?: string;
expiring_certificates?: string;
revoked_certificates?: string;
};
@@ -0,0 +1,272 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName, TCertificateSyncs } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { buildFindFilter, ormify, selectAllTableCols } from "@app/lib/knex";
import { CertificateSyncStatus } from "./certificate-sync-enums";
export type TCertificateSyncDALFactory = ReturnType<typeof certificateSyncDALFactory>;
type CertificateSyncFindFilter = Parameters<typeof buildFindFilter<TCertificateSyncs>>[0];
export const certificateSyncDALFactory = (db: TDbClient) => {
const certificateSyncOrm = ormify(db, TableName.CertificateSync);
const findByPkiSyncId = async (pkiSyncId: string, tx?: Knex) => {
try {
const docs = await (tx || db.replicaNode())(TableName.CertificateSync)
.where({ pkiSyncId })
.select(selectAllTableCols(TableName.CertificateSync));
return docs;
} catch (error) {
throw new DatabaseError({ error, name: "FindByPkiSyncId" });
}
};
const findByCertificateId = async (certificateId: string, tx?: Knex) => {
try {
const docs = await (tx || db.replicaNode())(TableName.CertificateSync)
.where({ certificateId })
.select(selectAllTableCols(TableName.CertificateSync));
return docs;
} catch (error) {
throw new DatabaseError({ error, name: "FindByCertificateId" });
}
};
const findByPkiSyncAndCertificate = async (pkiSyncId: string, certificateId: string, tx?: Knex) => {
try {
const doc = await (tx || db.replicaNode())(TableName.CertificateSync)
.where({ pkiSyncId, certificateId })
.select(selectAllTableCols(TableName.CertificateSync))
.first();
return doc;
} catch (error) {
throw new DatabaseError({ error, name: "FindByPkiSyncAndCertificate" });
}
};
const findCertificateIdsByPkiSyncId = async (pkiSyncId: string, tx?: Knex): Promise<string[]> => {
try {
const docs = (await (tx || db.replicaNode())(TableName.CertificateSync)
.where({ pkiSyncId })
.select("certificateId")) as Array<{ certificateId: string }>;
return docs.map((doc) => doc.certificateId);
} catch (error) {
throw new DatabaseError({ error, name: "FindCertificateIdsByPkiSyncId" });
}
};
const findPkiSyncIdsByCertificateId = async (certificateId: string, tx?: Knex): Promise<string[]> => {
try {
const docs = (await (tx || db.replicaNode())(TableName.CertificateSync)
.where({ certificateId })
.select("pkiSyncId")) as Array<{ pkiSyncId: string }>;
return docs.map((doc) => doc.pkiSyncId);
} catch (error) {
throw new DatabaseError({ error, name: "FindPkiSyncIdsByCertificateId" });
}
};
const addCertificates = async (
pkiSyncId: string,
certificateData: Array<{ certificateId: string; externalIdentifier?: string }>,
tx?: Knex
): Promise<TCertificateSyncs[]> => {
try {
const insertData = certificateData.map(({ certificateId, externalIdentifier }) => ({
pkiSyncId,
certificateId,
syncStatus: CertificateSyncStatus.Pending,
externalIdentifier
}));
const docs = await (tx || db)(TableName.CertificateSync).insert(insertData).returning("*");
return docs;
} catch (error) {
throw new DatabaseError({ error, name: "AddCertificates" });
}
};
const removeCertificates = async (pkiSyncId: string, certificateIds: string[], tx?: Knex): Promise<number> => {
try {
const deletedCount = await (tx || db)(TableName.CertificateSync)
.where({ pkiSyncId })
.whereIn("certificateId", certificateIds)
.del();
return deletedCount;
} catch (error) {
throw new DatabaseError({ error, name: "RemoveCertificates" });
}
};
const removeAllCertificatesFromSync = async (pkiSyncId: string, tx?: Knex): Promise<number> => {
try {
const deletedCount = await (tx || db)(TableName.CertificateSync).where({ pkiSyncId }).del();
return deletedCount;
} catch (error) {
throw new DatabaseError({ error, name: "RemoveAllCertificatesFromSync" });
}
};
const updateSyncStatus = async (
pkiSyncId: string,
certificateId: string,
status: string,
message?: string,
tx?: Knex
): Promise<TCertificateSyncs | undefined> => {
try {
const updateData: Partial<TCertificateSyncs> = {
syncStatus: status,
lastSyncedAt: new Date()
};
if (message !== undefined) {
updateData.lastSyncMessage = message;
}
const docs = await (tx || db)(TableName.CertificateSync)
.where({ pkiSyncId, certificateId })
.update(updateData)
.returning("*");
return docs[0];
} catch (error) {
throw new DatabaseError({ error, name: "UpdateSyncStatus" });
}
};
const bulkUpdateSyncStatus = async (
updates: Array<{
pkiSyncId: string;
certificateId: string;
status: string;
message?: string;
}>,
tx?: Knex
): Promise<void> => {
try {
if (tx) {
for (const update of updates) {
// eslint-disable-next-line no-await-in-loop
await updateSyncStatus(update.pkiSyncId, update.certificateId, update.status, update.message, tx);
}
} else {
await certificateSyncOrm.transaction(async (trx) => {
for (const update of updates) {
// eslint-disable-next-line no-await-in-loop
await updateSyncStatus(update.pkiSyncId, update.certificateId, update.status, update.message, trx);
}
});
}
} catch (error) {
throw new DatabaseError({ error, name: "BulkUpdateSyncStatus" });
}
};
const findWithDetails = async (
options: {
filter?: CertificateSyncFindFilter;
pkiSyncId?: string;
offset?: number;
limit?: number;
},
tx?: Knex
): Promise<{
certificateDetails: (TCertificateSyncs & {
certificateSerialNumber?: string;
certificateCommonName?: string;
certificateAltNames?: string;
certificateStatus?: string;
certificateNotBefore?: Date;
certificateNotAfter?: Date;
certificateRenewBeforeDays?: number | null;
certificateRenewedByCertificateId?: string;
certificateRenewalError?: string;
pkiSyncName?: string;
pkiSyncDestination?: string;
})[];
totalCount: number;
}> => {
try {
const { filter, pkiSyncId, offset, limit } = options;
const baseQuery = (tx || db.replicaNode())(TableName.CertificateSync)
.leftJoin(TableName.Certificate, `${TableName.CertificateSync}.certificateId`, `${TableName.Certificate}.id`)
.leftJoin(TableName.PkiSync, `${TableName.CertificateSync}.pkiSyncId`, `${TableName.PkiSync}.id`);
if (filter) {
// eslint-disable-next-line @typescript-eslint/no-misused-promises
void baseQuery.where(buildFindFilter(filter));
}
if (pkiSyncId) {
void baseQuery.where(`${TableName.CertificateSync}.pkiSyncId`, pkiSyncId);
}
const countResult = await baseQuery.clone().count("* as count");
const totalCount = Number((countResult[0] as unknown as { count: string | number }).count);
const query = baseQuery
.select(selectAllTableCols(TableName.CertificateSync))
.select(
db.ref("serialNumber").withSchema(TableName.Certificate).as("certificateSerialNumber"),
db.ref("commonName").withSchema(TableName.Certificate).as("certificateCommonName"),
db.ref("altNames").withSchema(TableName.Certificate).as("certificateAltNames"),
db.ref("status").withSchema(TableName.Certificate).as("certificateStatus"),
db.ref("notBefore").withSchema(TableName.Certificate).as("certificateNotBefore"),
db.ref("notAfter").withSchema(TableName.Certificate).as("certificateNotAfter"),
db.ref("renewBeforeDays").withSchema(TableName.Certificate).as("certificateRenewBeforeDays"),
db.ref("renewedByCertificateId").withSchema(TableName.Certificate).as("certificateRenewedByCertificateId"),
db.ref("renewalError").withSchema(TableName.Certificate).as("certificateRenewalError"),
db.ref("name").withSchema(TableName.PkiSync).as("pkiSyncName"),
db.ref("destination").withSchema(TableName.PkiSync).as("pkiSyncDestination")
)
.orderBy(`${TableName.CertificateSync}.createdAt`, "desc");
if (offset !== undefined) {
void query.offset(offset);
}
if (limit !== undefined) {
void query.limit(limit);
}
const certificateDetails = (await query) as (TCertificateSyncs & {
certificateSerialNumber?: string;
certificateCommonName?: string;
certificateAltNames?: string;
certificateStatus?: string;
certificateNotBefore?: Date;
certificateNotAfter?: Date;
certificateRenewBeforeDays?: number;
certificateRenewedByCertificateId?: string;
certificateRenewalError?: string;
pkiSyncName?: string;
pkiSyncDestination?: string;
})[];
return { certificateDetails, totalCount };
} catch (error) {
throw new DatabaseError({ error, name: "FindWithDetails" });
}
};
return {
...certificateSyncOrm,
findByPkiSyncId,
findByCertificateId,
findByPkiSyncAndCertificate,
findCertificateIdsByPkiSyncId,
findPkiSyncIdsByCertificateId,
addCertificates,
removeCertificates,
removeAllCertificatesFromSync,
updateSyncStatus,
bulkUpdateSyncStatus,
findWithDetails
};
};
@@ -0,0 +1,7 @@
export enum CertificateSyncStatus {
Pending = "pending",
Syncing = "syncing",
Succeeded = "succeeded",
Failed = "failed",
Running = "running"
}
@@ -133,7 +133,18 @@ describe("CertificateV3Service", () => {
certificateProfileDAL: mockCertificateProfileDAL, certificateProfileDAL: mockCertificateProfileDAL,
certificateTemplateV2Service: mockCertificateTemplateV2Service, certificateTemplateV2Service: mockCertificateTemplateV2Service,
internalCaService: mockInternalCaService, internalCaService: mockInternalCaService,
permissionService: mockPermissionService permissionService: mockPermissionService,
certificateSyncDAL: {
findPkiSyncIdsByCertificateId: vi.fn().mockResolvedValue([]),
addCertificates: vi.fn().mockResolvedValue([]),
findByPkiSyncAndCertificate: vi.fn().mockResolvedValue(null)
},
pkiSyncDAL: {
find: vi.fn().mockResolvedValue([])
},
pkiSyncQueue: {
queuePkiSyncSyncCertificatesById: vi.fn().mockResolvedValue(undefined)
}
}); });
}); });
@@ -48,6 +48,10 @@ import {
mapEnumsForValidation, mapEnumsForValidation,
normalizeDateForApi normalizeDateForApi
} from "../certificate-common/certificate-utils"; } from "../certificate-common/certificate-utils";
import { TCertificateSyncDALFactory } from "../certificate-sync/certificate-sync-dal";
import { TPkiSyncDALFactory } from "../pki-sync/pki-sync-dal";
import { TPkiSyncQueueFactory } from "../pki-sync/pki-sync-queue";
import { addRenewedCertificateToSyncs, triggerAutoSyncForCertificate } from "../pki-sync/pki-sync-utils";
import { import {
TCertificateFromProfileResponse, TCertificateFromProfileResponse,
TCertificateOrderResponse, TCertificateOrderResponse,
@@ -72,6 +76,12 @@ type TCertificateV3ServiceFactoryDep = {
>; >;
internalCaService: Pick<TInternalCertificateAuthorityServiceFactory, "signCertFromCa" | "issueCertFromCa">; internalCaService: Pick<TInternalCertificateAuthorityServiceFactory, "signCertFromCa" | "issueCertFromCa">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">; permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
certificateSyncDAL: Pick<
TCertificateSyncDALFactory,
"findPkiSyncIdsByCertificateId" | "addCertificates" | "findByPkiSyncAndCertificate"
>;
pkiSyncDAL: Pick<TPkiSyncDALFactory, "find">;
pkiSyncQueue: Pick<TPkiSyncQueueFactory, "queuePkiSyncSyncCertificatesById">;
}; };
export type TCertificateV3ServiceFactory = ReturnType<typeof certificateV3ServiceFactory>; export type TCertificateV3ServiceFactory = ReturnType<typeof certificateV3ServiceFactory>;
@@ -328,7 +338,10 @@ export const certificateV3ServiceFactory = ({
certificateProfileDAL, certificateProfileDAL,
certificateTemplateV2Service, certificateTemplateV2Service,
internalCaService, internalCaService,
permissionService permissionService,
certificateSyncDAL,
pkiSyncDAL,
pkiSyncQueue
}: TCertificateV3ServiceFactoryDep) => { }: TCertificateV3ServiceFactoryDep) => {
const issueCertificateFromProfile = async ({ const issueCertificateFromProfile = async ({
profileId, profileId,
@@ -872,6 +885,8 @@ export const certificateV3ServiceFactory = ({
tx tx
); );
await addRenewedCertificateToSyncs(originalCert.id, newCert.id, { certificateSyncDAL }, tx);
return { return {
certificate, certificate,
certificateChain, certificateChain,
@@ -883,6 +898,12 @@ export const certificateV3ServiceFactory = ({
}; };
}); });
await triggerAutoSyncForCertificate(renewalResult.newCert.id, {
certificateSyncDAL,
pkiSyncDAL,
pkiSyncQueue
});
return { return {
certificate: renewalResult.certificate, certificate: renewalResult.certificate,
issuingCaCertificate: renewalResult.issuingCaCertificate, issuingCaCertificate: renewalResult.issuingCaCertificate,
@@ -1,3 +1,5 @@
import RE2 from "re2";
import { TDbClient } from "@app/db"; import { TDbClient } from "@app/db";
import { TableName, TCertificates } from "@app/db/schemas"; import { TableName, TCertificates } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors"; import { DatabaseError } from "@app/lib/errors";
@@ -60,11 +62,13 @@ export const certificateDALFactory = (db: TDbClient) => {
.where(`${TableName.Project}.id`, projectId); .where(`${TableName.Project}.id`, projectId);
if (friendlyName) { if (friendlyName) {
query = query.andWhere(`${TableName.Certificate}.friendlyName`, friendlyName); const sanitizedValue = String(friendlyName).replace(new RE2("[%_\\\\]", "g"), "\\$&");
query = query.andWhere(`${TableName.Certificate}.friendlyName`, "like", `%${sanitizedValue}%`);
} }
if (commonName) { if (commonName) {
query = query.andWhere(`${TableName.Certificate}.commonName`, commonName); const sanitizedValue = String(commonName).replace(new RE2("[%_\\\\]", "g"), "\\$&");
query = query.andWhere(`${TableName.Certificate}.commonName`, "like", `%${sanitizedValue}%`);
} }
const count = await query.count("*").first(); const count = await query.count("*").first();
@@ -114,6 +118,109 @@ export const certificateDALFactory = (db: TDbClient) => {
} }
}; };
const findActiveCertificatesByIds = async (certificateIds: string[]): Promise<TCertificates[]> => {
try {
if (certificateIds.length === 0) {
return [];
}
const certs = await db
.replicaNode()(TableName.Certificate)
.whereIn("id", certificateIds)
.where({ status: CertStatus.ACTIVE })
.where("notAfter", ">", new Date())
.orderBy("notBefore", "desc")
.select("*");
return certs;
} catch (error) {
throw new DatabaseError({ error, name: "Find active certificates by IDs" });
}
};
const findActiveCertificatesForSync = async (
filter: Partial<TCertificates & { friendlyName?: string; commonName?: string }>,
options?: { limit?: number; offset?: number }
): Promise<(TCertificates & { hasPrivateKey: boolean })[]> => {
try {
let query = db
.replicaNode()(TableName.Certificate)
.leftJoin(TableName.CertificateSecret, `${TableName.Certificate}.id`, `${TableName.CertificateSecret}.certId`)
.select(selectAllTableCols(TableName.Certificate))
.select(db.ref(`${TableName.CertificateSecret}.certId`).as("privateKeyRef"))
.where({ status: CertStatus.ACTIVE })
.where("notAfter", ">", new Date())
.whereNull("renewedByCertificateId");
Object.entries(filter).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
if (key === "friendlyName" || key === "commonName") {
const sanitizedValue = String(value).replace(new RE2("[%_\\\\]", "g"), "\\$&");
query = query.andWhere(`${TableName.Certificate}.${key}`, "like", `%${sanitizedValue}%`);
} else {
query = query.andWhere(`${TableName.Certificate}.${key}`, value);
}
}
});
if (options?.offset) {
query = query.offset(options.offset);
}
if (options?.limit) {
query = query.limit(options.limit);
}
query = query.orderBy("createdAt", "desc");
const certs = await query;
return certs.map((cert) => ({ ...cert, hasPrivateKey: Boolean(cert.privateKeyRef) }));
} catch (error) {
throw new DatabaseError({ error, name: "Find active certificates for sync" });
}
};
const countActiveCertificatesForSync = async ({
projectId,
friendlyName,
commonName
}: {
projectId: string;
friendlyName?: string;
commonName?: string;
}) => {
try {
interface CountResult {
count: string;
}
let query = db
.replicaNode()(TableName.Certificate)
.join(TableName.CertificateAuthority, `${TableName.Certificate}.caId`, `${TableName.CertificateAuthority}.id`)
.join(TableName.Project, `${TableName.CertificateAuthority}.projectId`, `${TableName.Project}.id`)
.where(`${TableName.Project}.id`, projectId)
.where(`${TableName.Certificate}.status`, CertStatus.ACTIVE)
.where(`${TableName.Certificate}.notAfter`, ">", new Date())
.whereNull(`${TableName.Certificate}.renewedByCertificateId`);
if (friendlyName) {
const sanitizedValue = String(friendlyName).replace(new RE2("[%_\\\\]", "g"), "\\$&");
query = query.andWhere(`${TableName.Certificate}.friendlyName`, "like", `%${sanitizedValue}%`);
}
if (commonName) {
const sanitizedValue = String(commonName).replace(new RE2("[%_\\\\]", "g"), "\\$&");
query = query.andWhere(`${TableName.Certificate}.commonName`, "like", `%${sanitizedValue}%`);
}
const count = await query.count("*").first();
return parseInt((count as unknown as CountResult).count || "0", 10);
} catch (error) {
throw new DatabaseError({ error, name: "Count active certificates for sync" });
}
};
const findCertificatesEligibleForRenewal = async ({ const findCertificatesEligibleForRenewal = async ({
limit, limit,
offset offset
@@ -159,7 +266,7 @@ export const certificateDALFactory = (db: TDbClient) => {
}; };
const findWithPrivateKeyInfo = async ( const findWithPrivateKeyInfo = async (
filter: Partial<TCertificates>, filter: Partial<TCertificates & { friendlyName?: string; commonName?: string }>,
options?: { offset?: number; limit?: number; sort?: [string, "asc" | "desc"][] } options?: { offset?: number; limit?: number; sort?: [string, "asc" | "desc"][] }
): Promise<(TCertificates & { hasPrivateKey: boolean })[]> => { ): Promise<(TCertificates & { hasPrivateKey: boolean })[]> => {
try { try {
@@ -167,8 +274,18 @@ export const certificateDALFactory = (db: TDbClient) => {
.replicaNode()(TableName.Certificate) .replicaNode()(TableName.Certificate)
.leftJoin(TableName.CertificateSecret, `${TableName.Certificate}.id`, `${TableName.CertificateSecret}.certId`) .leftJoin(TableName.CertificateSecret, `${TableName.Certificate}.id`, `${TableName.CertificateSecret}.certId`)
.select(selectAllTableCols(TableName.Certificate)) .select(selectAllTableCols(TableName.Certificate))
.select(db.ref(`${TableName.CertificateSecret}.certId`).as("privateKeyRef")) .select(db.ref(`${TableName.CertificateSecret}.certId`).as("privateKeyRef"));
.where(filter);
Object.entries(filter).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
if (key === "friendlyName" || key === "commonName") {
const sanitizedValue = String(value).replace(new RE2("[%_\\\\]", "g"), "\\$&");
query = query.andWhere(`${TableName.Certificate}.${key}`, "like", `%${sanitizedValue}%`);
} else {
query = query.andWhere(`${TableName.Certificate}.${key}`, value);
}
}
});
if (options?.offset) { if (options?.offset) {
query = query.offset(options.offset); query = query.offset(options.offset);
@@ -197,10 +314,13 @@ export const certificateDALFactory = (db: TDbClient) => {
return { return {
...certificateOrm, ...certificateOrm,
countCertificatesInProject, countCertificatesInProject,
countActiveCertificatesForSync,
countCertificatesForPkiSubscriber, countCertificatesForPkiSubscriber,
findLatestActiveCertForSubscriber, findLatestActiveCertForSubscriber,
findAllActiveCertsForSubscriber, findAllActiveCertsForSubscriber,
findExpiredSyncedCertificates, findExpiredSyncedCertificates,
findActiveCertificatesByIds,
findActiveCertificatesForSync,
findCertificatesEligibleForRenewal, findCertificatesEligibleForRenewal,
findWithPrivateKeyInfo findWithPrivateKeyInfo
}; };
@@ -18,12 +18,13 @@ import { TCertificateAuthorityDALFactory } from "@app/services/certificate-autho
import { CaCapability, CaType } from "@app/services/certificate-authority/certificate-authority-enums"; import { CaCapability, CaType } from "@app/services/certificate-authority/certificate-authority-enums";
import { caSupportsCapability } from "@app/services/certificate-authority/certificate-authority-maps"; import { caSupportsCapability } from "@app/services/certificate-authority/certificate-authority-maps";
import { TCertificateAuthoritySecretDALFactory } from "@app/services/certificate-authority/certificate-authority-secret-dal"; import { TCertificateAuthoritySecretDALFactory } from "@app/services/certificate-authority/certificate-authority-secret-dal";
import { TCertificateSyncDALFactory } from "@app/services/certificate-sync/certificate-sync-dal";
import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TPkiCollectionDALFactory } from "@app/services/pki-collection/pki-collection-dal"; import { TPkiCollectionDALFactory } from "@app/services/pki-collection/pki-collection-dal";
import { TPkiCollectionItemDALFactory } from "@app/services/pki-collection/pki-collection-item-dal"; import { TPkiCollectionItemDALFactory } from "@app/services/pki-collection/pki-collection-item-dal";
import { TPkiSyncDALFactory } from "@app/services/pki-sync/pki-sync-dal"; import { TPkiSyncDALFactory } from "@app/services/pki-sync/pki-sync-dal";
import { TPkiSyncQueueFactory } from "@app/services/pki-sync/pki-sync-queue"; import { TPkiSyncQueueFactory } from "@app/services/pki-sync/pki-sync-queue";
import { triggerAutoSyncForSubscriber } from "@app/services/pki-sync/pki-sync-utils"; import { triggerAutoSyncForCertificate } from "@app/services/pki-sync/pki-sync-utils";
import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal";
import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns";
@@ -57,6 +58,7 @@ type TCertificateServiceFactoryDep = {
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug" | "findOne" | "updateById" | "findById" | "transaction">; projectDAL: Pick<TProjectDALFactory, "findProjectBySlug" | "findOne" | "updateById" | "findById" | "transaction">;
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey">; kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">; permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
certificateSyncDAL: Pick<TCertificateSyncDALFactory, "findPkiSyncIdsByCertificateId">;
pkiSyncDAL: Pick<TPkiSyncDALFactory, "find">; pkiSyncDAL: Pick<TPkiSyncDALFactory, "find">;
pkiSyncQueue: Pick<TPkiSyncQueueFactory, "queuePkiSyncSyncCertificatesById">; pkiSyncQueue: Pick<TPkiSyncQueueFactory, "queuePkiSyncSyncCertificatesById">;
}; };
@@ -76,6 +78,7 @@ export const certificateServiceFactory = ({
projectDAL, projectDAL,
kmsService, kmsService,
permissionService, permissionService,
certificateSyncDAL,
pkiSyncDAL, pkiSyncDAL,
pkiSyncQueue pkiSyncQueue
}: TCertificateServiceFactoryDep) => { }: TCertificateServiceFactoryDep) => {
@@ -166,10 +169,12 @@ export const certificateServiceFactory = ({
const deletedCert = await certificateDAL.deleteById(cert.id); const deletedCert = await certificateDAL.deleteById(cert.id);
// Trigger auto sync for PKI syncs connected to this certificate's subscriber // Trigger auto sync for PKI syncs connected to this certificate
if (cert.pkiSubscriberId) { await triggerAutoSyncForCertificate(cert.id, {
await triggerAutoSyncForSubscriber(cert.pkiSubscriberId, { pkiSyncDAL, pkiSyncQueue }); certificateSyncDAL,
} pkiSyncDAL,
pkiSyncQueue
});
return { return {
deletedCert deletedCert
@@ -235,10 +240,12 @@ export const certificateServiceFactory = ({
} }
); );
// Trigger auto sync for PKI syncs connected to this certificate's subscriber // Trigger auto sync for PKI syncs connected to this certificate
if (cert.pkiSubscriberId) { await triggerAutoSyncForCertificate(cert.id, {
await triggerAutoSyncForSubscriber(cert.pkiSubscriberId, { pkiSyncDAL, pkiSyncQueue }); certificateSyncDAL,
} pkiSyncDAL,
pkiSyncQueue
});
// Note: External CA revocation handling would go here for supported CA types // Note: External CA revocation handling would go here for supported CA types
// Currently, only internal CAs and ACME CAs support revocation // Currently, only internal CAs and ACME CAs support revocation
@@ -3,7 +3,9 @@ import * as AWS from "aws-sdk";
import RE2 from "re2"; import RE2 from "re2";
import { z } from "zod"; import { z } from "zod";
import { TCertificateSyncs } from "@app/db/schemas";
import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { logger } from "@app/lib/logger";
import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal";
import { AppConnection, AWSRegion } from "@app/services/app-connection/app-connection-enums"; import { AppConnection, AWSRegion } from "@app/services/app-connection/app-connection-enums";
import { decryptAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns"; import { decryptAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns";
@@ -14,6 +16,9 @@ import {
AwsConnectionAssumeRoleCredentialsSchema AwsConnectionAssumeRoleCredentialsSchema
} from "@app/services/app-connection/aws/aws-connection-schemas"; } from "@app/services/app-connection/aws/aws-connection-schemas";
import { TAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-types"; import { TAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-types";
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
import { TCertificateSyncDALFactory } from "@app/services/certificate-sync/certificate-sync-dal";
import { CertificateSyncStatus } from "@app/services/certificate-sync/certificate-sync-enums";
import { createConnectionQueue, RateLimitConfig } from "@app/services/connection-queue"; import { createConnectionQueue, RateLimitConfig } from "@app/services/connection-queue";
import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TCertificateMap } from "@app/services/pki-sync/pki-sync-types"; import { TCertificateMap } from "@app/services/pki-sync/pki-sync-types";
@@ -88,39 +93,6 @@ const shouldSkipCertificateExport = (certificate: AWS.ACM.CertificateSummary): b
return isAwsIssuedCertificate(certificate); return isAwsIssuedCertificate(certificate);
}; };
const findTagByKey = (tags: AWS.ACM.TagList | undefined, key: string): AWS.ACM.Tag | undefined => {
if (!tags || !Array.isArray(tags)) {
return undefined;
}
return tags.find((tag: AWS.ACM.Tag) => tag.Key === key && tag.Value);
};
const findInfisicalCertificateTag = (tags: AWS.ACM.TagList | undefined): AWS.ACM.Tag | undefined => {
return findTagByKey(tags, INFISICAL_CERTIFICATE_TAG);
};
const validateCertificateIdentification = (
certName: string,
existingCert: { arn?: string; Tags?: AWS.ACM.TagList; cert?: string; privateKey?: string; certificateChain?: string }
): boolean => {
if (!existingCert?.arn || !existingCert?.Tags) {
return false;
}
const certNameTag = findInfisicalCertificateTag(existingCert.Tags);
if (!certNameTag || !certNameTag.Value) {
return false;
}
return certNameTag.Value === certName;
};
type TAwsCertificateManagerPkiSyncFactoryDeps = {
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "updateById">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
};
const validateCertificateNameSchema = (schema: string): void => { const validateCertificateNameSchema = (schema: string): void => {
if (!schema.includes("{{certificateId}}")) { if (!schema.includes("{{certificateId}}")) {
throw new Error( throw new Error(
@@ -174,6 +146,21 @@ const generateCertificateName = (certificateName: string, pkiSync: TPkiSyncWithC
return sanitizedCertificateName; return sanitizedCertificateName;
}; };
type TAwsCertificateManagerPkiSyncFactoryDeps = {
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "updateById">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
certificateSyncDAL: Pick<
TCertificateSyncDALFactory,
| "removeCertificates"
| "addCertificates"
| "findByPkiSyncAndCertificate"
| "updateSyncStatus"
| "updateById"
| "findByPkiSyncId"
>;
certificateDAL: Pick<TCertificateDALFactory, "findById">;
};
const getAwsAcmClient = async ( const getAwsAcmClient = async (
connectionId: string, connectionId: string,
region: AWSRegion, region: AWSRegion,
@@ -230,7 +217,9 @@ const getAwsAcmClient = async (
export const awsCertificateManagerPkiSyncFactory = ({ export const awsCertificateManagerPkiSyncFactory = ({
kmsService, kmsService,
appConnectionDAL appConnectionDAL,
certificateSyncDAL,
certificateDAL
}: TAwsCertificateManagerPkiSyncFactoryDeps) => { }: TAwsCertificateManagerPkiSyncFactoryDeps) => {
const deleteCertificateFromAcm = async ( const deleteCertificateFromAcm = async (
acm: AWS.ACM, acm: AWS.ACM,
@@ -392,79 +381,201 @@ export const awsCertificateManagerPkiSyncFactory = ({
kmsService kmsService
); );
const { acmCertificates } = await $getAwsAcmCertificates(acm, pkiSync.id); const {
acmCertificates
}: {
acmCertificates: Record<
string,
{ cert: string; privateKey: string; certificateChain?: string; arn?: string; Tags?: AWS.ACM.TagList }
>;
} = await $getAwsAcmCertificates(acm, pkiSync.id);
const acmCertificatesByArn = new Map<string, (typeof acmCertificates)[string]>();
Object.values(acmCertificates).forEach((acmCert) => {
if (acmCert.arn) {
acmCertificatesByArn.set(acmCert.arn, acmCert);
}
});
const existingSyncRecords = await certificateSyncDAL.findByPkiSyncId(pkiSync.id);
const syncRecordsByCertId = new Map<string, TCertificateSyncs>();
const syncRecordsByExternalId = new Map<string, TCertificateSyncs>();
existingSyncRecords.forEach((record: TCertificateSyncs) => {
if (record.certificateId) {
syncRecordsByCertId.set(record.certificateId, record);
}
if (record.externalIdentifier) {
syncRecordsByExternalId.set(record.externalIdentifier, record);
}
});
const setCertificates: CertificateImportRequest[] = []; const setCertificates: CertificateImportRequest[] = [];
const validationErrors: Array<{ name: string; error: string }> = [];
const activeCertificateNames = Object.keys(certificateMap); const syncOptions = pkiSync.syncOptions as { preserveArn?: boolean; canRemoveCertificates?: boolean } | undefined;
const preserveArn = syncOptions?.preserveArn ?? true;
const canRemoveCertificates = syncOptions?.canRemoveCertificates ?? true;
Object.entries(certificateMap).forEach(([certName, certData]) => { const activeExternalIdentifiers = new Set<string>();
const { cert, privateKey, certificateChain } = certData;
const certificateName = generateCertificateName(certName, pkiSync);
const existingCert = Object.values(acmCertificates).find((acmCert) => for (const [certName, certData] of Object.entries(certificateMap)) {
validateCertificateIdentification(certName, acmCert) const { cert, privateKey, certificateChain, certificateId } = certData;
);
const shouldUpdateCert = !existingCert || existingCert.cert !== cert;
try { try {
validateCertificateContent(cert, privateKey); validateCertificateContent(cert, privateKey);
} catch (validationError) { } catch (validationError) {
throw new PkiSyncError({ const errorMessage = validationError instanceof Error ? validationError.message : String(validationError);
message: `Certificate validation failed for ${certName}: ${validationError instanceof Error ? validationError.message : String(validationError)}`, validationErrors.push({
shouldRetry: false, name: certName,
context: { error: `Certificate validation failed: ${errorMessage}`
certificateName,
certName
}
}); });
// eslint-disable-next-line no-continue
continue;
} }
if (shouldUpdateCert) { if (preserveArn && certificateId && typeof certificateId === "string") {
const certificate = await certificateDAL.findById(certificateId);
if (certificate?.renewedByCertificateId) {
// eslint-disable-next-line no-continue
continue;
}
}
const certificateName = generateCertificateName(certName, pkiSync);
let targetArn: string | undefined;
let shouldCreateNew = false;
if (!certificateId || typeof certificateId !== "string") {
shouldCreateNew = true;
} else {
const currentCertificate = await certificateDAL.findById(certificateId);
const isRenewal = !!currentCertificate?.renewedFromCertificateId;
if (isRenewal) {
const currentSyncRecord = syncRecordsByCertId.get(certificateId);
const oldCertificateId = currentCertificate.renewedFromCertificateId;
const oldSyncRecord = oldCertificateId ? syncRecordsByCertId.get(oldCertificateId) : undefined;
if (currentSyncRecord?.externalIdentifier) {
const existingAcmCert = acmCertificatesByArn.get(currentSyncRecord.externalIdentifier);
if (existingAcmCert) {
if (!preserveArn && oldSyncRecord?.externalIdentifier === currentSyncRecord.externalIdentifier) {
shouldCreateNew = true;
} else if (preserveArn && oldSyncRecord?.externalIdentifier === currentSyncRecord.externalIdentifier) {
targetArn = currentSyncRecord.externalIdentifier;
shouldCreateNew = true;
activeExternalIdentifiers.add(targetArn);
if (oldCertificateId && oldSyncRecord) {
await certificateSyncDAL.removeCertificates(pkiSync.id, [oldCertificateId]);
}
} else {
targetArn = currentSyncRecord.externalIdentifier;
activeExternalIdentifiers.add(targetArn);
shouldCreateNew = false;
}
} else {
shouldCreateNew = true;
}
} else if (preserveArn && oldSyncRecord?.externalIdentifier) {
const existingAcmCert = acmCertificatesByArn.get(oldSyncRecord.externalIdentifier);
if (existingAcmCert) {
targetArn = oldSyncRecord.externalIdentifier;
shouldCreateNew = true;
activeExternalIdentifiers.add(targetArn);
if (oldCertificateId) {
await certificateSyncDAL.removeCertificates(pkiSync.id, [oldCertificateId]);
}
} else {
shouldCreateNew = true;
}
} else {
shouldCreateNew = true;
}
} else {
const existingSyncRecord = syncRecordsByCertId.get(certificateId);
if (existingSyncRecord?.externalIdentifier) {
const existingAcmCert = acmCertificatesByArn.get(existingSyncRecord.externalIdentifier);
if (existingAcmCert) {
targetArn = existingSyncRecord.externalIdentifier;
activeExternalIdentifiers.add(targetArn);
shouldCreateNew = false;
} else {
shouldCreateNew = true;
}
} else {
shouldCreateNew = true;
}
}
}
if (shouldCreateNew) {
setCertificates.push({ setCertificates.push({
key: certName, key: certName,
name: certificateName, name: certificateName,
cert, cert,
privateKey, privateKey,
certificateChain, certificateChain,
existingArn: existingCert?.arn existingArn: targetArn,
certificateId: certificateId as string
}); });
} }
});
// Identify expired/removed certificates that need to be cleaned up from ACM if (targetArn) {
const certificatesToRemove = Object.values(acmCertificates) activeExternalIdentifiers.add(targetArn);
.filter((acmCert) => { }
if (!acmCert.arn || !acmCert.Tags) { }
return false;
const certificatesToRemove: string[] = [];
if (canRemoveCertificates) {
existingSyncRecords.forEach((syncRecord) => {
if (syncRecord.externalIdentifier && !activeExternalIdentifiers.has(syncRecord.externalIdentifier)) {
const acmCert = acmCertificatesByArn.get(syncRecord.externalIdentifier);
if (acmCert?.arn) {
certificatesToRemove.push(acmCert.arn);
}
} }
});
const certNameTag = findInfisicalCertificateTag(acmCert.Tags); Object.values(acmCertificates).forEach((acmCert) => {
if (!certNameTag || !certNameTag.Value) { if (acmCert.arn && acmCert.Tags) {
return false; const hasInfisicalTag = acmCert.Tags.some((tag) => tag.Key === INFISICAL_CERTIFICATE_TAG && tag.Value);
if (hasInfisicalTag) {
const isTrackedInSyncRecords = existingSyncRecords.some(
(record) => record.externalIdentifier === acmCert.arn
);
const isInActiveSet = activeExternalIdentifiers.has(acmCert.arn);
if (!isTrackedInSyncRecords && !isInActiveSet && !certificatesToRemove.includes(acmCert.arn)) {
certificatesToRemove.push(acmCert.arn);
}
}
} }
});
const isActive = activeCertificateNames.includes(certNameTag.Value); }
return !isActive;
})
.map((acmCert) => acmCert.arn!)
.filter((arn) => arn);
const uploadResults = await executeWithConcurrencyLimit( const uploadResults = await executeWithConcurrencyLimit(
setCertificates, setCertificates,
async ({ key, name, cert, privateKey, certificateChain, existingArn }) => { async ({ key, name, cert, privateKey, certificateChain, existingArn, certificateId }) => {
try { try {
const importParams: AWS.ACM.ImportCertificateRequest = { const importParams: AWS.ACM.ImportCertificateRequest = {
Certificate: cert, Certificate: cert,
PrivateKey: privateKey, PrivateKey: privateKey
Tags: [ };
if (!existingArn) {
importParams.Tags = [
{ {
Key: INFISICAL_CERTIFICATE_TAG, Key: INFISICAL_CERTIFICATE_TAG,
Value: key Value: key
} }
] ];
}; }
if (certificateChain && certificateChain.trim().length > 0) { if (certificateChain && certificateChain.trim().length > 0) {
importParams.CertificateChain = certificateChain; importParams.CertificateChain = certificateChain;
@@ -478,6 +589,57 @@ export const awsCertificateManagerPkiSyncFactory = ({
syncId: pkiSync.id syncId: pkiSync.id
}); });
if (existingArn && response.CertificateArn) {
try {
// Small delay to ensure AWS ACM has processed the certificate import
await new Promise<void>((resolve) => {
setTimeout(() => resolve(), 500);
});
await withRateLimitRetry(
() =>
acm
.addTagsToCertificate({
CertificateArn: response.CertificateArn!,
Tags: [
{
Key: INFISICAL_CERTIFICATE_TAG,
Value: key
}
]
})
.promise(),
{
operation: "add-tags-to-certificate",
syncId: pkiSync.id
}
);
} catch (tagError) {
const errorMessage = tagError instanceof Error ? tagError.message : "Unknown tagging error";
logger.warn(
`Failed to add tags to certificate ${key} (ARN: ${response.CertificateArn}): ${errorMessage}`
);
}
}
if (response.CertificateArn && certificateId) {
const existingCertSync = await certificateSyncDAL.findByPkiSyncAndCertificate(pkiSync.id, certificateId);
if (existingCertSync) {
await certificateSyncDAL.updateById(existingCertSync.id, {
externalIdentifier: response.CertificateArn,
syncStatus: CertificateSyncStatus.Succeeded,
lastSyncedAt: new Date()
});
} else {
await certificateSyncDAL.addCertificates(pkiSync.id, [
{
certificateId,
externalIdentifier: response.CertificateArn
}
]);
}
}
return { key, name, success: true, response }; return { key, name, success: true, response };
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error"; const errorMessage = error instanceof Error ? error.message : "Unknown error";
@@ -520,15 +682,21 @@ export const awsCertificateManagerPkiSyncFactory = ({
const details: { const details: {
failedUploads?: Array<{ name: string; error: string }>; failedUploads?: Array<{ name: string; error: string }>;
failedRemovals?: Array<{ name: string; error: string }>; failedRemovals?: Array<{ name: string; error: string }>;
validationErrors?: Array<{ name: string; error: string }>;
} = {}; } = {};
if (validationErrors.length > 0) {
details.validationErrors = validationErrors;
}
if (failedUploads.length > 0) { if (failedUploads.length > 0) {
details.failedUploads = failedUploads.map((failure, index) => { details.failedUploads = failedUploads.map((failure, index) => {
const certificateName = setCertificates[index]?.name || "unknown"; const certificateRequest = setCertificates[index];
const certificateName = certificateRequest?.name || certificateRequest?.key || "unknown";
let errorMessage = "Unknown error"; let errorMessage = "Unknown error";
if (failure.status === "rejected") { if (failure.status === "rejected") {
errorMessage = failure.reason instanceof Error ? failure.reason.message : "Unknown error"; errorMessage = failure.reason instanceof Error ? failure.reason.message : String(failure.reason);
} }
return { return {
@@ -567,7 +735,8 @@ export const awsCertificateManagerPkiSyncFactory = ({
const removeCertificates = async ( const removeCertificates = async (
pkiSync: TPkiSyncWithCredentials, pkiSync: TPkiSyncWithCredentials,
certificateNames: string[] certificateNames: string[],
deps?: { certificateSyncDAL?: TCertificateSyncDALFactory; certificateMap?: TCertificateMap }
): Promise<RemoveCertificatesResult> => { ): Promise<RemoveCertificatesResult> => {
const destinationConfig = pkiSync.destinationConfig as TAwsCertificateManagerPkiSyncConfig; const destinationConfig = pkiSync.destinationConfig as TAwsCertificateManagerPkiSyncConfig;
const acm = await getAwsAcmClient( const acm = await getAwsAcmClient(
@@ -577,22 +746,33 @@ export const awsCertificateManagerPkiSyncFactory = ({
kmsService kmsService
); );
const { acmCertificates } = await $getAwsAcmCertificates(acm, pkiSync.id); const existingSyncRecords = await certificateSyncDAL.findByPkiSyncId(pkiSync.id);
const certificateArnsToRemove: string[] = []; const certificateArnsToRemove: string[] = [];
const certificateIdToArnMap = new Map<string, string>();
for (const certName of certificateNames) { for (const certName of certificateNames) {
const matchingCerts = Object.values(acmCertificates).filter((acmCert) => const certificateData = deps?.certificateMap?.[certName];
validateCertificateIdentification(certName, acmCert) if (certificateData?.certificateId) {
); const { certificateId } = certificateData;
for (const acmCert of matchingCerts) { if (typeof certificateId === "string") {
if (acmCert.arn) { const syncRecord = existingSyncRecords.find((record) => record.certificateId === certificateId);
certificateArnsToRemove.push(acmCert.arn);
if (syncRecord?.externalIdentifier) {
certificateArnsToRemove.push(syncRecord.externalIdentifier);
certificateIdToArnMap.set(certificateId, syncRecord.externalIdentifier);
}
} }
} }
} }
if (certificateArnsToRemove.length === 0) {
return {
removed: 0,
failed: 0,
skipped: certificateNames.length
};
}
const results = await executeWithConcurrencyLimit( const results = await executeWithConcurrencyLimit(
certificateArnsToRemove, certificateArnsToRemove,
async (certificateArn) => async (certificateArn) =>
@@ -602,6 +782,38 @@ export const awsCertificateManagerPkiSyncFactory = ({
const failedRemovals = results.filter((result) => result.status === "rejected"); const failedRemovals = results.filter((result) => result.status === "rejected");
if (failedRemovals.length > 0 && deps?.certificateSyncDAL) {
for (const failure of failedRemovals) {
if (failure.status === "rejected") {
const failedArn = certificateArnsToRemove[results.indexOf(failure)];
const certificateId = Array.from(certificateIdToArnMap.entries()).find(([, arn]) => arn === failedArn)?.[0];
if (certificateId) {
const errorMessage = failure.reason instanceof Error ? failure.reason.message : "Unknown error";
await deps.certificateSyncDAL.updateSyncStatus(
pkiSync.id,
certificateId,
CertificateSyncStatus.Failed,
`Failed to remove from AWS: ${errorMessage}`
);
}
}
}
}
const successfulRemovals = results.filter((result) => result.status === "fulfilled");
if (successfulRemovals.length > 0) {
const successfulArns = new Set(successfulRemovals.map((_, index) => certificateArnsToRemove[index]));
const certificateIdsToRemove = Array.from(certificateIdToArnMap.entries())
.filter(([, arn]) => successfulArns.has(arn))
.map(([certificateId]) => certificateId);
if (certificateIdsToRemove.length > 0) {
await certificateSyncDAL.removeCertificates(pkiSync.id, certificateIdsToRemove);
}
}
if (failedRemovals.length > 0) { if (failedRemovals.length > 0) {
const failedReasons = failedRemovals.map((failure) => { const failedReasons = failedRemovals.map((failure) => {
if (failure.status === "rejected") { if (failure.status === "rejected") {
@@ -14,6 +14,7 @@ export const AwsCertificateManagerPkiSyncConfigSchema = z.object({
const AwsCertificateManagerPkiSyncOptionsSchema = z.object({ const AwsCertificateManagerPkiSyncOptionsSchema = z.object({
canImportCertificates: z.boolean().default(false), canImportCertificates: z.boolean().default(false),
canRemoveCertificates: z.boolean().default(true), canRemoveCertificates: z.boolean().default(true),
preserveArn: z.boolean().default(true),
certificateNameSchema: z certificateNameSchema: z
.string() .string()
.optional() .optional()
@@ -28,6 +29,9 @@ const AwsCertificateManagerPkiSyncOptionsSchema = z.object({
const testName = schema const testName = schema
.replace(new RE2("\\{\\{certificateId\\}\\}", "g"), "test-cert-id") .replace(new RE2("\\{\\{certificateId\\}\\}", "g"), "test-cert-id")
.replace(new RE2("\\{\\{profileId\\}\\}", "g"), "test-profile-id")
.replace(new RE2("\\{\\{commonName\\}\\}", "g"), "test-common-name")
.replace(new RE2("\\{\\{friendlyName\\}\\}", "g"), "test-friendly-name")
.replace(new RE2("\\{\\{environment\\}\\}", "g"), "test-env"); .replace(new RE2("\\{\\{environment\\}\\}", "g"), "test-env");
const hasForbiddenChars = AWS_CERTIFICATE_MANAGER_CERTIFICATE_NAMING.FORBIDDEN_CHARACTERS.split("").some( const hasForbiddenChars = AWS_CERTIFICATE_MANAGER_CERTIFICATE_NAMING.FORBIDDEN_CHARACTERS.split("").some(
@@ -43,7 +47,7 @@ const AwsCertificateManagerPkiSyncOptionsSchema = z.object({
}, },
{ {
message: message:
"Certificate name schema must include {{certificateId}} placeholder and result in names that contain only alphanumeric characters, spaces, hyphens, and underscores and be 1-256 characters long when compiled for AWS Certificate Manager" "Certificate name schema must include {{certificateId}} placeholder and result in names that contain only alphanumeric characters, spaces, hyphens, and underscores and be 1-256 characters long when compiled for AWS Certificate Manager. Available placeholders: {{certificateId}}, {{profileId}}, {{commonName}}, {{friendlyName}}, {{environment}}"
} }
) )
}); });
@@ -60,9 +64,10 @@ export const CreateAwsCertificateManagerPkiSyncSchema = z.object({
isAutoSyncEnabled: z.boolean().default(true), isAutoSyncEnabled: z.boolean().default(true),
destinationConfig: AwsCertificateManagerPkiSyncConfigSchema, destinationConfig: AwsCertificateManagerPkiSyncConfigSchema,
syncOptions: AwsCertificateManagerPkiSyncOptionsSchema.optional().default({}), syncOptions: AwsCertificateManagerPkiSyncOptionsSchema.optional().default({}),
subscriberId: z.string().optional(), subscriberId: z.string().nullish(),
connectionId: z.string(), connectionId: z.string(),
projectId: z.string().trim().min(1) projectId: z.string().trim().min(1),
certificateIds: z.array(z.string().uuid()).optional()
}); });
export const UpdateAwsCertificateManagerPkiSyncSchema = z.object({ export const UpdateAwsCertificateManagerPkiSyncSchema = z.object({
@@ -71,7 +76,7 @@ export const UpdateAwsCertificateManagerPkiSyncSchema = z.object({
isAutoSyncEnabled: z.boolean().optional(), isAutoSyncEnabled: z.boolean().optional(),
destinationConfig: AwsCertificateManagerPkiSyncConfigSchema.optional(), destinationConfig: AwsCertificateManagerPkiSyncConfigSchema.optional(),
syncOptions: AwsCertificateManagerPkiSyncOptionsSchema.optional(), syncOptions: AwsCertificateManagerPkiSyncOptionsSchema.optional(),
subscriberId: z.string().optional(), subscriberId: z.string().nullish(),
connectionId: z.string().optional() connectionId: z.string().optional()
}); });
@@ -39,6 +39,7 @@ export interface SyncCertificatesResult {
details?: { details?: {
failedUploads?: Array<{ name: string; error: string }>; failedUploads?: Array<{ name: string; error: string }>;
failedRemovals?: Array<{ name: string; error: string }>; failedRemovals?: Array<{ name: string; error: string }>;
validationErrors?: Array<{ name: string; error: string }>;
}; };
} }
@@ -55,4 +56,5 @@ export interface CertificateImportRequest {
privateKey: string; privateKey: string;
certificateChain?: string; certificateChain?: string;
existingArn?: string; existingArn?: string;
certificateId?: string;
} }
@@ -2,10 +2,14 @@
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import * as crypto from "crypto"; import * as crypto from "crypto";
import { TCertificateSyncs } from "@app/db/schemas";
import { request } from "@app/lib/config/request"; import { request } from "@app/lib/config/request";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal";
import { getAzureConnectionAccessToken } from "@app/services/app-connection/azure-key-vault"; import { getAzureConnectionAccessToken } from "@app/services/app-connection/azure-key-vault";
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
import { TCertificateSyncDALFactory } from "@app/services/certificate-sync/certificate-sync-dal";
import { CertificateSyncStatus } from "@app/services/certificate-sync/certificate-sync-enums";
import { createConnectionQueue, RateLimitConfig } from "@app/services/connection-queue"; import { createConnectionQueue, RateLimitConfig } from "@app/services/connection-queue";
import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { matchesCertificateNameSchema } from "@app/services/pki-sync/pki-sync-fns"; import { matchesCertificateNameSchema } from "@app/services/pki-sync/pki-sync-fns";
@@ -32,7 +36,9 @@ const extractCertificateNameFromId = (certificateId: string): string => {
}; };
const isInfisicalManagedCertificate = (certificateName: string, pkiSync: TPkiSyncWithCredentials): boolean => { const isInfisicalManagedCertificate = (certificateName: string, pkiSync: TPkiSyncWithCredentials): boolean => {
const syncOptions = pkiSync.syncOptions as { certificateNameSchema?: string } | undefined; const syncOptions = pkiSync.syncOptions as
| { certificateNameSchema?: string; canRemoveCertificates?: boolean }
| undefined;
const certificateNameSchema = syncOptions?.certificateNameSchema; const certificateNameSchema = syncOptions?.certificateNameSchema;
if (certificateNameSchema) { if (certificateNameSchema) {
@@ -46,6 +52,16 @@ const isInfisicalManagedCertificate = (certificateName: string, pkiSync: TPkiSyn
type TAzureKeyVaultPkiSyncFactoryDeps = { type TAzureKeyVaultPkiSyncFactoryDeps = {
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "updateById">; appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "updateById">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">; kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
certificateSyncDAL: Pick<
TCertificateSyncDALFactory,
| "removeCertificates"
| "addCertificates"
| "findByPkiSyncAndCertificate"
| "updateById"
| "findByPkiSyncId"
| "updateSyncStatus"
>;
certificateDAL: Pick<TCertificateDALFactory, "findById">;
}; };
const parseCertificateX509Props = (certPem: string) => { const parseCertificateX509Props = (certPem: string) => {
@@ -188,7 +204,12 @@ const parseCertificateKeyProps = (certPem: string) => {
} }
}; };
export const azureKeyVaultPkiSyncFactory = ({ kmsService, appConnectionDAL }: TAzureKeyVaultPkiSyncFactoryDeps) => { export const azureKeyVaultPkiSyncFactory = ({
kmsService,
appConnectionDAL,
certificateSyncDAL,
certificateDAL
}: TAzureKeyVaultPkiSyncFactoryDeps) => {
const $getAzureKeyVaultCertificates = async (accessToken: string, vaultBaseUrl: string, syncId = "unknown") => { const $getAzureKeyVaultCertificates = async (accessToken: string, vaultBaseUrl: string, syncId = "unknown") => {
const paginateAzureKeyVaultCertificates = async () => { const paginateAzureKeyVaultCertificates = async () => {
let result: GetAzureKeyVaultCertificate[] = []; let result: GetAzureKeyVaultCertificate[] = [];
@@ -325,48 +346,126 @@ export const azureKeyVaultPkiSyncFactory = ({ kmsService, appConnectionDAL }: TA
pkiSync.id pkiSync.id
); );
const existingSyncRecords = await certificateSyncDAL.findByPkiSyncId(pkiSync.id);
const syncRecordsByCertId = new Map<string, TCertificateSyncs>();
const syncRecordsByExternalId = new Map<string, TCertificateSyncs>();
existingSyncRecords.forEach((record: TCertificateSyncs) => {
if (record.certificateId) {
syncRecordsByCertId.set(record.certificateId, record);
}
if (record.externalIdentifier) {
syncRecordsByExternalId.set(record.externalIdentifier, record);
}
});
const setCertificates: { const setCertificates: {
key: string; key: string;
cert: string; cert: string;
privateKey: string; privateKey: string;
certificateChain?: string; certificateChain?: string;
certificateId?: string;
}[] = []; }[] = [];
// Track which certificates should exist in Azure Key Vault const syncOptions = pkiSync.syncOptions as
const activeCertificateNames = Object.keys(certificateMap); | { certificateNameSchema?: string; canRemoveCertificates?: boolean; enableVersioning?: boolean }
| undefined;
const canRemoveCertificates = syncOptions?.canRemoveCertificates ?? true;
const enableVersioning = syncOptions?.enableVersioning ?? true;
const activeExternalIdentifiers = new Set<string>();
// Iterate through certificates to sync to Azure Key Vault // Iterate through certificates to sync to Azure Key Vault
Object.entries(certificateMap).forEach(([certName, { cert, privateKey, certificateChain }]) => { for (const [certName, { cert, privateKey, certificateChain, certificateId }] of Object.entries(certificateMap)) {
if (disabledAzureKeyVaultCertificateKeys.includes(certName)) { if (disabledAzureKeyVaultCertificateKeys.includes(certName)) {
return; // eslint-disable-next-line no-continue
continue;
} }
const existingCert = vaultCertificates[certName]; if (enableVersioning && typeof certificateId === "string") {
const shouldUpdateCert = !existingCert || existingCert.cert !== cert; const certificate = await certificateDAL.findById(certificateId);
if (certificate?.renewedByCertificateId) {
// eslint-disable-next-line no-continue
continue;
}
}
if (shouldUpdateCert) { let targetCertName = certName;
let shouldCreateNew = false;
if (typeof certificateId === "string") {
const existingSyncRecord = syncRecordsByCertId.get(certificateId);
if (existingSyncRecord?.externalIdentifier) {
const existingAzureCert = vaultCertificates[existingSyncRecord.externalIdentifier];
if (existingAzureCert && enableVersioning) {
targetCertName = existingSyncRecord.externalIdentifier;
activeExternalIdentifiers.add(targetCertName);
const shouldUpdateCert = existingAzureCert.cert !== cert;
if (shouldUpdateCert) {
shouldCreateNew = true;
}
} else if (!existingAzureCert) {
shouldCreateNew = true;
} else if (!enableVersioning) {
shouldCreateNew = true;
}
} else {
shouldCreateNew = true;
}
} else {
shouldCreateNew = true;
}
if (shouldCreateNew || !vaultCertificates[targetCertName] || vaultCertificates[targetCertName].cert !== cert) {
setCertificates.push({ setCertificates.push({
key: certName, key: targetCertName,
cert, cert,
privateKey, privateKey,
certificateChain certificateChain,
certificateId
}); });
} }
});
// Identify expired/removed certificates that need to be cleaned up from Azure Key Vault if (targetCertName) {
// Only remove certificates that were managed by Infisical (match naming schema) activeExternalIdentifiers.add(targetCertName);
const certificatesToRemove = Object.keys(vaultCertificates).filter( }
(vaultCertName) => }
isInfisicalManagedCertificate(vaultCertName, pkiSync) &&
!activeCertificateNames.includes(vaultCertName) && const certificatesToRemove: string[] = [];
!disabledAzureKeyVaultCertificateKeys.includes(vaultCertName)
); if (canRemoveCertificates) {
existingSyncRecords.forEach((syncRecord) => {
if (syncRecord.externalIdentifier && !activeExternalIdentifiers.has(syncRecord.externalIdentifier)) {
if (vaultCertificates[syncRecord.externalIdentifier]) {
certificatesToRemove.push(syncRecord.externalIdentifier);
}
}
});
Object.keys(vaultCertificates).forEach((certificateName) => {
const isInfisicalManaged = isInfisicalManagedCertificate(certificateName, pkiSync);
if (isInfisicalManaged) {
const isTrackedInSyncRecords = existingSyncRecords.some(
(record) => record.externalIdentifier === certificateName
);
const isInActiveSet = activeExternalIdentifiers.has(certificateName);
if (!isTrackedInSyncRecords && !isInActiveSet && !certificatesToRemove.includes(certificateName)) {
certificatesToRemove.push(certificateName);
}
}
});
}
// Upload certificates to Azure Key Vault with rate limiting // Upload certificates to Azure Key Vault with rate limiting
const uploadResults = await executeWithConcurrencyLimit( const uploadResults = await executeWithConcurrencyLimit(
setCertificates, setCertificates,
async ({ key, cert, privateKey, certificateChain }) => { async ({ key, cert, privateKey, certificateChain, certificateId }) => {
try { try {
// Combine private key, certificate, and certificate chain in PEM format for Azure Key Vault // Combine private key, certificate, and certificate chain in PEM format for Azure Key Vault
let combinedPem = ""; let combinedPem = "";
@@ -428,6 +527,31 @@ export const azureKeyVaultPkiSyncFactory = ({ kmsService, appConnectionDAL }: TA
} }
); );
if (certificateId) {
const existingCertSync = await certificateSyncDAL.findByPkiSyncAndCertificate(pkiSync.id, certificateId);
if (existingCertSync) {
await certificateSyncDAL.updateById(existingCertSync.id, {
externalIdentifier: key,
syncStatus: CertificateSyncStatus.Succeeded,
lastSyncedAt: new Date()
});
} else {
await certificateSyncDAL.addCertificates(pkiSync.id, [
{
certificateId,
externalIdentifier: key
}
]);
}
if (enableVersioning) {
const currentCertificate = await certificateDAL.findById(certificateId);
if (currentCertificate?.renewedFromCertificateId) {
await certificateSyncDAL.removeCertificates(pkiSync.id, [currentCertificate.renewedFromCertificateId]);
}
}
}
return { key, success: true, response: response.data as unknown }; return { key, success: true, response: response.data as unknown };
} catch (error) { } catch (error) {
if (error instanceof AxiosError) { if (error instanceof AxiosError) {
@@ -599,19 +723,43 @@ export const azureKeyVaultPkiSyncFactory = ({ kmsService, appConnectionDAL }: TA
}; };
}; };
const removeCertificates = async (pkiSync: TPkiSyncWithCredentials, certificateNames: string[]) => { const removeCertificates = async (
pkiSync: TPkiSyncWithCredentials,
certificateNames: string[],
deps?: { certificateSyncDAL?: TCertificateSyncDALFactory; certificateMap?: TCertificateMap }
) => {
const { accessToken } = await getAzureConnectionAccessToken(pkiSync.connection.id, appConnectionDAL, kmsService); const { accessToken } = await getAzureConnectionAccessToken(pkiSync.connection.id, appConnectionDAL, kmsService);
// Cast destination config to Azure Key Vault config // Cast destination config to Azure Key Vault config
const destinationConfig = pkiSync.destinationConfig as TAzureKeyVaultPkiSyncConfig; const destinationConfig = pkiSync.destinationConfig as TAzureKeyVaultPkiSyncConfig;
// Only remove certificates that are managed by Infisical (match naming schema) const existingSyncRecords = await certificateSyncDAL.findByPkiSyncId(pkiSync.id);
const infisicalManagedCertNames = certificateNames.filter((certName) => const certificateNamesToRemove: string[] = [];
isInfisicalManagedCertificate(certName, pkiSync) const certificateIdToNameMap = new Map<string, string>();
);
for (const certName of certificateNames) {
if (deps?.certificateMap?.[certName]?.certificateId) {
const { certificateId } = deps.certificateMap[certName];
const syncRecord = existingSyncRecords.find((record) => record.certificateId === certificateId);
if (syncRecord?.externalIdentifier && typeof certificateId === "string") {
certificateNamesToRemove.push(syncRecord.externalIdentifier);
certificateIdToNameMap.set(certificateId, syncRecord.externalIdentifier);
}
}
}
if (certificateNamesToRemove.length === 0) {
return {
removed: 0,
failed: 0,
skipped: certificateNames.length
};
}
const results = await executeWithConcurrencyLimit( const results = await executeWithConcurrencyLimit(
infisicalManagedCertNames, certificateNamesToRemove,
async (certName) => { async (certName) => {
try { try {
const response = await request.delete( const response = await request.delete(
@@ -646,8 +794,44 @@ export const azureKeyVaultPkiSyncFactory = ({ kmsService, appConnectionDAL }: TA
}, },
{ operation: "remove-specific-certificates", syncId: pkiSync.id } { operation: "remove-specific-certificates", syncId: pkiSync.id }
); );
const failedRemovals = results.filter((result) => result.status === "rejected"); const failedRemovals = results.filter((result) => result.status === "rejected");
if (failedRemovals.length > 0 && deps?.certificateSyncDAL) {
for (const failure of failedRemovals) {
if (failure.status === "rejected") {
const failedCertName = certificateNamesToRemove[results.indexOf(failure)];
const certificateId = Array.from(certificateIdToNameMap.entries()).find(
([, name]) => name === failedCertName
)?.[0];
if (certificateId) {
const errorMessage = (failure.reason as Error)?.message || "Unknown error";
await deps.certificateSyncDAL.updateSyncStatus(
pkiSync.id,
certificateId,
CertificateSyncStatus.Failed,
`Failed to remove from Azure: ${errorMessage}`
);
}
}
}
}
const successfulRemovals = results.filter((result) => result.status === "fulfilled");
if (successfulRemovals.length > 0) {
const successfulCertNames = new Set(successfulRemovals.map((_, index) => certificateNamesToRemove[index]));
const certificateIdsToRemove = Array.from(certificateIdToNameMap.entries())
.filter(([, name]) => successfulCertNames.has(name))
.map(([certificateId]) => certificateId);
if (certificateIdsToRemove.length > 0) {
await certificateSyncDAL.removeCertificates(pkiSync.id, certificateIdsToRemove);
}
}
if (failedRemovals.length > 0) { if (failedRemovals.length > 0) {
const failedReasons = failedRemovals.map((failure) => { const failedReasons = failedRemovals.map((failure) => {
if (failure.status === "rejected") { if (failure.status === "rejected") {
@@ -660,16 +844,16 @@ export const azureKeyVaultPkiSyncFactory = ({ kmsService, appConnectionDAL }: TA
message: `Failed to remove ${failedRemovals.length} certificate(s) from Azure Key Vault`, message: `Failed to remove ${failedRemovals.length} certificate(s) from Azure Key Vault`,
context: { context: {
failedReasons, failedReasons,
totalCertificates: infisicalManagedCertNames.length, totalCertificates: certificateNamesToRemove.length,
failedCount: failedRemovals.length failedCount: failedRemovals.length
} }
}); });
} }
return { return {
removed: infisicalManagedCertNames.length - failedRemovals.length, removed: certificateNamesToRemove.length - failedRemovals.length,
failed: failedRemovals.length, failed: failedRemovals.length,
skipped: certificateNames.length - infisicalManagedCertNames.length skipped: certificateNames.length - certificateNamesToRemove.length
}; };
}; };
@@ -14,6 +14,7 @@ export const AzureKeyVaultPkiSyncConfigSchema = z.object({
const AzureKeyVaultPkiSyncOptionsSchema = z.object({ const AzureKeyVaultPkiSyncOptionsSchema = z.object({
canImportCertificates: z.boolean().default(false), canImportCertificates: z.boolean().default(false),
canRemoveCertificates: z.boolean().default(true), canRemoveCertificates: z.boolean().default(true),
enableVersioning: z.boolean().default(true),
certificateNameSchema: z certificateNameSchema: z
.string() .string()
.optional() .optional()
@@ -50,9 +51,10 @@ export const CreateAzureKeyVaultPkiSyncSchema = z.object({
isAutoSyncEnabled: z.boolean().default(true), isAutoSyncEnabled: z.boolean().default(true),
destinationConfig: AzureKeyVaultPkiSyncConfigSchema, destinationConfig: AzureKeyVaultPkiSyncConfigSchema,
syncOptions: AzureKeyVaultPkiSyncOptionsSchema.optional().default({}), syncOptions: AzureKeyVaultPkiSyncOptionsSchema.optional().default({}),
subscriberId: z.string().optional(), subscriberId: z.string().nullish(),
connectionId: z.string(), connectionId: z.string(),
projectId: z.string().trim().min(1) projectId: z.string().trim().min(1),
certificateIds: z.array(z.string().uuid()).optional()
}); });
export const UpdateAzureKeyVaultPkiSyncSchema = z.object({ export const UpdateAzureKeyVaultPkiSyncSchema = z.object({
@@ -61,7 +63,7 @@ export const UpdateAzureKeyVaultPkiSyncSchema = z.object({
isAutoSyncEnabled: z.boolean().optional(), isAutoSyncEnabled: z.boolean().optional(),
destinationConfig: AzureKeyVaultPkiSyncConfigSchema.optional(), destinationConfig: AzureKeyVaultPkiSyncConfigSchema.optional(),
syncOptions: AzureKeyVaultPkiSyncOptionsSchema.optional(), syncOptions: AzureKeyVaultPkiSyncOptionsSchema.optional(),
subscriberId: z.string().optional(), subscriberId: z.string().nullish(),
connectionId: z.string().optional() connectionId: z.string().optional()
}); });
+40 -6
View File
@@ -4,6 +4,8 @@ import { z, ZodSchema } from "zod";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { BadRequestError } from "@app/lib/errors"; import { BadRequestError } from "@app/lib/errors";
import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal";
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
import { TCertificateSyncDALFactory } from "@app/services/certificate-sync/certificate-sync-dal";
import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { AWS_CERTIFICATE_MANAGER_PKI_SYNC_LIST_OPTION } from "./aws-certificate-manager/aws-certificate-manager-pki-sync-constants"; import { AWS_CERTIFICATE_MANAGER_PKI_SYNC_LIST_OPTION } from "./aws-certificate-manager/aws-certificate-manager-pki-sync-constants";
@@ -184,6 +186,8 @@ export const PkiSyncFns = {
dependencies: { dependencies: {
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "updateById">; appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "updateById">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">; kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
certificateDAL: TCertificateDALFactory;
certificateSyncDAL: TCertificateSyncDALFactory;
} }
): Promise<{ ): Promise<{
uploaded: number; uploaded: number;
@@ -194,17 +198,28 @@ export const PkiSyncFns = {
failedUploads?: Array<{ name: string; error: string }>; failedUploads?: Array<{ name: string; error: string }>;
failedRemovals?: Array<{ name: string; error: string }>; failedRemovals?: Array<{ name: string; error: string }>;
skippedCertificates?: Array<{ name: string; reason: string }>; skippedCertificates?: Array<{ name: string; reason: string }>;
validationErrors?: Array<{ name: string; error: string }>;
}; };
}> => { }> => {
switch (pkiSync.destination) { switch (pkiSync.destination) {
case PkiSync.AzureKeyVault: { case PkiSync.AzureKeyVault: {
checkPkiSyncDestination(pkiSync, PkiSync.AzureKeyVault); checkPkiSyncDestination(pkiSync, PkiSync.AzureKeyVault);
const azureKeyVaultPkiSync = azureKeyVaultPkiSyncFactory(dependencies); const azureKeyVaultPkiSync = azureKeyVaultPkiSyncFactory({
appConnectionDAL: dependencies.appConnectionDAL,
kmsService: dependencies.kmsService,
certificateDAL: dependencies.certificateDAL,
certificateSyncDAL: dependencies.certificateSyncDAL
});
return azureKeyVaultPkiSync.syncCertificates(pkiSync, certificateMap); return azureKeyVaultPkiSync.syncCertificates(pkiSync, certificateMap);
} }
case PkiSync.AwsCertificateManager: { case PkiSync.AwsCertificateManager: {
checkPkiSyncDestination(pkiSync, PkiSync.AwsCertificateManager); checkPkiSyncDestination(pkiSync, PkiSync.AwsCertificateManager);
const awsCertificateManagerPkiSync = awsCertificateManagerPkiSyncFactory(dependencies); const awsCertificateManagerPkiSync = awsCertificateManagerPkiSyncFactory({
appConnectionDAL: dependencies.appConnectionDAL,
kmsService: dependencies.kmsService,
certificateDAL: dependencies.certificateDAL,
certificateSyncDAL: dependencies.certificateSyncDAL
});
return awsCertificateManagerPkiSync.syncCertificates(pkiSync, certificateMap); return awsCertificateManagerPkiSync.syncCertificates(pkiSync, certificateMap);
} }
default: default:
@@ -218,19 +233,38 @@ export const PkiSyncFns = {
dependencies: { dependencies: {
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "updateById">; appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "updateById">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">; kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
certificateSyncDAL: TCertificateSyncDALFactory;
certificateDAL: TCertificateDALFactory;
certificateMap: TCertificateMap;
} }
): Promise<void> => { ): Promise<void> => {
switch (pkiSync.destination) { switch (pkiSync.destination) {
case PkiSync.AzureKeyVault: { case PkiSync.AzureKeyVault: {
checkPkiSyncDestination(pkiSync, PkiSync.AzureKeyVault); checkPkiSyncDestination(pkiSync, PkiSync.AzureKeyVault);
const azureKeyVaultPkiSync = azureKeyVaultPkiSyncFactory(dependencies); const azureKeyVaultPkiSync = azureKeyVaultPkiSyncFactory({
await azureKeyVaultPkiSync.removeCertificates(pkiSync, certificateNames); appConnectionDAL: dependencies.appConnectionDAL,
kmsService: dependencies.kmsService,
certificateDAL: dependencies.certificateDAL,
certificateSyncDAL: dependencies.certificateSyncDAL
});
await azureKeyVaultPkiSync.removeCertificates(pkiSync, certificateNames, {
certificateSyncDAL: dependencies.certificateSyncDAL,
certificateMap: dependencies.certificateMap
});
break; break;
} }
case PkiSync.AwsCertificateManager: { case PkiSync.AwsCertificateManager: {
checkPkiSyncDestination(pkiSync, PkiSync.AwsCertificateManager); checkPkiSyncDestination(pkiSync, PkiSync.AwsCertificateManager);
const awsCertificateManagerPkiSync = awsCertificateManagerPkiSyncFactory(dependencies); const awsCertificateManagerPkiSync = awsCertificateManagerPkiSyncFactory({
await awsCertificateManagerPkiSync.removeCertificates(pkiSync, certificateNames); appConnectionDAL: dependencies.appConnectionDAL,
kmsService: dependencies.kmsService,
certificateDAL: dependencies.certificateDAL,
certificateSyncDAL: dependencies.certificateSyncDAL
});
await awsCertificateManagerPkiSync.removeCertificates(pkiSync, certificateNames, {
certificateSyncDAL: dependencies.certificateSyncDAL,
certificateMap: dependencies.certificateMap
});
break; break;
} }
default: default:
+153 -31
View File
@@ -5,6 +5,7 @@ import { AxiosError } from "axios";
import { Job } from "bullmq"; import { Job } from "bullmq";
import handlebars from "handlebars"; import handlebars from "handlebars";
import { TCertificates } from "@app/db/schemas";
import { EventType, TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-types"; import { EventType, TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-types";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore";
@@ -25,6 +26,8 @@ import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-
import { TCertificateAuthorityCertDALFactory } from "../certificate-authority/certificate-authority-cert-dal"; import { TCertificateAuthorityCertDALFactory } from "../certificate-authority/certificate-authority-cert-dal";
import { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal"; import { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal";
import { getCaCertChain } from "../certificate-authority/certificate-authority-fns"; import { getCaCertChain } from "../certificate-authority/certificate-authority-fns";
import { TCertificateSyncDALFactory } from "../certificate-sync/certificate-sync-dal";
import { CertificateSyncStatus } from "../certificate-sync/certificate-sync-enums";
import { TPkiSyncDALFactory } from "./pki-sync-dal"; import { TPkiSyncDALFactory } from "./pki-sync-dal";
import { PkiSyncStatus } from "./pki-sync-enums"; import { PkiSyncStatus } from "./pki-sync-enums";
import { PkiSyncError } from "./pki-sync-errors"; import { PkiSyncError } from "./pki-sync-errors";
@@ -55,14 +58,12 @@ type TPkiSyncQueueFactoryDep = {
auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">; auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">;
projectDAL: TProjectDALFactory; projectDAL: TProjectDALFactory;
licenseService: Pick<TLicenseServiceFactory, "getPlan">; licenseService: Pick<TLicenseServiceFactory, "getPlan">;
certificateDAL: Pick< certificateDAL: TCertificateDALFactory;
TCertificateDALFactory,
"findLatestActiveCertForSubscriber" | "findAllActiveCertsForSubscriber" | "create"
>;
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "findOne" | "create">; certificateBodyDAL: Pick<TCertificateBodyDALFactory, "findOne" | "create">;
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "findOne" | "create">; certificateSecretDAL: Pick<TCertificateSecretDALFactory, "findOne" | "create">;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findById">; certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findById">;
certificateAuthorityCertDAL: Pick<TCertificateAuthorityCertDALFactory, "findById">; certificateAuthorityCertDAL: Pick<TCertificateAuthorityCertDALFactory, "findById">;
certificateSyncDAL: TCertificateSyncDALFactory;
}; };
type PkiSyncActionJob = Job< type PkiSyncActionJob = Job<
@@ -93,7 +94,8 @@ export const pkiSyncQueueFactory = ({
certificateBodyDAL, certificateBodyDAL,
certificateSecretDAL, certificateSecretDAL,
certificateAuthorityDAL, certificateAuthorityDAL,
certificateAuthorityCertDAL certificateAuthorityCertDAL,
certificateSyncDAL
}: TPkiSyncQueueFactoryDep) => { }: TPkiSyncQueueFactoryDep) => {
const appCfg = getConfig(); const appCfg = getConfig();
@@ -153,25 +155,39 @@ export const pkiSyncQueueFactory = ({
const $getInfisicalCertificates = async ( const $getInfisicalCertificates = async (
pkiSync: TPkiSyncRaw | TPkiSyncWithCredentials pkiSync: TPkiSyncRaw | TPkiSyncWithCredentials
): Promise<TCertificateMap> => { ): Promise<{ certificateMap: TCertificateMap; certificateMetadata: Map<string, { id: string; name: string }> }> => {
const { projectId, subscriberId } = pkiSync; const { projectId, subscriberId, id: pkiSyncId } = pkiSync;
if (!subscriberId) {
throw new PkiSyncError({
message: "Invalid PKI Sync source configuration: subscriber no longer exists. Please update source subscriber.",
shouldRetry: false
});
}
const certificateMap: TCertificateMap = {}; const certificateMap: TCertificateMap = {};
const certificateMetadata = new Map<string, { id: string; name: string }>();
let certificates: Array<{ id: string; projectId: string; caCertId?: string | null }> = [];
try { try {
// Get all active certificates for the subscriber (not just the latest) if (subscriberId) {
const certificates = await certificateDAL.findAllActiveCertsForSubscriber({ const subscriberCertificates = await certificateDAL.findAllActiveCertsForSubscriber({
subscriberId subscriberId
}); });
certificates.push(...subscriberCertificates);
}
const certificateIds = await certificateSyncDAL.findCertificateIdsByPkiSyncId(pkiSyncId);
if (certificateIds.length > 0) {
const directCertificates = await certificateDAL.findActiveCertificatesByIds(certificateIds);
certificates.push(...directCertificates);
}
const uniqueCertificates = certificates.filter(
(cert, index, self) => self.findIndex((c) => c.id === cert.id) === index
);
if (uniqueCertificates.length === 0) {
return { certificateMap, certificateMetadata };
}
certificates = uniqueCertificates;
for (const certificate of certificates) { for (const certificate of certificates) {
const cert = certificate as TCertificates;
try { try {
// Get the certificate body and decrypt the certificate data // Get the certificate body and decrypt the certificate data
const certBody = await certificateBodyDAL.findOne({ certId: certificate.id }); const certBody = await certificateBodyDAL.findOne({ certId: certificate.id });
@@ -246,19 +262,45 @@ export const pkiSyncQueueFactory = ({
if (certificateNameSchema) { if (certificateNameSchema) {
const environment = "global"; const environment = "global";
certificateName = handlebars.compile(certificateNameSchema)({ const templateData = {
certificateId: certificate.id.replace(/-/g, ""), certificateId: certificate.id.replace(/-/g, ""),
profileId: cert.profileId?.replace(/-/g, "") || certificate.id.replace(/-/g, ""),
commonName: cert.commonName || "",
friendlyName: cert.friendlyName || "",
environment environment
}); };
certificateName = handlebars.compile(certificateNameSchema)(templateData);
} else { } else {
certificateName = `Infisical-${certificate.id.replace(/-/g, "")}`; const stableId = cert.profileId
? `${cert.profileId.replace(/-/g, "")}-${(cert.commonName || "").replace(/[^a-zA-Z0-9]/g, "")}`
: certificate.id.replace(/-/g, "");
certificateName = `Infisical-${stableId}`;
}
const alternativeNames: string[] = [];
const legacyName = `Infisical-${certificate.id.replace(/-/g, "")}`;
if (legacyName !== certificateName) {
alternativeNames.push(legacyName);
}
if (cert.renewedFromCertificateId) {
const originalLegacyName = `Infisical-${cert.renewedFromCertificateId.replace(/-/g, "")}`;
alternativeNames.push(originalLegacyName);
} }
certificateMap[certificateName] = { certificateMap[certificateName] = {
cert: certificatePem, cert: certificatePem,
privateKey: certPrivateKey || "", privateKey: certPrivateKey || "",
certificateChain certificateChain,
alternativeNames,
certificateId: certificate.id
}; };
certificateMetadata.set(certificateName, {
id: certificate.id,
name: certificateName
});
} else { } else {
logger.warn({ certificateId: certificate.id, subscriberId }, "Certificate body not found for certificate"); logger.warn({ certificateId: certificate.id, subscriberId }, "Certificate body not found for certificate");
} }
@@ -281,7 +323,7 @@ export const pkiSyncQueueFactory = ({
}); });
} }
return certificateMap; return { certificateMap, certificateMetadata };
}; };
const queuePkiSyncSyncCertificatesById = async (payload: TQueuePkiSyncSyncCertificatesByIdDTO) => const queuePkiSyncSyncCertificatesById = async (payload: TQueuePkiSyncSyncCertificatesByIdDTO) =>
@@ -348,12 +390,17 @@ export const pkiSyncQueueFactory = ({
try { try {
const { const {
connection: { orgId, encryptedCredentials, projectId: appConnectionProjectId } connection: { id: connectionId, orgId, projectId: appConnectionProjectId }
} = pkiSync; } = pkiSync;
const appConnection = await appConnectionDAL.findById(connectionId);
if (!appConnection) {
throw new Error(`App connection not found: ${connectionId}`);
}
const credentials = await decryptAppConnectionCredentials({ const credentials = await decryptAppConnectionCredentials({
orgId, orgId,
encryptedCredentials, encryptedCredentials: appConnection.encryptedCredentials,
kmsService, kmsService,
projectId: appConnectionProjectId projectId: appConnectionProjectId
}); });
@@ -366,11 +413,24 @@ export const pkiSyncQueueFactory = ({
} }
} as TPkiSyncWithCredentials; } as TPkiSyncWithCredentials;
const certificateMap = await $getInfisicalCertificates(pkiSync); const { certificateMap, certificateMetadata } = await $getInfisicalCertificates(pkiSync);
const statusUpdates = Array.from(certificateMetadata.entries()).map(([, metadata]) => ({
pkiSyncId: pkiSync.id,
certificateId: metadata.id,
status: CertificateSyncStatus.Running,
message: "Syncing certificate to destination"
}));
if (statusUpdates.length > 0) {
await certificateSyncDAL.bulkUpdateSyncStatus(statusUpdates);
}
const syncResult = await PkiSyncFns.syncCertificates(pkiSyncWithCredentials, certificateMap, { const syncResult = await PkiSyncFns.syncCertificates(pkiSyncWithCredentials, certificateMap, {
appConnectionDAL, appConnectionDAL,
kmsService kmsService,
certificateDAL,
certificateSyncDAL
}); });
logger.info( logger.info(
@@ -384,6 +444,60 @@ export const pkiSyncQueueFactory = ({
"PKI sync operation completed with certificate cleanup" "PKI sync operation completed with certificate cleanup"
); );
const postSyncUpdates: Array<{
pkiSyncId: string;
certificateId: string;
status: string;
message?: string;
}> = [];
for (const [, metadata] of certificateMetadata.entries()) {
postSyncUpdates.push({
pkiSyncId: pkiSync.id,
certificateId: metadata.id,
status: CertificateSyncStatus.Succeeded,
message: "Certificate successfully synced to destination"
});
}
if (syncResult.details?.validationErrors) {
for (const validationError of syncResult.details.validationErrors) {
const metadata = certificateMetadata.get(validationError.name);
if (metadata) {
const updateIndex = postSyncUpdates.findIndex((u) => u.certificateId === metadata.id);
if (updateIndex >= 0) {
postSyncUpdates[updateIndex] = {
pkiSyncId: pkiSync.id,
certificateId: metadata.id,
status: CertificateSyncStatus.Failed,
message: `${validationError.error}`
};
}
}
}
}
if (syncResult.details?.failedUploads) {
for (const failure of syncResult.details.failedUploads) {
const metadata = certificateMetadata.get(failure.name);
if (metadata) {
const updateIndex = postSyncUpdates.findIndex((u) => u.certificateId === metadata.id);
if (updateIndex >= 0) {
postSyncUpdates[updateIndex] = {
pkiSyncId: pkiSync.id,
certificateId: metadata.id,
status: CertificateSyncStatus.Failed,
message: `Failed to sync certificate: ${failure.error}`
};
}
}
}
}
if (postSyncUpdates.length > 0) {
await certificateSyncDAL.bulkUpdateSyncStatus(postSyncUpdates);
}
isSynced = true; isSynced = true;
} catch (err) { } catch (err) {
logger.error( logger.error(
@@ -550,17 +664,22 @@ export const pkiSyncQueueFactory = ({
try { try {
const { const {
connection: { orgId, encryptedCredentials, projectId: appConnectionProjectId } connection: { id: connectionId, orgId, projectId: appConnectionProjectId }
} = pkiSync; } = pkiSync;
const appConnection = await appConnectionDAL.findById(connectionId);
if (!appConnection) {
throw new Error(`App connection not found: ${connectionId}`);
}
const credentials = await decryptAppConnectionCredentials({ const credentials = await decryptAppConnectionCredentials({
orgId, orgId,
encryptedCredentials, encryptedCredentials: appConnection.encryptedCredentials,
kmsService, kmsService,
projectId: appConnectionProjectId projectId: appConnectionProjectId
}); });
const certificateMap = await $getInfisicalCertificates(pkiSync); const { certificateMap } = await $getInfisicalCertificates(pkiSync);
await PkiSyncFns.removeCertificates( await PkiSyncFns.removeCertificates(
{ {
@@ -573,7 +692,10 @@ export const pkiSyncQueueFactory = ({
Object.keys(certificateMap), Object.keys(certificateMap),
{ {
appConnectionDAL, appConnectionDAL,
kmsService kmsService,
certificateSyncDAL,
certificateDAL,
certificateMap
} }
); );
@@ -1,6 +1,6 @@
import { ForbiddenError, subject } from "@casl/ability"; import { ForbiddenError, subject } from "@casl/ability";
import { ActionProjectType } from "@app/db/schemas"; import { ActionProjectType, TCertificateSyncs } from "@app/db/schemas";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { ProjectPermissionPkiSyncActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { ProjectPermissionPkiSyncActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
@@ -10,17 +10,24 @@ import { AppConnection } from "@app/services/app-connection/app-connection-enums
import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service";
import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal"; import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal";
import { TCertificateDALFactory } from "../certificate/certificate-dal";
import { TCertificateSyncDALFactory } from "../certificate-sync/certificate-sync-dal";
import { CertificateSyncStatus } from "../certificate-sync/certificate-sync-enums";
import { TPkiSyncDALFactory } from "./pki-sync-dal"; import { TPkiSyncDALFactory } from "./pki-sync-dal";
import { PkiSync, PkiSyncStatus } from "./pki-sync-enums"; import { PkiSync, PkiSyncStatus } from "./pki-sync-enums";
import { enterprisePkiSyncCheck, getPkiSyncProviderCapabilities, listPkiSyncOptions } from "./pki-sync-fns"; import { enterprisePkiSyncCheck, getPkiSyncProviderCapabilities, listPkiSyncOptions } from "./pki-sync-fns";
import { PKI_SYNC_CONNECTION_MAP, PKI_SYNC_NAME_MAP } from "./pki-sync-maps"; import { PKI_SYNC_CONNECTION_MAP, PKI_SYNC_NAME_MAP } from "./pki-sync-maps";
import { TPkiSyncQueueFactory } from "./pki-sync-queue"; import { TPkiSyncQueueFactory } from "./pki-sync-queue";
import { import {
TAddCertificatesToPkiSyncDTO,
TCreatePkiSyncDTO, TCreatePkiSyncDTO,
TDeletePkiSyncDTO, TDeletePkiSyncDTO,
TFindPkiSyncByIdDTO, TFindPkiSyncByIdDTO,
TListPkiSyncCertificatesDTO,
TListPkiSyncsByProjectId, TListPkiSyncsByProjectId,
TPkiSync, TPkiSync,
TPkiSyncCertificate,
TRemoveCertificatesFromPkiSyncDTO,
TTriggerPkiSyncImportCertificatesByIdDTO, TTriggerPkiSyncImportCertificatesByIdDTO,
TTriggerPkiSyncRemoveCertificatesByIdDTO, TTriggerPkiSyncRemoveCertificatesByIdDTO,
TTriggerPkiSyncSyncCertificatesByIdDTO, TTriggerPkiSyncSyncCertificatesByIdDTO,
@@ -42,6 +49,17 @@ type TPkiSyncServiceFactoryDep = {
TPkiSyncDALFactory, TPkiSyncDALFactory,
"findById" | "findByProjectIdWithSubscribers" | "findByNameAndProjectId" | "create" | "updateById" | "deleteById" "findById" | "findByProjectIdWithSubscribers" | "findByNameAndProjectId" | "create" | "updateById" | "deleteById"
>; >;
certificateDAL: Pick<TCertificateDALFactory, "findActiveCertificatesByIds">;
certificateSyncDAL: Pick<
TCertificateSyncDALFactory,
| "findByPkiSyncId"
| "findByCertificateId"
| "findCertificateIdsByPkiSyncId"
| "addCertificates"
| "removeCertificates"
| "removeAllCertificatesFromSync"
| "findWithDetails"
>;
pkiSubscriberDAL: Pick<TPkiSubscriberDALFactory, "findById">; pkiSubscriberDAL: Pick<TPkiSubscriberDALFactory, "findById">;
appConnectionService: Pick<TAppConnectionServiceFactory, "connectAppConnectionById">; appConnectionService: Pick<TAppConnectionServiceFactory, "connectAppConnectionById">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">; permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
@@ -56,12 +74,41 @@ export type TPkiSyncServiceFactory = ReturnType<typeof pkiSyncServiceFactory>;
export const pkiSyncServiceFactory = ({ export const pkiSyncServiceFactory = ({
pkiSyncDAL, pkiSyncDAL,
certificateDAL,
certificateSyncDAL,
pkiSubscriberDAL, pkiSubscriberDAL,
appConnectionService, appConnectionService,
permissionService, permissionService,
licenseService, licenseService,
pkiSyncQueue pkiSyncQueue
}: TPkiSyncServiceFactoryDep) => { }: TPkiSyncServiceFactoryDep) => {
const validateCertificatesProjectOwnership = async (certificateIds: string[], expectedProjectId: string) => {
if (certificateIds.length === 0) return;
const certificates = await certificateDAL.findActiveCertificatesByIds(certificateIds);
if (certificates.length !== certificateIds.length) {
const foundIds = certificates.map((cert) => cert.id);
const missingIds = certificateIds.filter((id) => !foundIds.includes(id));
throw new NotFoundError({
message: `Certificates not found or not active: ${missingIds.join(", ")}`
});
}
const invalidProjectCertificates = certificates.filter((cert) => cert.projectId !== expectedProjectId);
if (invalidProjectCertificates.length > 0) {
throw new BadRequestError({
message: `Certificates do not belong to the same project: ${invalidProjectCertificates.map((cert) => cert.id).join(", ")}`
});
}
const invalidRenewedCertificates = certificates.filter((cert) => cert.renewedByCertificateId);
if (invalidRenewedCertificates.length > 0) {
throw new BadRequestError({
message: `Cannot add renewed certificates to PKI sync: ${invalidRenewedCertificates.map((cert) => cert.id).join(", ")}`
});
}
};
const createPkiSync = async ( const createPkiSync = async (
{ {
name, name,
@@ -72,7 +119,8 @@ export const pkiSyncServiceFactory = ({
syncOptions = {}, syncOptions = {},
subscriberId, subscriberId,
connectionId, connectionId,
projectId projectId,
certificateIds = []
}: Omit<TCreatePkiSyncDTO, "auditLogInfo">, }: Omit<TCreatePkiSyncDTO, "auditLogInfo">,
actor: OrgServiceActor actor: OrgServiceActor
): Promise<TPkiSync> => { ): Promise<TPkiSync> => {
@@ -114,6 +162,10 @@ export const pkiSyncServiceFactory = ({
...syncOptions ...syncOptions
}; };
if (certificateIds.length > 0) {
await validateCertificatesProjectOwnership(certificateIds, projectId);
}
try { try {
const pkiSync = await pkiSyncDAL.create({ const pkiSync = await pkiSyncDAL.create({
name, name,
@@ -128,6 +180,13 @@ export const pkiSyncServiceFactory = ({
...(isAutoSyncEnabled && { syncStatus: PkiSyncStatus.Pending }) ...(isAutoSyncEnabled && { syncStatus: PkiSyncStatus.Pending })
}); });
if (certificateIds.length > 0) {
await certificateSyncDAL.addCertificates(
pkiSync.id,
certificateIds.map((id) => ({ certificateId: id }))
);
}
if (pkiSync.isAutoSyncEnabled) { if (pkiSync.isAutoSyncEnabled) {
await pkiSyncQueue.queuePkiSyncSyncCertificatesById({ syncId: pkiSync.id }); await pkiSyncQueue.queuePkiSyncSyncCertificatesById({ syncId: pkiSync.id });
} }
@@ -152,7 +211,8 @@ export const pkiSyncServiceFactory = ({
destinationConfig, destinationConfig,
syncOptions, syncOptions,
subscriberId, subscriberId,
connectionId connectionId,
certificateIds
}: Omit<TUpdatePkiSyncDTO, "auditLogInfo" | "projectId">, }: Omit<TUpdatePkiSyncDTO, "auditLogInfo" | "projectId">,
actor: OrgServiceActor actor: OrgServiceActor
): Promise<TPkiSync> => { ): Promise<TPkiSync> => {
@@ -221,6 +281,20 @@ export const pkiSyncServiceFactory = ({
}; };
} }
if (certificateIds !== undefined) {
if (certificateIds.length > 0) {
await validateCertificatesProjectOwnership(certificateIds, pkiSync.projectId);
}
await certificateSyncDAL.removeAllCertificatesFromSync(id);
if (certificateIds.length > 0) {
await certificateSyncDAL.addCertificates(
id,
certificateIds.map((certId) => ({ certificateId: certId }))
);
}
}
const updatedPkiSync = await pkiSyncDAL.updateById(id, { const updatedPkiSync = await pkiSyncDAL.updateById(id, {
name, name,
description, description,
@@ -266,7 +340,7 @@ export const pkiSyncServiceFactory = ({
}; };
const listPkiSyncsByProjectId = async ( const listPkiSyncsByProjectId = async (
{ projectId }: TListPkiSyncsByProjectId, { projectId, certificateId }: TListPkiSyncsByProjectId,
actor: OrgServiceActor actor: OrgServiceActor
): Promise<TPkiSync[]> => { ): Promise<TPkiSync[]> => {
const { permission } = await permissionService.getProjectPermission({ const { permission } = await permissionService.getProjectPermission({
@@ -282,6 +356,29 @@ export const pkiSyncServiceFactory = ({
const pkiSyncsWithSubscribers = await pkiSyncDAL.findByProjectIdWithSubscribers(projectId); const pkiSyncsWithSubscribers = await pkiSyncDAL.findByProjectIdWithSubscribers(projectId);
if (certificateId) {
const syncsWithCertificateInfo = await Promise.all(
pkiSyncsWithSubscribers.map(async (sync) => {
try {
const certificateSyncs = await certificateSyncDAL.findByPkiSyncId(sync.id);
const hasCertificate = certificateSyncs.some((certSync) => certSync.certificateId === certificateId);
return {
...sync,
hasCertificate
};
} catch (error) {
return {
...sync,
hasCertificate: false
};
}
})
);
return syncsWithCertificateInfo as TPkiSync[];
}
return pkiSyncsWithSubscribers as TPkiSync[]; return pkiSyncsWithSubscribers as TPkiSync[];
}; };
@@ -433,6 +530,145 @@ export const pkiSyncServiceFactory = ({
return listPkiSyncOptions(); return listPkiSyncOptions();
}; };
const addCertificatesToPkiSync = async (
{ pkiSyncId, certificateIds }: Omit<TAddCertificatesToPkiSyncDTO, "auditLogInfo" | "projectId">,
actor: OrgServiceActor
): Promise<{
addedCertificates: TCertificateSyncs[];
pkiSyncInfo: { projectId: string; destination: string; name: string };
}> => {
const pkiSync = await pkiSyncDAL.findById(pkiSyncId);
if (!pkiSync) throw new NotFoundError({ message: "PKI sync not found" });
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorId: actor.id,
actorAuthMethod: actor.authMethod,
actorOrgId: actor.orgId,
actionProjectType: ActionProjectType.CertificateManager,
projectId: pkiSync.projectId
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionPkiSyncActions.Edit, ProjectPermissionSub.PkiSyncs);
await validateCertificatesProjectOwnership(certificateIds, pkiSync.projectId);
const addedCertificates = await certificateSyncDAL.addCertificates(
pkiSyncId,
certificateIds.map((id) => ({ certificateId: id }))
);
if (pkiSync.isAutoSyncEnabled) {
await pkiSyncQueue.queuePkiSyncSyncCertificatesById({ syncId: pkiSyncId });
}
return {
addedCertificates,
pkiSyncInfo: {
projectId: pkiSync.projectId,
destination: pkiSync.destination,
name: pkiSync.name
}
};
};
const removeCertificatesFromPkiSync = async (
{ pkiSyncId, certificateIds }: Omit<TRemoveCertificatesFromPkiSyncDTO, "auditLogInfo" | "projectId">,
actor: OrgServiceActor
): Promise<{ removedCount: number; pkiSyncInfo: { projectId: string; destination: string; name: string } }> => {
const pkiSync = await pkiSyncDAL.findById(pkiSyncId);
if (!pkiSync) throw new NotFoundError({ message: "PKI sync not found" });
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorId: actor.id,
actorAuthMethod: actor.authMethod,
actorOrgId: actor.orgId,
actionProjectType: ActionProjectType.CertificateManager,
projectId: pkiSync.projectId
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionPkiSyncActions.Edit, ProjectPermissionSub.PkiSyncs);
const removedCount = await certificateSyncDAL.removeCertificates(pkiSyncId, certificateIds);
if (pkiSync.isAutoSyncEnabled) {
await pkiSyncQueue.queuePkiSyncSyncCertificatesById({ syncId: pkiSyncId });
}
return {
removedCount,
pkiSyncInfo: {
projectId: pkiSync.projectId,
destination: pkiSync.destination,
name: pkiSync.name
}
};
};
const listPkiSyncCertificates = async (
{ pkiSyncId, offset = 0, limit = 20 }: Omit<TListPkiSyncCertificatesDTO, "projectId">,
actor: OrgServiceActor
): Promise<{
certificates: TPkiSyncCertificate[];
totalCount: number;
pkiSyncInfo: { projectId: string; destination: string; name: string };
}> => {
const pkiSync = await pkiSyncDAL.findById(pkiSyncId);
if (!pkiSync) throw new NotFoundError({ message: "PKI sync not found" });
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorId: actor.id,
actorAuthMethod: actor.authMethod,
actorOrgId: actor.orgId,
actionProjectType: ActionProjectType.CertificateManager,
projectId: pkiSync.projectId
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionPkiSyncActions.Read, ProjectPermissionSub.PkiSyncs);
const result = await certificateSyncDAL.findWithDetails({
pkiSyncId,
offset,
limit
});
const { certificateDetails, totalCount } = result;
const certificates = certificateDetails.map((detail) => ({
id: detail.id,
pkiSyncId: detail.pkiSyncId,
certificateId: detail.certificateId,
syncStatus: (detail.syncStatus as CertificateSyncStatus) || CertificateSyncStatus.Pending,
lastSyncMessage: detail.lastSyncMessage || undefined,
lastSyncedAt: detail.lastSyncedAt || undefined,
createdAt: detail.createdAt,
updatedAt: detail.updatedAt,
certificateSerialNumber: detail.certificateSerialNumber || undefined,
certificateCommonName: detail.certificateCommonName || undefined,
certificateAltNames: detail.certificateAltNames || undefined,
certificateStatus: detail.certificateStatus || undefined,
certificateNotBefore: detail.certificateNotBefore || undefined,
certificateNotAfter: detail.certificateNotAfter || undefined,
certificateRenewBeforeDays: !detail.certificateRenewedByCertificateId
? detail.certificateRenewBeforeDays || undefined
: undefined,
certificateRenewalError: detail.certificateRenewalError || undefined,
pkiSyncName: detail.pkiSyncName || undefined,
pkiSyncDestination: detail.pkiSyncDestination || undefined
}));
return {
certificates,
totalCount,
pkiSyncInfo: {
projectId: pkiSync.projectId,
destination: pkiSync.destination,
name: pkiSync.name
}
};
};
return { return {
createPkiSync, createPkiSync,
updatePkiSync, updatePkiSync,
@@ -442,6 +678,9 @@ export const pkiSyncServiceFactory = ({
triggerPkiSyncSyncCertificatesById, triggerPkiSyncSyncCertificatesById,
triggerPkiSyncImportCertificatesById, triggerPkiSyncImportCertificatesById,
triggerPkiSyncRemoveCertificatesById, triggerPkiSyncRemoveCertificatesById,
getPkiSyncOptions getPkiSyncOptions,
addCertificatesToPkiSync,
removeCertificatesFromPkiSync,
listPkiSyncCertificates
}; };
}; };
@@ -2,6 +2,7 @@ import { Job } from "bullmq";
import { AuditLogInfo } from "@app/ee/services/audit-log/audit-log-types"; import { AuditLogInfo } from "@app/ee/services/audit-log/audit-log-types";
import { QueueJobs } from "@app/queue"; import { QueueJobs } from "@app/queue";
import { CertificateSyncStatus } from "@app/services/certificate-sync/certificate-sync-enums";
import { ResourceMetadataDTO } from "@app/services/resource-metadata/resource-metadata-schema"; import { ResourceMetadataDTO } from "@app/services/resource-metadata/resource-metadata-schema";
import { TPkiSyncDALFactory } from "./pki-sync-dal"; import { TPkiSyncDALFactory } from "./pki-sync-dal";
@@ -70,7 +71,10 @@ export type TPkiSyncListItem = TPkiSync & {
appConnectionApp: string; appConnectionApp: string;
}; };
export type TCertificateMap = Record<string, { cert: string; privateKey: string; certificateChain?: string }>; export type TCertificateMap = Record<
string,
{ cert: string; privateKey: string; certificateChain?: string; alternativeNames?: string[]; certificateId?: string }
>;
export type TCreatePkiSyncDTO = { export type TCreatePkiSyncDTO = {
name: string; name: string;
@@ -79,9 +83,10 @@ export type TCreatePkiSyncDTO = {
isAutoSyncEnabled?: boolean; isAutoSyncEnabled?: boolean;
destinationConfig: Record<string, unknown>; destinationConfig: Record<string, unknown>;
syncOptions?: Record<string, unknown>; syncOptions?: Record<string, unknown>;
subscriberId?: string; subscriberId?: string | null;
connectionId: string; connectionId: string;
projectId: string; projectId: string;
certificateIds?: string[];
auditLogInfo: AuditLogInfo; auditLogInfo: AuditLogInfo;
resourceMetadata?: ResourceMetadataDTO; resourceMetadata?: ResourceMetadataDTO;
}; };
@@ -94,8 +99,9 @@ export type TUpdatePkiSyncDTO = {
isAutoSyncEnabled?: boolean; isAutoSyncEnabled?: boolean;
destinationConfig?: Record<string, unknown>; destinationConfig?: Record<string, unknown>;
syncOptions?: Record<string, unknown>; syncOptions?: Record<string, unknown>;
subscriberId?: string; subscriberId?: string | null;
connectionId?: string; connectionId?: string;
certificateIds?: string[];
auditLogInfo: AuditLogInfo; auditLogInfo: AuditLogInfo;
resourceMetadata?: ResourceMetadataDTO; resourceMetadata?: ResourceMetadataDTO;
}; };
@@ -108,6 +114,7 @@ export type TDeletePkiSyncDTO = {
export type TListPkiSyncsByProjectId = { export type TListPkiSyncsByProjectId = {
projectId: string; projectId: string;
certificateId?: string;
}; };
export type TFindPkiSyncByIdDTO = { export type TFindPkiSyncByIdDTO = {
@@ -133,6 +140,48 @@ export type TTriggerPkiSyncRemoveCertificatesByIdDTO = {
auditLogInfo: AuditLogInfo; auditLogInfo: AuditLogInfo;
}; };
export type TAddCertificatesToPkiSyncDTO = {
pkiSyncId: string;
certificateIds: string[];
projectId?: string;
auditLogInfo: AuditLogInfo;
};
export type TRemoveCertificatesFromPkiSyncDTO = {
pkiSyncId: string;
certificateIds: string[];
projectId?: string;
auditLogInfo: AuditLogInfo;
};
export type TListPkiSyncCertificatesDTO = {
pkiSyncId: string;
projectId?: string;
offset?: number;
limit?: number;
};
export type TPkiSyncCertificate = {
id: string;
pkiSyncId: string;
certificateId: string;
syncStatus: CertificateSyncStatus;
lastSyncMessage?: string;
lastSyncedAt?: Date;
createdAt: Date;
updatedAt: Date;
certificateSerialNumber?: string;
certificateCommonName?: string;
certificateAltNames?: string;
certificateStatus?: string;
certificateNotBefore?: Date;
certificateNotAfter?: Date;
certificateRenewBeforeDays?: number;
certificateRenewalError?: string;
pkiSyncName?: string;
pkiSyncDestination?: string;
};
export type TPkiSyncRaw = NonNullable<Awaited<ReturnType<TPkiSyncDALFactory["findById"]>>>; export type TPkiSyncRaw = NonNullable<Awaited<ReturnType<TPkiSyncDALFactory["findById"]>>>;
export type TQueuePkiSyncSyncCertificatesByIdDTO = { export type TQueuePkiSyncSyncCertificatesByIdDTO = {
@@ -1,5 +1,8 @@
import { Knex } from "knex";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { TCertificateSyncDALFactory } from "../certificate-sync/certificate-sync-dal";
import { TPkiSyncDALFactory } from "./pki-sync-dal"; import { TPkiSyncDALFactory } from "./pki-sync-dal";
import { TPkiSyncQueueFactory } from "./pki-sync-queue"; import { TPkiSyncQueueFactory } from "./pki-sync-queue";
@@ -25,3 +28,78 @@ export const triggerAutoSyncForSubscriber = async (
logger.error(error, `Failed to trigger auto sync for subscriber ${subscriberId}:`); logger.error(error, `Failed to trigger auto sync for subscriber ${subscriberId}:`);
} }
}; };
export const triggerAutoSyncForCertificate = async (
certificateId: string,
dependencies: {
certificateSyncDAL: Pick<TCertificateSyncDALFactory, "findPkiSyncIdsByCertificateId">;
pkiSyncDAL: Pick<TPkiSyncDALFactory, "find">;
pkiSyncQueue: Pick<TPkiSyncQueueFactory, "queuePkiSyncSyncCertificatesById">;
}
) => {
try {
const pkiSyncIds = await dependencies.certificateSyncDAL.findPkiSyncIdsByCertificateId(certificateId);
if (pkiSyncIds.length === 0) {
return;
}
const allPkiSyncs = await dependencies.pkiSyncDAL.find({
isAutoSyncEnabled: true,
$in: {
id: pkiSyncIds
}
});
const syncPromises = allPkiSyncs.map((pkiSync) =>
dependencies.pkiSyncQueue.queuePkiSyncSyncCertificatesById({ syncId: pkiSync.id })
);
await Promise.all(syncPromises);
} catch (error) {
logger.error(error, `Failed to trigger auto sync for certificate ${certificateId}:`);
}
};
export const addRenewedCertificateToSyncs = async (
oldCertificateId: string,
newCertificateId: string,
dependencies: {
certificateSyncDAL: Pick<
TCertificateSyncDALFactory,
"findPkiSyncIdsByCertificateId" | "addCertificates" | "findByPkiSyncAndCertificate"
>;
},
tx?: Knex
) => {
try {
const pkiSyncIds = await dependencies.certificateSyncDAL.findPkiSyncIdsByCertificateId(oldCertificateId);
if (pkiSyncIds.length === 0) {
return;
}
const addPromises = pkiSyncIds.map(async (pkiSyncId) => {
const oldCertificateRecord = await dependencies.certificateSyncDAL.findByPkiSyncAndCertificate(
pkiSyncId,
oldCertificateId
);
await dependencies.certificateSyncDAL.addCertificates(
pkiSyncId,
[
{
certificateId: newCertificateId,
externalIdentifier: oldCertificateRecord?.externalIdentifier || undefined
}
],
tx
);
});
await Promise.all(addPromises);
logger.info(`Successfully added renewed certificate ${newCertificateId} to PKI sync(s)`);
} catch (error) {
logger.error(error, `Failed to add renewed certificate ${newCertificateId} to syncs:`);
throw error;
}
};
+37 -14
View File
@@ -156,7 +156,14 @@ type TProjectServiceFactoryDep = {
>; >;
pkiSubscriberDAL: Pick<TPkiSubscriberDALFactory, "find">; pkiSubscriberDAL: Pick<TPkiSubscriberDALFactory, "find">;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "find" | "findWithAssociatedCa">; certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "find" | "findWithAssociatedCa">;
certificateDAL: Pick<TCertificateDALFactory, "find" | "countCertificatesInProject" | "findWithPrivateKeyInfo">; certificateDAL: Pick<
TCertificateDALFactory,
| "find"
| "countCertificatesInProject"
| "findWithPrivateKeyInfo"
| "findActiveCertificatesForSync"
| "countActiveCertificatesForSync"
>;
certificateTemplateDAL: Pick<TCertificateTemplateDALFactory, "getCertTemplatesByProjectId">; certificateTemplateDAL: Pick<TCertificateTemplateDALFactory, "getCertTemplatesByProjectId">;
pkiAlertDAL: Pick<TPkiAlertDALFactory, "find">; pkiAlertDAL: Pick<TPkiAlertDALFactory, "find">;
pkiCollectionDAL: Pick<TPkiCollectionDALFactory, "find">; pkiCollectionDAL: Pick<TPkiCollectionDALFactory, "find">;
@@ -929,6 +936,7 @@ export const projectServiceFactory = ({
offset = 0, offset = 0,
friendlyName, friendlyName,
commonName, commonName,
forPkiSync = false,
actorId, actorId,
actorOrgId, actorOrgId,
actorAuthMethod, actorAuthMethod,
@@ -952,20 +960,35 @@ export const projectServiceFactory = ({
ProjectPermissionSub.Certificates ProjectPermissionSub.Certificates
); );
const certificates = await certificateDAL.findWithPrivateKeyInfo( const certificates = forPkiSync
{ ? await certificateDAL.findActiveCertificatesForSync(
projectId, {
...(friendlyName && { friendlyName }), projectId,
...(commonName && { commonName }) ...(friendlyName && { friendlyName }),
}, ...(commonName && { commonName })
{ offset, limit, sort: [["notAfter", "desc"]] } },
); { offset, limit }
)
: await certificateDAL.findWithPrivateKeyInfo(
{
projectId,
...(friendlyName && { friendlyName }),
...(commonName && { commonName })
},
{ offset, limit, sort: [["notAfter", "desc"]] }
);
const count = await certificateDAL.countCertificatesInProject({ const count = forPkiSync
projectId, ? await certificateDAL.countActiveCertificatesForSync({
friendlyName, projectId,
commonName friendlyName,
}); commonName
})
: await certificateDAL.countCertificatesInProject({
projectId,
friendlyName,
commonName
});
return { return {
certificates, certificates,
@@ -142,6 +142,7 @@ export type TListProjectCertsDTO = {
limit: number; limit: number;
friendlyName?: string; friendlyName?: string;
commonName?: string; commonName?: string;
forPkiSync?: boolean;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TListProjectAlertsDTO = TProjectPermission; export type TListProjectAlertsDTO = TProjectPermission;
@@ -31,7 +31,7 @@ This section walks you through the complete end-to-end process of setting up Azu
<Step title="Create New Azure ADCS Certificate Service CA"> <Step title="Create New Azure ADCS Certificate Service CA">
Click **Create CA** and configure: Click **Create CA** and configure:
- **Type**: Choose **Azure AD Certificate Service** - **Type**: Choose **Active Directory Certificate Services (AD CS)**
- **Name**: Friendly name for this CA (e.g., "Production ADCS CA") - **Name**: Friendly name for this CA (e.g., "Production ADCS CA")
- **App Connection**: Choose your ADCS connection from the dropdown - **App Connection**: Choose your ADCS connection from the dropdown
@@ -0,0 +1,444 @@
import React, { useEffect, useState } from "react";
import { faSearch, faX } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import {
Button,
Checkbox,
EmptyState,
Input,
Modal,
ModalContent,
Pagination,
Table,
TableContainer,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { useProject } from "@app/context";
import {
CertStatus,
useAddCertificatesToPkiSync,
useListPkiSyncCertificates,
useRemoveCertificatesFromPkiSync
} from "@app/hooks/api";
import { TPkiSync } from "@app/hooks/api/pkiSyncs";
import { useListWorkspaceCertificates } from "@app/hooks/api/projects";
type Props = {
isOpen: boolean;
onClose: () => void;
pkiSync?: TPkiSync;
onCertificatesUpdated?: () => void;
selectedCertificateIds?: string[];
onCertificateSelectionChange?: (certificateIds: string[]) => void;
title?: string;
subtitle?: string;
saveButtonText?: string;
};
export const CertificateManagementModal = ({
isOpen,
onClose,
pkiSync,
onCertificatesUpdated,
selectedCertificateIds,
onCertificateSelectionChange,
title = "Manage Certificate Sync",
subtitle = "Select which certificates should be synced.",
saveButtonText = "Save Changes"
}: Props) => {
const { currentProject } = useProject();
const [currentPage, setCurrentPage] = useState(1);
const [searchTerm, setSearchTerm] = useState("");
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
const pageSize = 10;
const isCreateMode = !pkiSync;
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedSearchTerm(searchTerm);
setCurrentPage(1);
}, 300);
return () => {
clearTimeout(handler);
};
}, [searchTerm]);
const { data } = useListWorkspaceCertificates({
projectId: currentProject?.id || "",
offset: (currentPage - 1) * pageSize,
limit: pageSize,
commonName: debouncedSearchTerm || undefined,
friendlyName: debouncedSearchTerm || undefined,
forPkiSync: true
});
const allCertificates = data?.certificates || [];
const totalCount = data?.totalCount || 0;
const { data: syncData } = useListPkiSyncCertificates(pkiSync?.id || "");
const syncCertificates = syncData?.certificates || [];
const addCertificatesToSync = useAddCertificatesToPkiSync();
const removeCertificatesFromSync = useRemoveCertificatesFromPkiSync();
const syncedCertificateIds = isCreateMode
? selectedCertificateIds || []
: syncCertificates.map((sc) => sc.certificateId);
const totalPages = Math.ceil(totalCount / pageSize);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
React.useEffect(() => {
setSelectedIds(syncedCertificateIds);
}, [JSON.stringify(syncedCertificateIds)]);
const handleToggleSelection = (certId: string) => {
setSelectedIds((prev) =>
prev.includes(certId) ? prev.filter((id) => id !== certId) : [...prev, certId]
);
};
const handleSelectAll = () => {
const currentPageIds = allCertificates.map((cert) => cert.id);
const allCurrentPageSelected = currentPageIds.every((id) => selectedIds.includes(id));
if (allCurrentPageSelected) {
setSelectedIds((prev) => prev.filter((id) => !currentPageIds.includes(id)));
} else {
setSelectedIds((prev) => [...new Set([...prev, ...currentPageIds])]);
}
};
const clearSearch = () => {
setSearchTerm("");
setCurrentPage(1);
};
React.useEffect(() => {
if (isOpen) {
setCurrentPage(1);
setSearchTerm("");
}
}, [isOpen]);
const handleSaveCertificates = async () => {
try {
if (isCreateMode) {
if (onCertificateSelectionChange) {
onCertificateSelectionChange(selectedIds);
onClose();
}
return;
}
if (!pkiSync) return;
const certificatesToAdd = selectedIds.filter((id) => !syncedCertificateIds.includes(id));
const certificatesToRemove = syncedCertificateIds.filter((id) => !selectedIds.includes(id));
const invalidCertificates = certificatesToAdd
.map((id) => allCertificates.find((cert) => cert.id === id))
.filter((cert) => {
if (!cert) return false;
const isExpired = new Date(cert.notAfter) < new Date();
const isRevoked = cert.status === CertStatus.REVOKED;
return isExpired || isRevoked;
});
if (invalidCertificates.length > 0) {
const invalidNames = invalidCertificates.map((cert) => cert?.commonName).join(", ");
createNotification({
text: `Cannot add expired or revoked certificates: ${invalidNames}`,
type: "error"
});
return;
}
const operations = [];
if (certificatesToAdd.length > 0) {
operations.push(
addCertificatesToSync
.mutateAsync({
pkiSyncId: pkiSync.id,
certificateIds: certificatesToAdd
})
.then(() => ({
type: "add",
count: certificatesToAdd.length,
success: true
}))
.catch((error) => ({
type: "add",
count: certificatesToAdd.length,
success: false,
error
}))
);
}
if (certificatesToRemove.length > 0) {
operations.push(
removeCertificatesFromSync
.mutateAsync({
pkiSyncId: pkiSync.id,
certificateIds: certificatesToRemove
})
.then(() => ({
type: "remove",
count: certificatesToRemove.length,
success: true
}))
.catch((error) => ({
type: "remove",
count: certificatesToRemove.length,
success: false,
error
}))
);
}
if (operations.length === 0) {
createNotification({
text: "No changes to save",
type: "info"
});
onClose();
return;
}
const results = await Promise.all(operations);
const failures = results.filter((r) => !r.success);
const successes = results.filter((r) => r.success);
if (failures.length === 0) {
const addCount = successes.find((r) => r.type === "add")?.count || 0;
const removeCount = successes.find((r) => r.type === "remove")?.count || 0;
let message = "Certificate selection updated successfully";
if (addCount > 0 && removeCount > 0) {
message = `Added ${addCount} and removed ${removeCount} certificate(s)`;
} else if (addCount > 0) {
message = `Added ${addCount} certificate(s)`;
} else if (removeCount > 0) {
message = `Removed ${removeCount} certificate(s)`;
}
createNotification({
text: message,
type: "success"
});
if (onCertificatesUpdated) {
onCertificatesUpdated();
}
onClose();
} else {
const partialSuccess = successes.length > 0;
console.error("Certificate sync operation failures:", failures);
createNotification({
text: partialSuccess
? "Some certificate changes failed. Check console for details."
: "Failed to update certificate selection",
type: partialSuccess ? "warning" : "error"
});
if (partialSuccess && onCertificatesUpdated) {
onCertificatesUpdated();
}
}
} catch (error) {
console.error("Unexpected error during certificate sync operation:", error);
createNotification({
text: "An unexpected error occurred while updating certificates",
type: "error"
});
}
};
const isLoading = addCertificatesToSync.isPending || removeCertificatesFromSync.isPending;
return (
<Modal isOpen={isOpen} onOpenChange={(open) => !open && onClose()}>
<ModalContent title={title} subTitle={subtitle} className="max-w-4xl">
<div className="space-y-4">
<div className="space-y-3">
<div className="relative">
<Input
placeholder="Search by common name, serial number, or SAN..."
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setCurrentPage(1);
}}
className="pl-9"
/>
<FontAwesomeIcon
icon={faSearch}
className="absolute top-1/2 left-3 h-3 w-3 -translate-y-1/2 transform text-bunker-300"
/>
{searchTerm && (
<button
type="button"
onClick={clearSearch}
className="absolute top-1/2 right-3 -translate-y-1/2 transform text-bunker-300 hover:text-bunker-100"
>
<FontAwesomeIcon icon={faX} className="h-3 w-3" />
</button>
)}
</div>
</div>
<TableContainer>
<Table>
<THead>
<Tr>
<Th className="w-12">
<Checkbox
id="select-all-certificates"
isChecked={
allCertificates.length > 0 &&
allCertificates.every((cert) => selectedIds.includes(cert.id))
}
onCheckedChange={handleSelectAll}
/>
</Th>
<Th className="w-1/3">SAN / CN</Th>
<Th className="w-1/4">Serial Number</Th>
<Th className="w-1/6">Issued At</Th>
<Th className="w-1/6">Expires At</Th>
</Tr>
</THead>
<TBody>
{allCertificates.map((cert) => {
const isExpired = new Date(cert.notAfter) < new Date();
const isRevoked = cert.status === CertStatus.REVOKED;
const cannotBeAdded = isExpired || isRevoked;
const isAlreadySynced = syncedCertificateIds.includes(cert.id);
let originalDisplayName = "—";
if (cert.altNames && cert.altNames.trim()) {
originalDisplayName = cert.altNames.trim();
} else if (cert.commonName && cert.commonName.trim()) {
originalDisplayName = cert.commonName.trim();
}
let displayName = originalDisplayName;
let isTruncated = false;
if (originalDisplayName.length > 34) {
displayName = `${originalDisplayName.substring(0, 34)}...`;
isTruncated = true;
}
const truncatedSerial =
cert.serialNumber.length > 8
? `${cert.serialNumber.slice(0, 4)}...${cert.serialNumber.slice(-4)}`
: cert.serialNumber;
return (
<Tr
key={cert.id}
className={`cursor-pointer hover:bg-mineshaft-700 ${
cannotBeAdded && !isAlreadySynced ? "opacity-50" : ""
}`}
onClick={() => {
if (!cannotBeAdded || isAlreadySynced) {
handleToggleSelection(cert.id);
}
}}
>
<Td className="max-w-0" onClick={(e) => e.stopPropagation()}>
<Checkbox
id={cert.id}
isChecked={selectedIds.includes(cert.id)}
onCheckedChange={() => {
if (!cannotBeAdded || isAlreadySynced) {
handleToggleSelection(cert.id);
}
}}
isDisabled={cannotBeAdded && !isAlreadySynced}
/>
</Td>
<Td className="max-w-0">
{isTruncated ? (
<Tooltip content={originalDisplayName} className="max-w-lg">
<div className="truncate">{displayName}</div>
</Tooltip>
) : (
<div className="truncate">{displayName}</div>
)}
</Td>
<Td className="max-w-0">
<div
className="font-mono text-xs text-bunker-300"
title={cert.serialNumber}
>
{truncatedSerial}
</div>
</Td>
<Td className="max-w-0">
<span className="text-sm text-bunker-300">
{new Date(cert.notBefore).toLocaleDateString()}
</span>
</Td>
<Td className="max-w-0">
<span
className={`text-sm ${isExpired ? "text-red-400" : "text-bunker-300"}`}
>
{new Date(cert.notAfter).toLocaleDateString()}
</span>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{allCertificates.length === 0 && (
<EmptyState title="No certificates found">
{searchTerm
? "No certificates match your search criteria."
: "No certificates available for sync."}
</EmptyState>
)}
</TableContainer>
{totalPages > 1 && (
<div className="mt-4 flex justify-center">
<Pagination
count={totalCount}
page={currentPage}
perPage={pageSize}
onChangePage={(page: number) => setCurrentPage(page)}
onChangePerPage={() => {}}
/>
</div>
)}
</div>
<div className="mt-6 flex justify-end gap-2">
<Button variant="outline_bg" onClick={onClose}>
Cancel
</Button>
<Button
variant="solid"
colorSchema="primary"
onClick={handleSaveCertificates}
isLoading={isLoading}
>
{saveButtonText}
</Button>
</div>
</ModalContent>
</Modal>
);
};
@@ -11,21 +11,24 @@ type Props = {
isOpen: boolean; isOpen: boolean;
onOpenChange: (isOpen: boolean) => void; onOpenChange: (isOpen: boolean) => void;
selectSync?: PkiSync | null; selectSync?: PkiSync | null;
initialData?: any;
}; };
type ContentProps = { type ContentProps = {
onComplete: (pkiSync: TPkiSync) => void; onComplete: (pkiSync: TPkiSync) => void;
selectedSync: PkiSync | null; selectedSync: PkiSync | null;
setSelectedSync: (selectedSync: PkiSync | null) => void; setSelectedSync: (selectedSync: PkiSync | null) => void;
initialData?: any;
}; };
const Content = ({ onComplete, setSelectedSync, selectedSync }: ContentProps) => { const Content = ({ onComplete, setSelectedSync, selectedSync, initialData }: ContentProps) => {
if (selectedSync) { if (selectedSync) {
return ( return (
<CreatePkiSyncForm <CreatePkiSyncForm
onComplete={onComplete} onComplete={onComplete}
onCancel={() => setSelectedSync(null)} onCancel={() => setSelectedSync(null)}
destination={selectedSync} destination={selectedSync}
initialData={initialData}
/> />
); );
} }
@@ -33,7 +36,12 @@ const Content = ({ onComplete, setSelectedSync, selectedSync }: ContentProps) =>
return <PkiSyncSelect onSelect={setSelectedSync} />; return <PkiSyncSelect onSelect={setSelectedSync} />;
}; };
export const CreatePkiSyncModal = ({ onOpenChange, selectSync = null, ...props }: Props) => { export const CreatePkiSyncModal = ({
onOpenChange,
selectSync = null,
initialData,
...props
}: Props) => {
const [selectedSync, setSelectedSync] = useState<PkiSync | null>(selectSync); const [selectedSync, setSelectedSync] = useState<PkiSync | null>(selectSync);
useEffect(() => { useEffect(() => {
@@ -69,6 +77,7 @@ export const CreatePkiSyncModal = ({ onOpenChange, selectSync = null, ...props }
}} }}
selectedSync={selectedSync} selectedSync={selectedSync}
setSelectedSync={setSelectedSync} setSelectedSync={setSelectedSync}
initialData={initialData}
/> />
</ModalContent> </ModalContent>
</Modal> </Modal>
@@ -13,27 +13,28 @@ import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs";
import { PkiSync, TPkiSync, useCreatePkiSync, usePkiSyncOption } from "@app/hooks/api/pkiSyncs"; import { PkiSync, TPkiSync, useCreatePkiSync, usePkiSyncOption } from "@app/hooks/api/pkiSyncs";
import { PkiSyncFormSchema, TPkiSyncForm } from "./schemas/pki-sync-schema"; import { PkiSyncFormSchema, TPkiSyncForm } from "./schemas/pki-sync-schema";
import { PkiSyncCertificatesFields } from "./PkiSyncCertificatesFields";
import { PkiSyncDestinationFields } from "./PkiSyncDestinationFields"; import { PkiSyncDestinationFields } from "./PkiSyncDestinationFields";
import { PkiSyncDetailsFields } from "./PkiSyncDetailsFields"; import { PkiSyncDetailsFields } from "./PkiSyncDetailsFields";
import { PkiSyncOptionsFields } from "./PkiSyncOptionsFields"; import { PkiSyncOptionsFields } from "./PkiSyncOptionsFields";
import { PkiSyncReviewFields } from "./PkiSyncReviewFields"; import { PkiSyncReviewFields } from "./PkiSyncReviewFields";
import { PkiSyncSourceFields } from "./PkiSyncSourceFields";
type Props = { type Props = {
onComplete: (pkiSync: TPkiSync) => void; onComplete: (pkiSync: TPkiSync) => void;
destination: PkiSync; destination: PkiSync;
onCancel: () => void; onCancel: () => void;
initialData?: any;
}; };
const FORM_TABS: { name: string; key: string; fields: (keyof TPkiSyncForm)[] }[] = [ const FORM_TABS: { name: string; key: string; fields: (keyof TPkiSyncForm)[] }[] = [
{ name: "Source", key: "source", fields: ["subscriberId"] },
{ name: "Destination", key: "destination", fields: ["connection", "destinationConfig"] }, { name: "Destination", key: "destination", fields: ["connection", "destinationConfig"] },
{ name: "Sync Options", key: "options", fields: ["syncOptions"] }, { name: "Sync Options", key: "options", fields: ["syncOptions"] },
{ name: "Details", key: "details", fields: ["name", "description"] }, { name: "Details", key: "details", fields: ["name", "description"] },
{ name: "Certificates", key: "certificates", fields: ["certificateIds"] },
{ name: "Review", key: "review", fields: [] } { name: "Review", key: "review", fields: [] }
]; ];
export const CreatePkiSyncForm = ({ destination, onComplete, onCancel }: Props) => { export const CreatePkiSyncForm = ({ destination, onComplete, onCancel, initialData }: Props) => {
const createPkiSync = useCreatePkiSync(); const createPkiSync = useCreatePkiSync();
const { currentProject } = useProject(); const { currentProject } = useProject();
const { name: destinationName } = PKI_SYNC_MAP[destination]; const { name: destinationName } = PKI_SYNC_MAP[destination];
@@ -49,34 +50,47 @@ export const CreatePkiSyncForm = ({ destination, onComplete, onCancel }: Props)
defaultValues: { defaultValues: {
destination, destination,
isAutoSyncEnabled: false, isAutoSyncEnabled: false,
certificateIds: [],
syncOptions: { syncOptions: {
canImportCertificates: false, canImportCertificates: false,
canRemoveCertificates: false, canRemoveCertificates: false,
preserveArn: true,
certificateNameSchema: syncOption?.defaultCertificateNameSchema certificateNameSchema: syncOption?.defaultCertificateNameSchema
} },
...initialData
} as Partial<TPkiSyncForm>, } as Partial<TPkiSyncForm>,
reValidateMode: "onChange" reValidateMode: "onChange"
}); });
const onSubmit = async ({ connection, destinationConfig, ...formData }: TPkiSyncForm) => { const onSubmit = async ({
connection,
destinationConfig,
certificateIds,
...formData
}: TPkiSyncForm) => {
try { try {
const pkiSync = await createPkiSync.mutateAsync({ const pkiSync = await createPkiSync.mutateAsync({
...formData, ...formData,
connectionId: connection.id, connectionId: connection.id,
projectId: currentProject.id, projectId: currentProject.id,
destinationConfig destinationConfig,
certificateIds: certificateIds || []
}); });
createNotification({ createNotification({
text: `Successfully added ${destinationName} Certificate Sync`, text: `Successfully created ${destinationName} Certificate Sync${
certificateIds && certificateIds.length > 0
? ` with ${certificateIds.length} certificate(s)`
: ""
}`,
type: "success" type: "success"
}); });
onComplete(pkiSync); onComplete(pkiSync);
} catch (err: Error | unknown) { } catch (err: Error | unknown) {
console.error(err); console.error("PKI sync creation failed:", err);
setShowConfirmation(false); setShowConfirmation(false);
createNotification({ createNotification({
title: `Failed to add ${destinationName} Certificate Sync`, title: `Failed to create ${destinationName} Certificate Sync`,
text: err instanceof Error ? err.message : "An unknown error occurred", text: err instanceof Error ? err.message : "An unknown error occurred",
type: "error" type: "error"
}); });
@@ -184,9 +198,6 @@ export const CreatePkiSyncForm = ({ destination, onComplete, onCancel }: Props)
))} ))}
</Tab.List> </Tab.List>
<Tab.Panels> <Tab.Panels>
<Tab.Panel>
<PkiSyncSourceFields />
</Tab.Panel>
<Tab.Panel> <Tab.Panel>
<PkiSyncDestinationFields /> <PkiSyncDestinationFields />
</Tab.Panel> </Tab.Panel>
@@ -200,8 +211,8 @@ export const CreatePkiSyncForm = ({ destination, onComplete, onCancel }: Props)
<FormControl <FormControl
helperText={ helperText={
value value
? "Certificates will automatically be synced when changes occur in the source subscriber." ? "Certificates will automatically be synced when changes occur in the selected certificates."
: "Certificates will not automatically be synced when changes occur in the source subscriber. You can still trigger syncs manually." : "Certificates will not automatically be synced when changes occur. You can still trigger syncs manually."
} }
isError={Boolean(error)} isError={Boolean(error)}
errorText={error?.message} errorText={error?.message}
@@ -223,6 +234,9 @@ export const CreatePkiSyncForm = ({ destination, onComplete, onCancel }: Props)
<Tab.Panel> <Tab.Panel>
<PkiSyncDetailsFields /> <PkiSyncDetailsFields />
</Tab.Panel> </Tab.Panel>
<Tab.Panel>
<PkiSyncCertificatesFields />
</Tab.Panel>
<Tab.Panel> <Tab.Panel>
<PkiSyncReviewFields /> <PkiSyncReviewFields />
</Tab.Panel> </Tab.Panel>
@@ -27,12 +27,16 @@ export const EditPkiSyncForm = ({ pkiSync, fields, onComplete }: Props) => {
const formMethods = useForm<TUpdatePkiSyncForm>({ const formMethods = useForm<TUpdatePkiSyncForm>({
resolver: zodResolver(UpdatePkiSyncFormSchema), resolver: zodResolver(UpdatePkiSyncFormSchema),
defaultValues: { defaultValues: {
...pkiSync, name: pkiSync.name,
destination: pkiSync.destination,
description: pkiSync.description ?? "", description: pkiSync.description ?? "",
connection: { connection: {
id: pkiSync.connectionId, id: pkiSync.connectionId,
name: pkiSync.appConnectionName name: pkiSync.appConnectionName
} },
syncOptions: pkiSync.syncOptions,
destinationConfig: pkiSync.destinationConfig,
isAutoSyncEnabled: pkiSync.isAutoSyncEnabled
} as Partial<TUpdatePkiSyncForm>, } as Partial<TUpdatePkiSyncForm>,
reValidateMode: "onChange" reValidateMode: "onChange"
}); });
@@ -0,0 +1,189 @@
import { useMemo, useState } from "react";
import { Controller, useFormContext } from "react-hook-form";
import { faCertificate, faEdit, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
Button,
EmptyState,
FormControl,
Table,
TableContainer,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { useProject } from "@app/context";
import { CertStatus } from "@app/hooks/api";
import { useListWorkspaceCertificates } from "@app/hooks/api/projects";
import { CertificateManagementModal } from "../CertificateManagementModal";
import { TPkiSyncForm } from "./schemas/pki-sync-schema";
export const PkiSyncCertificatesFields = () => {
const { control, watch, setValue } = useFormContext<TPkiSyncForm>();
const { currentProject } = useProject();
const [isSelectionModalOpen, setIsSelectionModalOpen] = useState(false);
const certificateIds = watch("certificateIds") || [];
const { data, isLoading } = useListWorkspaceCertificates({
projectId: currentProject?.id || "",
offset: 0,
limit: 100,
forPkiSync: true
});
const certificates = data?.certificates || [];
const activeCertificates = useMemo(
() => certificates.filter((cert) => cert.status === CertStatus.ACTIVE),
[certificates]
);
const selectedCertificates = useMemo(
() => activeCertificates.filter((cert) => certificateIds.includes(cert.id)),
[activeCertificates, certificateIds]
);
if (isLoading) {
return (
<div className="flex items-center justify-center py-8">
<div className="text-sm text-bunker-300">Loading certificates...</div>
</div>
);
}
return (
<>
<p className="mb-4 text-sm text-bunker-300">
Select certificates to sync with this integration. Only active certificates can be synced.
You can modify this selection after creating the sync.
</p>
<Controller
control={control}
name="certificateIds"
render={({ field: { value = [], onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<div className="space-y-4">
<Button
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faEdit} />}
onClick={() => setIsSelectionModalOpen(true)}
>
Add Certificates
</Button>
<div className="max-h-64 overflow-y-auto">
<TableContainer>
<Table>
<THead>
<Tr>
<Th className="w-1/3">SAN / CN</Th>
<Th className="w-1/4">Serial Number</Th>
<Th className="w-1/6">Issued At</Th>
<Th className="w-1/6">Expires At</Th>
<Th className="w-12">Remove</Th>
</Tr>
</THead>
<TBody>
{selectedCertificates.map((cert) => {
let originalDisplayName = "—";
if (cert.altNames && cert.altNames.trim()) {
originalDisplayName = cert.altNames.trim();
} else if (cert.commonName && cert.commonName.trim()) {
originalDisplayName = cert.commonName.trim();
}
let displayName = originalDisplayName;
let isTruncated = false;
if (originalDisplayName.length > 34) {
displayName = `${originalDisplayName.substring(0, 34)}...`;
isTruncated = true;
}
const truncatedSerial =
cert.serialNumber.length > 8
? `${cert.serialNumber.slice(0, 4)}...${cert.serialNumber.slice(-4)}`
: cert.serialNumber;
const isExpired = new Date(cert.notAfter) < new Date();
return (
<Tr key={cert.id}>
<Td className="max-w-0">
{isTruncated ? (
<Tooltip content={originalDisplayName} className="max-w-lg">
<div className="truncate">{displayName}</div>
</Tooltip>
) : (
<div className="truncate">{displayName}</div>
)}
</Td>
<Td className="max-w-0">
<div
className="font-mono text-xs text-bunker-300"
title={cert.serialNumber}
>
{truncatedSerial}
</div>
</Td>
<Td className="max-w-0">
<span className="text-sm text-bunker-300">
{new Date(cert.notBefore).toLocaleDateString()}
</span>
</Td>
<Td className="max-w-0">
<span
className={`text-sm ${isExpired ? "text-red-400" : "text-bunker-300"}`}
>
{new Date(cert.notAfter).toLocaleDateString()}
</span>
</Td>
<Td>
<Button
size="xs"
variant="plain"
colorSchema="secondary"
className="pl-5"
aria-label="Remove certificate"
onClick={() => {
const newIds = value.filter((id: string) => id !== cert.id);
onChange(newIds);
}}
>
<FontAwesomeIcon icon={faTrash} />
</Button>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{selectedCertificates.length === 0 && (
<EmptyState title="No certificates selected" icon={faCertificate} />
)}
</TableContainer>
</div>
</div>
</FormControl>
)}
/>
<CertificateManagementModal
isOpen={isSelectionModalOpen}
onClose={() => setIsSelectionModalOpen(false)}
selectedCertificateIds={certificateIds}
onCertificateSelectionChange={(newCertificateIds) => {
setValue("certificateIds", newCertificateIds);
}}
title="Select Certificates for Sync"
subtitle="Choose which certificates you want to include in this sync. You can modify this selection after creating the sync."
saveButtonText="Update Selection"
/>
</>
);
};
@@ -1,14 +1,18 @@
import { Controller, useFormContext } from "react-hook-form"; import { Controller, useFormContext } from "react-hook-form";
import { SingleValue } from "react-select";
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link } from "@tanstack/react-router"; import { useRouterState } from "@tanstack/react-router";
import { AppConnectionOption } from "@app/components/app-connections";
import { FilterableSelect, FormControl } from "@app/components/v2"; import { FilterableSelect, FormControl } from "@app/components/v2";
import { ProjectPermissionSub, useProject, useProjectPermission } from "@app/context"; import { ProjectPermissionSub, useProject, useProjectPermission } from "@app/context";
import { ProjectPermissionAppConnectionActions } from "@app/context/ProjectPermissionContext/types"; import { ProjectPermissionAppConnectionActions } from "@app/context/ProjectPermissionContext/types";
import { APP_CONNECTION_MAP } from "@app/helpers/appConnections"; import { APP_CONNECTION_MAP } from "@app/helpers/appConnections";
import { PKI_SYNC_CONNECTION_MAP } from "@app/helpers/pkiSyncs"; import { PKI_SYNC_CONNECTION_MAP } from "@app/helpers/pkiSyncs";
import { usePopUp } from "@app/hooks";
import { useListAvailableAppConnections } from "@app/hooks/api/appConnections"; import { useListAvailableAppConnections } from "@app/hooks/api/appConnections";
import { AddAppConnectionModal } from "@app/pages/organization/AppConnections/AppConnectionsPage/components";
import { TPkiSyncForm } from "./schemas/pki-sync-schema"; import { TPkiSyncForm } from "./schemas/pki-sync-schema";
@@ -18,12 +22,30 @@ type Props = {
export const PkiSyncConnectionField = ({ onChange: callback }: Props) => { export const PkiSyncConnectionField = ({ onChange: callback }: Props) => {
const { permission } = useProjectPermission(); const { permission } = useProjectPermission();
const { control, watch } = useFormContext<TPkiSyncForm>(); const { control, watch, setValue } = useFormContext<TPkiSyncForm>();
const { currentProject } = useProject();
const { popUp, handlePopUpToggle, handlePopUpOpen } = usePopUp(["addConnection"] as const);
const destination = watch("destination"); const destination = watch("destination");
const app = PKI_SYNC_CONNECTION_MAP[destination]; const app = PKI_SYNC_CONNECTION_MAP[destination];
const { currentProject } = useProject();
const {
location: { pathname }
} = useRouterState();
const getPkiSyncReturnUrl = () => {
if (pathname.includes("selectedTab=secret-syncs")) {
return pathname.replace("selectedTab=secret-syncs", "selectedTab=pki-syncs");
}
if (!pathname.includes("selectedTab=")) {
const separator = pathname.includes("?") ? "&" : "?";
return `${pathname}${separator}selectedTab=pki-syncs`;
}
return pathname;
};
const { data: availableConnections, isPending } = useListAvailableAppConnections( const { data: availableConnections, isPending } = useListAvailableAppConnections(
app, app,
currentProject.id currentProject.id
@@ -47,6 +69,7 @@ export const PkiSyncConnectionField = ({ onChange: callback }: Props) => {
<Controller <Controller
render={({ field: { value, onChange }, fieldState: { error } }) => ( render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl <FormControl
tooltipText="App Connections can be created from the Project Settings page."
isError={Boolean(error)} isError={Boolean(error)}
errorText={error?.message} errorText={error?.message}
label={`${connectionName} Connection`} label={`${connectionName} Connection`}
@@ -54,36 +77,54 @@ export const PkiSyncConnectionField = ({ onChange: callback }: Props) => {
<FilterableSelect <FilterableSelect
value={value} value={value}
onChange={(newValue) => { onChange={(newValue) => {
if ((newValue as SingleValue<{ id: string; name: string }>)?.id === "_create") {
handlePopUpOpen("addConnection");
onChange(null);
const formData = { ...watch(), returnUrl: getPkiSyncReturnUrl() };
localStorage.setItem("pkiSyncFormData", JSON.stringify(formData));
if (callback) callback();
return;
}
onChange(newValue); onChange(newValue);
if (callback) callback(); if (callback) callback();
}} }}
isLoading={isPending} isLoading={isPending}
options={availableConnections} options={[
...(canCreateConnection ? [{ id: "_create", name: "Create Connection" }] : []),
...(availableConnections ?? [])
]}
placeholder="Select connection..." placeholder="Select connection..."
getOptionLabel={(option) => option.name} getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id} getOptionValue={(option) => option.id}
components={{ Option: AppConnectionOption }}
/> />
</FormControl> </FormControl>
)} )}
control={control} control={control}
name="connection" name="connection"
/> />
{availableConnections?.length === 0 && ( {!isPending && !availableConnections?.length && !canCreateConnection && (
<p className="-mt-2.5 mb-2.5 text-xs text-yellow"> <p className="-mt-2.5 mb-2.5 text-xs text-yellow">
<FontAwesomeIcon className="mr-1" size="xs" icon={faInfoCircle} /> <FontAwesomeIcon className="mr-1" size="xs" icon={faInfoCircle} />
{canCreateConnection ? ( You do not have access to any {appName} Connections. Contact an admin to create one.
<>
You do not have access to any {appName} Connections. Create one from the{" "}
<Link to="/organization/app-connections" className="underline">
App Connections
</Link>{" "}
page.
</>
) : (
`You do not have access to any ${appName} Connections. Contact an admin to create one.`
)}
</p> </p>
)} )}
<AddAppConnectionModal
isOpen={popUp.addConnection.isOpen}
onOpenChange={(isOpen) => {
localStorage.removeItem("pkiSyncFormData");
handlePopUpToggle("addConnection", isOpen);
}}
projectType={currentProject.type}
projectId={currentProject.id}
app={app}
onComplete={(connection) => {
if (connection) {
setValue("connection", connection);
}
}}
/>
</> </>
); );
}; };
@@ -71,14 +71,14 @@ export const PkiSyncOptionsFields = ({ destination }: Props) => {
isChecked={value} isChecked={value}
> >
<p> <p>
Enable Certificate Removal{" "} Enable Removal of Active/Revoked Certificates{" "}
<Tooltip <Tooltip
className="max-w-md" className="max-w-md"
content={ content={
<> <>
<p> <p>
When enabled, Infisical will remove certificates from the destination during When enabled, Infisical will remove certificates from the destination during
a sync if they are no longer managed by Infisical. a sync if they are no longer active in Infisical.
</p> </p>
<p className="mt-4"> <p className="mt-4">
Disable this option if you intend to manage some certificates manually Disable this option if you intend to manage some certificates manually
@@ -95,6 +95,94 @@ export const PkiSyncOptionsFields = ({ destination }: Props) => {
)} )}
/> />
{currentDestination === PkiSync.AwsCertificateManager && (
<Controller
control={control}
name="syncOptions.preserveArn"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Switch
className="bg-mineshaft-400/80 shadow-inner data-[state=checked]:bg-green/80"
id="preserve-arn"
thumbClassName="bg-mineshaft-800"
onCheckedChange={onChange}
isChecked={value}
>
<p>
Preserve ARN on Renewal{" "}
<Tooltip
className="max-w-md"
content={
<>
<p>
When enabled, Infisical will replace the contents of existing certificates
while preserving the same ARN during certificate renewal syncs.
</p>
<p className="mt-4">
This allows consuming services like load balancers to continue using the
same ARN without requiring manual updates.
</p>
<p className="mt-4">
When disabled, new certificates will be created with new ARNs, and old
certificates will be removed.
</p>
</>
}
>
<FontAwesomeIcon icon={faQuestionCircle} size="sm" className="ml-1" />
</Tooltip>
</p>
</Switch>
</FormControl>
)}
/>
)}
{currentDestination === PkiSync.AzureKeyVault && (
<Controller
control={control}
name="syncOptions.enableVersioning"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}>
<Switch
className="bg-mineshaft-400/80 shadow-inner data-[state=checked]:bg-green/80"
id="preserve-version"
thumbClassName="bg-mineshaft-800"
onCheckedChange={onChange}
isChecked={value}
>
<p>
Preserve Version on Renewal{" "}
<Tooltip
className="max-w-md"
content={
<>
<p>
When enabled, Infisical will create a new version of the existing
certificate in Azure Key Vault during certificate renewal syncs,
preserving the original certificate name.
</p>
<p className="mt-4">
This allows consuming services to continue using the same certificate name
while automatically using the latest version without requiring manual
updates.
</p>
<p className="mt-4">
When disabled, new certificates will be created with new names, and old
certificates will be removed.
</p>
</>
}
>
<FontAwesomeIcon icon={faQuestionCircle} size="sm" className="ml-1" />
</Tooltip>
</p>
</Switch>
</FormControl>
)}
/>
)}
<Controller <Controller
control={control} control={control}
name="syncOptions.certificateNameSchema" name="syncOptions.certificateNameSchema"
@@ -1,10 +1,20 @@
import { useFormContext } from "react-hook-form"; import { useFormContext } from "react-hook-form";
import { GenericFieldLabel } from "@app/components/v2"; import {
GenericFieldLabel,
Table,
TableContainer,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { Badge } from "@app/components/v3"; import { Badge } from "@app/components/v3";
import { useProject } from "@app/context"; import { useProject } from "@app/context";
import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs"; import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs";
import { useListWorkspacePkiSubscribers } from "@app/hooks/api"; import { useListWorkspaceCertificates } from "@app/hooks/api/projects";
import { TPkiSyncForm } from "./schemas/pki-sync-schema"; import { TPkiSyncForm } from "./schemas/pki-sync-schema";
@@ -12,18 +22,24 @@ export const PkiSyncReviewFields = () => {
const { watch } = useFormContext<TPkiSyncForm>(); const { watch } = useFormContext<TPkiSyncForm>();
const { currentProject } = useProject(); const { currentProject } = useProject();
const { data: pkiSubscribers = [] } = useListWorkspacePkiSubscribers(currentProject?.id || ""); const { data } = useListWorkspaceCertificates({
projectId: currentProject?.id || "",
offset: 0,
limit: 100
});
const getSubscriberName = (subscriberId?: string) => { const certificates = data?.certificates || [];
const subscriber = pkiSubscribers.find((sub) => sub.id === subscriberId);
return subscriber?.name || "Unknown"; const getSelectedCertificates = (certificateIds?: string[]) => {
if (!certificateIds || certificateIds.length === 0) return [];
return certificates.filter((cert) => certificateIds.includes(cert.id));
}; };
const { const {
name, name,
description, description,
connection, connection,
subscriberId, certificateIds,
syncOptions, syncOptions,
destination, destination,
destinationConfig, destinationConfig,
@@ -31,17 +47,79 @@ export const PkiSyncReviewFields = () => {
} = watch(); } = watch();
const destinationName = PKI_SYNC_MAP[destination].name; const destinationName = PKI_SYNC_MAP[destination].name;
const selectedCertificates = getSelectedCertificates(certificateIds);
return ( return (
<div className="mb-4 flex flex-col gap-6"> <div className="mb-4 flex flex-col gap-6">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="w-full border-b border-mineshaft-600"> <div className="w-full border-b border-mineshaft-600">
<span className="text-sm text-mineshaft-300">Source</span> <span className="text-sm text-mineshaft-300">Certificates</span>
</div> </div>
<div className="flex flex-wrap gap-x-8 gap-y-2"> <div className="w-full">
<GenericFieldLabel label="PKI Subscriber"> {selectedCertificates.length === 0 ? (
{getSubscriberName(subscriberId)} <span className="text-bunker-400">No certificates selected</span>
</GenericFieldLabel> ) : (
<TableContainer>
<Table>
<THead>
<Tr>
<Th className="w-1/2">SAN / CN</Th>
<Th className="w-1/4">Serial Number</Th>
<Th className="w-1/4">Expires At</Th>
</Tr>
</THead>
<TBody>
{selectedCertificates.map((cert) => {
let originalDisplayName = "—";
if (cert.altNames && cert.altNames.trim()) {
originalDisplayName = cert.altNames.trim();
} else if (cert.commonName && cert.commonName.trim()) {
originalDisplayName = cert.commonName.trim();
}
let displayName = originalDisplayName;
let isTruncated = false;
if (originalDisplayName.length > 34) {
displayName = `${originalDisplayName.substring(0, 34)}...`;
isTruncated = true;
}
const truncatedSerial =
cert.serialNumber.length > 8
? `${cert.serialNumber.slice(0, 4)}...${cert.serialNumber.slice(-4)}`
: cert.serialNumber;
return (
<Tr key={cert.id}>
<Td className="max-w-0">
{isTruncated ? (
<Tooltip content={originalDisplayName} className="max-w-lg">
<div className="truncate">{displayName}</div>
</Tooltip>
) : (
<div className="truncate">{displayName}</div>
)}
</Td>
<Td className="max-w-0">
<div
className="font-mono text-xs text-bunker-300"
title={cert.serialNumber}
>
{truncatedSerial}
</div>
</Td>
<Td className="max-w-0">
<span className="text-sm text-bunker-300">
{new Date(cert.notAfter).toLocaleDateString()}
</span>
</Td>
</Tr>
);
})}
</TBody>
</Table>
</TableContainer>
)}
</div> </div>
</div> </div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
@@ -62,11 +140,13 @@ export const PkiSyncReviewFields = () => {
<div className="w-full border-b border-mineshaft-600"> <div className="w-full border-b border-mineshaft-600">
<span className="text-sm text-mineshaft-300">Sync Options</span> <span className="text-sm text-mineshaft-300">Sync Options</span>
</div> </div>
<div className="flex flex-wrap gap-x-8 gap-y-2"> <div className="flex flex-wrap gap-x-8 gap-y-3">
<GenericFieldLabel label="Auto-Sync"> <GenericFieldLabel label="Auto-Sync">
<Badge variant={isAutoSyncEnabled ? "success" : "danger"}> <div className="mt-1">
{isAutoSyncEnabled ? "Enabled" : "Disabled"} <Badge variant={isAutoSyncEnabled ? "success" : "danger"}>
</Badge> {isAutoSyncEnabled ? "Enabled" : "Disabled"}
</Badge>
</div>
</GenericFieldLabel> </GenericFieldLabel>
{/* Hidden for now - Import certificates functionality disabled {/* Hidden for now - Import certificates functionality disabled
{syncOptions?.canImportCertificates !== undefined && ( {syncOptions?.canImportCertificates !== undefined && (
@@ -79,9 +159,11 @@ export const PkiSyncReviewFields = () => {
*/} */}
{syncOptions?.canRemoveCertificates !== undefined && ( {syncOptions?.canRemoveCertificates !== undefined && (
<GenericFieldLabel label="Remove Certificates"> <GenericFieldLabel label="Remove Certificates">
<Badge variant={syncOptions.canRemoveCertificates ? "success" : "danger"}> <div className="mt-1">
{syncOptions.canRemoveCertificates ? "Enabled" : "Disabled"} <Badge variant={syncOptions.canRemoveCertificates ? "success" : "danger"}>
</Badge> {syncOptions.canRemoveCertificates ? "Enabled" : "Disabled"}
</Badge>
</div>
</GenericFieldLabel> </GenericFieldLabel>
)} )}
</div> </div>
@@ -7,6 +7,7 @@ import { BasePkiSyncSchema } from "./base-pki-sync-schema";
const AwsCertificateManagerSyncOptionsSchema = z.object({ const AwsCertificateManagerSyncOptionsSchema = z.object({
canImportCertificates: z.boolean().default(false), canImportCertificates: z.boolean().default(false),
canRemoveCertificates: z.boolean().default(false), canRemoveCertificates: z.boolean().default(false),
preserveArn: z.boolean().default(true),
certificateNameSchema: z certificateNameSchema: z
.string() .string()
.optional() .optional()
@@ -4,7 +4,46 @@ import { PkiSync } from "@app/hooks/api/pkiSyncs";
import { BasePkiSyncSchema } from "./base-pki-sync-schema"; import { BasePkiSyncSchema } from "./base-pki-sync-schema";
export const AzureKeyVaultPkiSyncDestinationSchema = BasePkiSyncSchema().merge( const AzureKeyVaultSyncOptionsSchema = z.object({
canImportCertificates: z.boolean().default(false),
canRemoveCertificates: z.boolean().default(true),
enableVersioning: z.boolean().default(true),
certificateNameSchema: z
.string()
.optional()
.refine(
(val) => {
if (!val) return true;
const allowedOptionalPlaceholders = ["{{environment}}"];
const allowedPlaceholdersRegexPart = ["{{certificateId}}", ...allowedOptionalPlaceholders]
.map((p) => p.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&"))
.join("|");
const allowedContentRegex = new RegExp(
`^([a-zA-Z0-9_\\-/]|${allowedPlaceholdersRegexPart})*$`
);
const contentIsValid = allowedContentRegex.test(val);
if (val.trim()) {
const certificateIdRegex = /\{\{certificateId\}\}/;
const certificateIdIsPresent = certificateIdRegex.test(val);
return contentIsValid && certificateIdIsPresent;
}
return contentIsValid;
},
{
message:
"Certificate name schema must include exactly one {{certificateId}} placeholder. It can also include {{environment}} placeholders. Only alphanumeric characters (a-z, A-Z, 0-9), dashes (-), underscores (_), and slashes (/) are allowed besides the placeholders."
}
)
});
export const AzureKeyVaultPkiSyncDestinationSchema = BasePkiSyncSchema(
AzureKeyVaultSyncOptionsSchema
).merge(
z.object({ z.object({
destination: z.literal(PkiSync.AzureKeyVault), destination: z.literal(PkiSync.AzureKeyVault),
destinationConfig: z.object({ destinationConfig: z.object({
@@ -53,7 +53,8 @@ export const BasePkiSyncSchema = <T extends AnyZodObject | undefined = undefined
.max(255, "Name must be less than 255 characters"), .max(255, "Name must be less than 255 characters"),
description: z.string().optional(), description: z.string().optional(),
isAutoSyncEnabled: z.boolean().default(true), isAutoSyncEnabled: z.boolean().default(true),
subscriberId: z.string().min(1, "PKI Subscriber is required"), subscriberId: z.string().nullable().optional(),
certificateIds: z.array(z.string()).optional(),
connection: z.object({ connection: z.object({
id: z.string().uuid("Invalid connection ID format"), id: z.string().uuid("Invalid connection ID format"),
name: z.string().max(255, "Connection name must be less than 255 characters") name: z.string().max(255, "Connection name must be less than 255 characters")
@@ -0,0 +1,100 @@
import { ReactNode } from "react";
import { Tooltip } from "@app/components/v2";
interface CertificateNameData {
altNames?: string | null;
commonName?: string | null;
certificateAltNames?: string | null;
certificateCommonName?: string | null;
}
interface DisplayNameResult {
originalDisplayName: string;
displayName: string;
isTruncated: boolean;
}
/**
* Extracts and formats the display name for a certificate from SAN/CN data
* @param cert - Certificate object with potential altNames/commonName fields
* @param maxLength - Maximum length before truncating (default: 64)
* @param fallback - Fallback text when no name is found (default: "—")
* @returns Object with original name, truncated name, and truncation flag
*/
export const getCertificateDisplayName = (
cert: CertificateNameData,
maxLength: number = 64,
fallback: string = "—"
): DisplayNameResult => {
// Extract original display name - prioritize SAN over CN
let originalDisplayName = fallback;
// Handle different property name variations
const altNames = cert.altNames || cert.certificateAltNames;
const commonName = cert.commonName || cert.certificateCommonName;
if (altNames && altNames.trim()) {
originalDisplayName = altNames.trim();
} else if (commonName && commonName.trim()) {
originalDisplayName = commonName.trim();
}
// Handle truncation
let displayName = originalDisplayName;
let isTruncated = false;
if (originalDisplayName.length > maxLength) {
displayName = `${originalDisplayName.substring(0, maxLength)}...`;
isTruncated = true;
}
return {
originalDisplayName,
displayName,
isTruncated
};
};
/**
* Renders a certificate display name with optional tooltip for truncated names
* @param cert - Certificate object with potential altNames/commonName fields
* @param maxLength - Maximum length before truncating (default: 64)
* @param fallback - Fallback text when no name is found (default: "—")
* @param className - Optional CSS class for the display element
* @param tooltipClassName - Optional CSS class for the tooltip (default: "max-w-lg")
* @returns JSX element with certificate name and optional tooltip
*/
export const CertificateDisplayName = ({
cert,
maxLength = 64,
fallback = "—",
className = "truncate",
tooltipClassName = "max-w-lg"
}: {
cert: CertificateNameData;
maxLength?: number;
fallback?: string;
className?: string;
tooltipClassName?: string;
}): ReactNode => {
const { originalDisplayName, displayName, isTruncated } = getCertificateDisplayName(
cert,
maxLength,
fallback
);
if (isTruncated) {
return (
<Tooltip content={originalDisplayName} className={tooltipClassName}>
<div className={className}>{displayName}</div>
</Tooltip>
);
}
return (
<div className={className} title={originalDisplayName}>
{displayName}
</div>
);
};
-4
View File
@@ -301,10 +301,6 @@ export const ROUTE_PATHS = Object.freeze({
"/projects/cert-management/$projectId/subscribers", "/projects/cert-management/$projectId/subscribers",
"/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/subscribers" "/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/subscribers"
), ),
CertificatesPage: setRoute(
"/projects/cert-management/$projectId/certificates",
"/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/certificates"
),
CertificateAuthoritiesPage: setRoute( CertificateAuthoritiesPage: setRoute(
"/projects/cert-management/$projectId/certificate-authorities", "/projects/cert-management/$projectId/certificate-authorities",
"/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/certificate-authorities" "/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/certificate-authorities"
+7 -1
View File
@@ -152,7 +152,7 @@ export const useCreateCertificate = () => {
}); });
}; };
export const useCreateCertificateV3 = () => { export const useCreateCertificateV3 = (options?: { projectId?: string }) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation<TCreateCertificateV3Response, object, TCreateCertificateV3DTO>({ return useMutation<TCreateCertificateV3Response, object, TCreateCertificateV3DTO>({
mutationFn: async (body) => { mutationFn: async (body) => {
@@ -167,6 +167,12 @@ export const useCreateCertificateV3 = () => {
queryKey: projectKeys.forProjectCertificates(projectSlug) queryKey: projectKeys.forProjectCertificates(projectSlug)
}); });
if (options?.projectId) {
queryClient.invalidateQueries({
queryKey: projectKeys.forProjectCertificates(options.projectId)
});
}
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ["certificate-profiles"] queryKey: ["certificate-profiles"]
}); });
@@ -8,7 +8,6 @@ export {
useGetCertificateProfileById, useGetCertificateProfileById,
useGetCertificateProfileBySlug, useGetCertificateProfileBySlug,
useGetProfileCertificates, useGetProfileCertificates,
useGetProfileMetrics,
useListCertificateProfiles useListCertificateProfiles
} from "./queries"; } from "./queries";
export type * from "./types"; export type * from "./types";
@@ -4,7 +4,6 @@ import { apiRequest } from "@app/config/request";
import { import {
TCertificateProfile, TCertificateProfile,
TCertificateProfileMetrics,
TCertificateProfileWithDetails, TCertificateProfileWithDetails,
TGetCertificateProfileByIdDTO, TGetCertificateProfileByIdDTO,
TGetCertificateProfileBySlugDTO, TGetCertificateProfileBySlugDTO,
@@ -20,7 +19,6 @@ export const certificateProfileKeys = {
limit?: number; limit?: number;
offset?: number; offset?: number;
search?: string; search?: string;
includeMetrics?: boolean;
includeConfigs?: boolean; includeConfigs?: boolean;
enrollmentType?: string; enrollmentType?: string;
expiringDays?: number; expiringDays?: number;
@@ -51,10 +49,8 @@ export const useListCertificateProfiles = ({
limit = 20, limit = 20,
offset = 0, offset = 0,
search, search,
includeMetrics = false,
includeConfigs = false, includeConfigs = false,
enrollmentType, enrollmentType
expiringDays = 7
}: TListCertificateProfilesDTO) => { }: TListCertificateProfilesDTO) => {
return useQuery({ return useQuery({
queryKey: certificateProfileKeys.list({ queryKey: certificateProfileKeys.list({
@@ -62,10 +58,8 @@ export const useListCertificateProfiles = ({
limit, limit,
offset, offset,
search, search,
includeMetrics,
includeConfigs, includeConfigs,
enrollmentType, enrollmentType
expiringDays
}), }),
queryFn: async () => { queryFn: async () => {
const { data } = await apiRequest.get<{ const { data } = await apiRequest.get<{
@@ -77,10 +71,8 @@ export const useListCertificateProfiles = ({
limit, limit,
offset, offset,
search, search,
includeMetrics,
includeConfigs, includeConfigs,
enrollmentType, enrollmentType
expiringDays
} }
}); });
return data; return data;
@@ -145,18 +137,3 @@ export const useGetProfileCertificates = ({
enabled: Boolean(profileId) enabled: Boolean(profileId)
}); });
}; };
export const useGetProfileMetrics = ({ profileId, expiringDays = 7 }: TGetProfileMetricsDTO) => {
return useQuery({
queryKey: certificateProfileKeys.getMetrics(profileId, { expiringDays }),
queryFn: async () => {
const { data } = await apiRequest.get<{
metrics: TCertificateProfileMetrics;
}>(`/api/v1/pki/certificate-profiles/${profileId}/metrics`, {
params: { expiringDays }
});
return data.metrics;
},
enabled: Boolean(profileId)
});
};
@@ -10,7 +10,6 @@ export type TCertificateProfile = {
apiConfigId?: string; apiConfigId?: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
metrics?: TCertificateProfileMetrics;
}; };
export type TCertificateProfileWithDetails = TCertificateProfile & { export type TCertificateProfileWithDetails = TCertificateProfile & {
@@ -81,10 +80,8 @@ export type TListCertificateProfilesDTO = {
limit?: number; limit?: number;
offset?: number; offset?: number;
search?: string; search?: string;
includeMetrics?: boolean;
includeConfigs?: boolean; includeConfigs?: boolean;
enrollmentType?: "api" | "est"; enrollmentType?: "api" | "est";
expiringDays?: number;
}; };
export type TGetCertificateProfileByIdDTO = { export type TGetCertificateProfileByIdDTO = {
@@ -96,15 +93,6 @@ export type TGetCertificateProfileBySlugDTO = {
slug: string; slug: string;
}; };
export type TCertificateProfileMetrics = {
profileId: string;
totalCertificates: number;
activeCertificates: number;
expiredCertificates: number;
expiringCertificates: number;
revokedCertificates: number;
};
export type TProfileCertificate = { export type TProfileCertificate = {
id: string; id: string;
serialNumber: string; serialNumber: string;
@@ -126,5 +114,4 @@ export type TGetProfileCertificatesDTO = {
export type TGetProfileMetricsDTO = { export type TGetProfileMetricsDTO = {
profileId: string; profileId: string;
expiringDays?: number;
}; };
@@ -1,3 +1,4 @@
export { CertStatus } from "./enums";
export { export {
useDeleteCert, useDeleteCert,
useImportCertificate, useImportCertificate,
@@ -9,6 +9,7 @@ export type TCertificate = {
friendlyName: string; friendlyName: string;
commonName: string; commonName: string;
subjectAltNames: string; subjectAltNames: string;
altNames?: string;
serialNumber: string; serialNumber: string;
notBefore: string; notBefore: string;
notAfter: string; notAfter: string;
+1
View File
@@ -28,6 +28,7 @@ export * from "./organization";
export * from "./pkiAlerts"; export * from "./pkiAlerts";
export * from "./pkiCollections"; export * from "./pkiCollections";
export * from "./pkiSubscriber"; export * from "./pkiSubscriber";
export * from "./pkiSyncs";
export * from "./projects"; export * from "./projects";
export * from "./projectUserAdditionalPrivilege"; export * from "./projectUserAdditionalPrivilege";
export * from "./rateLimit"; export * from "./rateLimit";
+7
View File
@@ -9,3 +9,10 @@ export enum PkiSyncStatus {
Succeeded = "succeeded", Succeeded = "succeeded",
Failed = "failed" Failed = "failed"
} }
export enum CertificateSyncStatus {
Pending = "pending",
Syncing = "syncing",
Succeeded = "succeeded",
Failed = "failed"
}
@@ -198,3 +198,47 @@ export const useTriggerPkiSyncRemoveCertificates = () => {
} }
}); });
}; };
export const useAddCertificatesToPkiSync = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
pkiSyncId,
certificateIds
}: {
pkiSyncId: string;
certificateIds: string[];
}) => {
const { data } = await apiRequest.post(`/api/v1/pki/syncs/${pkiSyncId}/certificates`, {
certificateIds
});
return data;
},
onSuccess: (_, { pkiSyncId }) => {
queryClient.invalidateQueries({ queryKey: pkiSyncKeys.certificates(pkiSyncId) });
}
});
};
export const useRemoveCertificatesFromPkiSync = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
pkiSyncId,
certificateIds
}: {
pkiSyncId: string;
certificateIds: string[];
}) => {
const { data } = await apiRequest.delete(`/api/v1/pki/syncs/${pkiSyncId}/certificates`, {
data: { certificateIds }
});
return data;
},
onSuccess: (_, { pkiSyncId }) => {
queryClient.invalidateQueries({ queryKey: pkiSyncKeys.certificates(pkiSyncId) });
}
});
};
+71 -4
View File
@@ -2,14 +2,25 @@ import { useQuery, UseQueryOptions } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
import { PkiSync, TPkiSyncOption } from "@app/hooks/api/pkiSyncs"; import { PkiSync, TPkiSyncOption } from "@app/hooks/api/pkiSyncs";
import { TListPkiSyncOptions, TListPkiSyncs, TPkiSync } from "@app/hooks/api/pkiSyncs/types"; import {
TListPkiSyncOptions,
TListPkiSyncs,
TPkiSync,
TPkiSyncCertificate
} from "@app/hooks/api/pkiSyncs/types";
export const pkiSyncKeys = { export const pkiSyncKeys = {
all: ["pki-sync"] as const, all: ["pki-sync"] as const,
options: () => [...pkiSyncKeys.all, "options"] as const, options: () => [...pkiSyncKeys.all, "options"] as const,
list: (projectId: string) => [...pkiSyncKeys.all, "list", projectId] as const, list: (projectId: string) => [...pkiSyncKeys.all, "list", projectId] as const,
listWithCertificate: (projectId: string, certificateId: string) =>
[...pkiSyncKeys.all, "list", projectId, "with-certificate", certificateId] as const,
byId: (syncId: string, projectId: string) => byId: (syncId: string, projectId: string) =>
[...pkiSyncKeys.all, "by-id", syncId, projectId] as const [...pkiSyncKeys.all, "by-id", syncId, projectId] as const,
certificates: (syncId: string, pagination?: { offset: number; limit: number }) =>
pagination
? ([...pkiSyncKeys.all, "certificates", syncId, pagination] as const)
: ([...pkiSyncKeys.all, "certificates", syncId] as const)
}; };
export const usePkiSyncOptions = ( export const usePkiSyncOptions = (
@@ -41,9 +52,14 @@ export const usePkiSyncOption = (destination: PkiSync) => {
return { syncOption, isPending }; return { syncOption, isPending };
}; };
export const fetchPkiSyncsByProjectId = async (projectId: string) => { export const fetchPkiSyncsByProjectId = async (projectId: string, certificateId?: string) => {
const params: { projectId: string; certificateId?: string } = { projectId };
if (certificateId) {
params.certificateId = certificateId;
}
const { data } = await apiRequest.get<TListPkiSyncs>("/api/v1/pki/syncs", { const { data } = await apiRequest.get<TListPkiSyncs>("/api/v1/pki/syncs", {
params: { projectId } params
}); });
return data.pkiSyncs; return data.pkiSyncs;
@@ -63,6 +79,27 @@ export const useListPkiSyncs = (
}); });
}; };
export const useListPkiSyncsWithCertificate = (
projectId: string,
certificateId: string,
options?: Omit<
UseQueryOptions<
TPkiSync[],
unknown,
TPkiSync[],
ReturnType<typeof pkiSyncKeys.listWithCertificate>
>,
"queryKey" | "queryFn"
>
) => {
return useQuery({
queryKey: pkiSyncKeys.listWithCertificate(projectId, certificateId),
queryFn: () => fetchPkiSyncsByProjectId(projectId, certificateId),
enabled: !!projectId && !!certificateId,
...options
});
};
export const useGetPkiSync = ( export const useGetPkiSync = (
{ syncId, projectId }: { syncId: string; projectId: string }, { syncId, projectId }: { syncId: string; projectId: string },
options?: Omit< options?: Omit<
@@ -82,3 +119,33 @@ export const useGetPkiSync = (
...options ...options
}); });
}; };
export const useListPkiSyncCertificates = (
syncId: string,
pagination?: { offset?: number; limit?: number },
options?: Omit<
UseQueryOptions<
{ certificates: TPkiSyncCertificate[]; totalCount: number },
unknown,
{ certificates: TPkiSyncCertificate[]; totalCount: number },
ReturnType<typeof pkiSyncKeys.certificates>
>,
"queryKey" | "queryFn"
>
) => {
const { offset = 0, limit = 20 } = pagination || {};
return useQuery({
queryKey: pkiSyncKeys.certificates(syncId, { offset, limit }),
queryFn: async () => {
const { data } = await apiRequest.get(`/api/v1/pki/syncs/${syncId}/certificates`, {
params: { offset, limit }
});
return {
certificates: data.certificates || [],
totalCount: data.totalCount || 0
};
},
...options
});
};
@@ -1,6 +1,6 @@
import { AppConnection } from "@app/hooks/api/appConnections/enums"; import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { PkiSyncStatus } from "../enums"; import { CertificateSyncStatus, PkiSyncStatus } from "../enums";
export type RootPkiSyncOptions = { export type RootPkiSyncOptions = {
canImportCertificates: boolean; canImportCertificates: boolean;
@@ -43,4 +43,26 @@ export type TRootPkiSync = {
} | null; } | null;
appConnectionName?: string; appConnectionName?: string;
appConnectionApp?: string; appConnectionApp?: string;
hasCertificate?: boolean;
};
export type TPkiSyncCertificate = {
id: string;
pkiSyncId: string;
certificateId: string;
syncStatus?: CertificateSyncStatus | null;
lastSyncMessage?: string | null;
lastSyncedAt?: string | null;
createdAt: string;
updatedAt: string;
certificateSerialNumber?: string;
certificateCommonName?: string;
certificateAltNames?: string;
certificateStatus?: string;
certificateNotBefore?: Date;
certificateNotAfter?: Date;
certificateRenewBeforeDays?: number;
certificateRenewalError?: string;
pkiSyncName?: string;
pkiSyncDestination?: string;
}; };
@@ -33,7 +33,8 @@ type TCreatePkiSyncDTOBase = {
certificateNameSchema?: string; certificateNameSchema?: string;
}; };
isAutoSyncEnabled: boolean; isAutoSyncEnabled: boolean;
subscriberId?: string; subscriberId?: string | null;
certificateIds?: string[];
projectId: string; projectId: string;
}; };
+23 -3
View File
@@ -664,17 +664,26 @@ export const useListWorkspaceCas = ({
export const useListWorkspaceCertificates = ({ export const useListWorkspaceCertificates = ({
projectId, projectId,
offset, offset,
limit limit,
friendlyName,
commonName,
forPkiSync
}: { }: {
projectId: string; projectId: string;
offset: number; offset: number;
limit: number; limit: number;
friendlyName?: string;
commonName?: string;
forPkiSync?: boolean;
}) => { }) => {
return useQuery({ return useQuery({
queryKey: projectKeys.specificProjectCertificates({ queryKey: projectKeys.specificProjectCertificates({
projectId, projectId,
offset, offset,
limit limit,
friendlyName,
commonName,
forPkiSync
}), }),
queryFn: async () => { queryFn: async () => {
const params = new URLSearchParams({ const params = new URLSearchParams({
@@ -682,6 +691,16 @@ export const useListWorkspaceCertificates = ({
limit: String(limit) limit: String(limit)
}); });
if (friendlyName) {
params.append("friendlyName", friendlyName);
}
if (commonName) {
params.append("commonName", commonName);
}
if (forPkiSync) {
params.append("forPkiSync", "true");
}
const { const {
data: { certificates, totalCount } data: { certificates, totalCount }
} = await apiRequest.get<{ certificates: TCertificate[]; totalCount: number }>( } = await apiRequest.get<{ certificates: TCertificate[]; totalCount: number }>(
@@ -693,7 +712,8 @@ export const useListWorkspaceCertificates = ({
return { certificates, totalCount }; return { certificates, totalCount };
}, },
enabled: Boolean(projectId) enabled: Boolean(projectId),
placeholderData: (previousData) => previousData
}); });
}; };
+12 -2
View File
@@ -39,12 +39,22 @@ export const projectKeys = {
specificProjectCertificates: ({ specificProjectCertificates: ({
projectId, projectId,
offset, offset,
limit limit,
friendlyName,
commonName,
forPkiSync
}: { }: {
projectId: string; projectId: string;
offset: number; offset: number;
limit: number; limit: number;
}) => [...projectKeys.forProjectCertificates(projectId), { offset, limit }] as const, friendlyName?: string;
commonName?: string;
forPkiSync?: boolean;
}) =>
[
...projectKeys.forProjectCertificates(projectId),
{ offset, limit, friendlyName, commonName, forPkiSync }
] as const,
getProjectPkiAlerts: (projectId: string) => [{ projectId }, "project-pki-alerts"] as const, getProjectPkiAlerts: (projectId: string) => [{ projectId }, "project-pki-alerts"] as const,
getProjectPkiSubscribers: (projectId: string) => getProjectPkiSubscribers: (projectId: string) =>
[{ projectId }, "project-pki-subscribers"] as const, [{ projectId }, "project-pki-subscribers"] as const,
@@ -52,25 +52,7 @@ export const PkiManagerLayout = () => {
projectId: currentProject.id projectId: currentProject.id
}} }}
> >
{({ isActive }) => <Tab value={isActive ? "selected" : ""}>Policies</Tab>} {({ isActive }) => <Tab value={isActive ? "selected" : ""}>Certificates</Tab>}
</Link>
<Link
to="/projects/cert-management/$projectId/certificates"
params={{
projectId: currentProject.id
}}
>
{({ isActive }) => (
<Tab
value={
isActive || location.pathname.match(/\/pki-collections\//)
? "selected"
: ""
}
>
Certificates
</Tab>
)}
</Link> </Link>
<Link <Link
to="/projects/cert-management/$projectId/certificate-authorities" to="/projects/cert-management/$projectId/certificate-authorities"
@@ -124,7 +124,7 @@ type Props = {
const caTypes = [ const caTypes = [
{ label: "ACME", value: CaType.ACME }, { label: "ACME", value: CaType.ACME },
{ label: "Azure AD Certificate Service", value: CaType.AZURE_AD_CS } { label: "Active Directory Certificate Services (AD CS)", value: CaType.AZURE_AD_CS }
]; ];
export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => {
@@ -1,61 +0,0 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { ProjectPermissionCan } from "@app/components/permissions";
import { PageHeader } from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionCertificateActions,
ProjectPermissionSub,
useProjectPermission
} from "@app/context";
import { ProjectType } from "@app/hooks/api/projects/types";
import { PkiCollectionSection } from "../AlertingPage/components";
import { CertificatesSection } from "./components";
export const CertificatesPage = () => {
const { t } = useTranslation();
const { permission } = useProjectPermission();
const canAccessPkiColl = permission.can(
ProjectPermissionActions.Read,
ProjectPermissionSub.PkiCollections
);
const canAccessCerts = permission.can(
ProjectPermissionCertificateActions.Read,
ProjectPermissionSub.Certificates
);
return (
<div className="mx-auto flex h-full flex-col justify-between bg-bunker-800 text-white">
<Helmet>
<title>{t("common.head-title", { title: "Certificates" })}</title>
</Helmet>
<div className="mx-auto mb-6 w-full max-w-8xl">
<PageHeader
scope={ProjectType.CertificateManager}
title="Certificates"
description="View and track issued certificates, monitor expiration dates, and manage certificate lifecycles."
/>
{/* If both are false, the section does not render. This is to prevent duplicate banners. */}
{(canAccessCerts || canAccessPkiColl) && (
<ProjectPermissionCan
renderGuardBanner
I={ProjectPermissionActions.Read}
a={ProjectPermissionSub.PkiCollections}
>
<PkiCollectionSection />
</ProjectPermissionCan>
)}
<ProjectPermissionCan
renderGuardBanner
I={ProjectPermissionCertificateActions.Read}
a={ProjectPermissionSub.Certificates}
>
<CertificatesSection />
</ProjectPermissionCan>
</div>
</div>
);
};
@@ -122,11 +122,12 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
const { data: profilesData } = useListCertificateProfiles({ const { data: profilesData } = useListCertificateProfiles({
projectId: currentProject?.id || "", projectId: currentProject?.id || "",
includeMetrics: false,
enrollmentType: "api" enrollmentType: "api"
}); });
const { mutateAsync: createCertificate } = useCreateCertificateV3(); const { mutateAsync: createCertificate } = useCreateCertificateV3({
projectId: currentProject?.id
});
const formResolver = useMemo(() => { const formResolver = useMemo(() => {
return zodResolver(createSchema(shouldShowSubjectSection)); return zodResolver(createSchema(shouldShowSubjectSection));
@@ -0,0 +1,296 @@
import { useEffect, useMemo, useState } from "react";
import { faPlus, faSearch } from "@fortawesome/free-solid-svg-icons";
import { useNavigate } from "@tanstack/react-router";
import { createNotification } from "@app/components/notifications";
import {
Button,
Checkbox,
EmptyState,
Input,
Modal,
ModalContent,
Pagination,
Table,
TableContainer,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import { useProject } from "@app/context";
import {
PkiSync,
useAddCertificatesToPkiSync,
useListPkiSyncsWithCertificate,
useRemoveCertificatesFromPkiSync
} from "@app/hooks/api/pkiSyncs";
import { IntegrationsListPageTabs } from "@app/types/integrations";
type Props = {
popUp: {
isOpen: boolean;
data?: {
certificateId?: string;
commonName?: string;
};
};
handlePopUpToggle: (popUpName: "managePkiSyncs", state?: boolean) => void;
};
const PER_PAGE = 10;
export const CertificateManagePkiSyncsModal = ({ popUp, handlePopUpToggle }: Props) => {
const [selectedSyncIds, setSelectedSyncIds] = useState<Set<string>>(new Set());
const [initialSyncIds, setInitialSyncIds] = useState<Set<string>>(new Set());
const [isSubmitting, setIsSubmitting] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [searchTerm, setSearchTerm] = useState("");
const { currentProject } = useProject();
const navigate = useNavigate();
const { certificateId, commonName } = popUp.data || {};
const { data: pkiSyncs = [], isPending } = useListPkiSyncsWithCertificate(
currentProject?.id || "",
certificateId || "",
{
enabled: !!currentProject?.id && !!certificateId
}
);
const addCertificatesToSync = useAddCertificatesToPkiSync();
const removeCertificatesFromSync = useRemoveCertificatesFromPkiSync();
const filteredSyncs = useMemo(() => {
if (!searchTerm.trim()) return pkiSyncs;
const searchLower = searchTerm.toLowerCase();
return pkiSyncs.filter((sync) => sync.name.toLowerCase().includes(searchLower));
}, [pkiSyncs, searchTerm]);
const startIndex = (currentPage - 1) * PER_PAGE;
const endIndex = startIndex + PER_PAGE;
const paginatedSyncs = filteredSyncs.slice(startIndex, endIndex);
useEffect(() => {
setCurrentPage(1);
}, [searchTerm]);
const handleClose = () => {
handlePopUpToggle("managePkiSyncs", false);
setSelectedSyncIds(new Set());
setInitialSyncIds(new Set());
setSearchTerm("");
setCurrentPage(1);
};
const handleNavigateToPkiSyncs = () => {
if (!currentProject?.id) return;
navigate({
to: ROUTE_PATHS.CertManager.IntegrationsListPage.path,
params: {
projectId: currentProject.id
},
search: {
selectedTab: IntegrationsListPageTabs.PkiSyncs
}
});
handleClose();
};
const getDestinationDisplayName = (destination: string) => {
switch (destination) {
case PkiSync.AzureKeyVault:
return "Azure Key Vault";
case PkiSync.AwsCertificateManager:
return "AWS Certificate Manager";
default:
return destination;
}
};
useEffect(() => {
if (!certificateId || !pkiSyncs || pkiSyncs.length === 0) return;
const currentSyncIds = new Set(
pkiSyncs.filter((sync) => sync.hasCertificate).map((sync) => sync.id)
);
setSelectedSyncIds(currentSyncIds);
setInitialSyncIds(new Set(currentSyncIds));
}, [certificateId, pkiSyncs]);
const handleSyncToggle = (syncId: string) => {
setSelectedSyncIds((prev) => {
const newSet = new Set(prev);
if (newSet.has(syncId)) {
newSet.delete(syncId);
} else {
newSet.add(syncId);
}
return newSet;
});
};
const handleSaveChanges = async () => {
if (!certificateId) return;
try {
setIsSubmitting(true);
const syncsToAdd = Array.from(selectedSyncIds).filter((id) => !initialSyncIds.has(id));
const syncsToRemove = Array.from(initialSyncIds).filter((id) => !selectedSyncIds.has(id));
await Promise.all(
syncsToAdd.map((syncId) =>
addCertificatesToSync.mutateAsync({
pkiSyncId: syncId,
certificateIds: [certificateId]
})
)
);
await Promise.all(
syncsToRemove.map((syncId) =>
removeCertificatesFromSync.mutateAsync({
pkiSyncId: syncId,
certificateIds: [certificateId]
})
)
);
createNotification({
text: `PKI sync settings updated for certificate "${commonName}"`,
type: "success"
});
handleClose();
} catch (error) {
console.error(error);
createNotification({
text: "Failed to update PKI sync settings",
type: "error"
});
} finally {
setIsSubmitting(false);
}
};
return (
<Modal isOpen={popUp.isOpen} onOpenChange={handleClose}>
<ModalContent
title="Manage PKI Syncs"
subTitle={`Select which PKI syncs "${commonName}" should be part of`}
className="max-w-3xl"
>
<div className="mb-4">
<Input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search PKI syncs by name..."
/>
</div>
<div className="mt-4 max-h-96 overflow-y-auto">
{isPending && (
<div className="flex h-32 items-center justify-center">
<div className="text-bunker-300">Loading PKI syncs...</div>
</div>
)}
{!isPending && pkiSyncs.length === 0 && (
<EmptyState title="No PKI syncs available" icon={faPlus}>
<div className="mt-1">
Create a{" "}
<button
type="button"
onClick={handleNavigateToPkiSyncs}
className="cursor-pointer underline hover:text-mineshaft-300"
>
PKI sync
</button>{" "}
first to manage certificate syncing.
</div>
</EmptyState>
)}
{!isPending && pkiSyncs.length > 0 && filteredSyncs.length === 0 && searchTerm && (
<EmptyState title="No PKI syncs found" icon={faSearch}>
<div className="mt-1">
No PKI syncs match your search criteria. Try a different search term.
</div>
</EmptyState>
)}
{!isPending && filteredSyncs.length > 0 && (
<TableContainer>
<Table>
<THead>
<Tr>
<Th className="w-12" />
<Th className="w-1/2">Name</Th>
<Th className="w-1/2">Destination</Th>
</Tr>
</THead>
<TBody>
{paginatedSyncs.map((sync) => (
<Tr
key={sync.id}
className="cursor-pointer hover:bg-mineshaft-700"
onClick={() => handleSyncToggle(sync.id)}
>
<Td>
<Checkbox
isChecked={selectedSyncIds.has(sync.id)}
onCheckedChange={() => handleSyncToggle(sync.id)}
id={`sync-${sync.id}`}
/>
</Td>
<Td className="w-1/2 max-w-0">
<div className="truncate" title={sync.name}>
{sync.name}
</div>
</Td>
<Td className="w-1/2 max-w-0">
<div
className="truncate capitalize"
title={getDestinationDisplayName(sync.destination)}
>
{getDestinationDisplayName(sync.destination)}
</div>
</Td>
</Tr>
))}
</TBody>
</Table>
</TableContainer>
)}
{!isPending && filteredSyncs.length > PER_PAGE && (
<div className="mt-4">
<Pagination
count={filteredSyncs.length}
page={currentPage}
perPage={PER_PAGE}
onChangePage={setCurrentPage}
onChangePerPage={() => {}}
/>
</div>
)}
</div>
<div className="mt-6 flex justify-end gap-3">
<Button variant="outline_bg" onClick={handleClose} isDisabled={isSubmitting}>
Cancel
</Button>
<Button
variant="solid"
colorSchema="primary"
onClick={handleSaveChanges}
isDisabled={isSubmitting}
isLoading={isSubmitting}
>
Save Changes
</Button>
</div>
</ModalContent>
</Modal>
);
};
@@ -15,6 +15,7 @@ import { usePopUp } from "@app/hooks/usePopUp";
import { CertificateCertModal } from "./CertificateCertModal"; import { CertificateCertModal } from "./CertificateCertModal";
import { CertificateImportModal } from "./CertificateImportModal"; import { CertificateImportModal } from "./CertificateImportModal";
import { CertificateIssuanceModal } from "./CertificateIssuanceModal"; import { CertificateIssuanceModal } from "./CertificateIssuanceModal";
import { CertificateManagePkiSyncsModal } from "./CertificateManagePkiSyncsModal";
import { CertificateManageRenewalModal } from "./CertificateManageRenewalModal"; import { CertificateManageRenewalModal } from "./CertificateManageRenewalModal";
import { CertificateModal } from "./CertificateModal"; import { CertificateModal } from "./CertificateModal";
import { CertificateRenewalModal } from "./CertificateRenewalModal"; import { CertificateRenewalModal } from "./CertificateRenewalModal";
@@ -36,7 +37,8 @@ export const CertificatesSection = () => {
"deleteCertificate", "deleteCertificate",
"revokeCertificate", "revokeCertificate",
"manageRenewal", "manageRenewal",
"renewCertificate" "renewCertificate",
"managePkiSyncs"
] as const); ] as const);
const onRemoveCertificateSubmit = async (serialNumber: string) => { const onRemoveCertificateSubmit = async (serialNumber: string) => {
@@ -104,6 +106,10 @@ export const CertificatesSection = () => {
<CertificateManageRenewalModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} /> <CertificateManageRenewalModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CertificateRenewalModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} /> <CertificateRenewalModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CertificateRevocationModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} /> <CertificateRevocationModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CertificateManagePkiSyncsModal
popUp={popUp.managePkiSyncs}
handlePopUpToggle={handlePopUpToggle}
/>
<DeleteActionModal <DeleteActionModal
isOpen={popUp.deleteCertificate.isOpen} isOpen={popUp.deleteCertificate.isOpen}
title={`Are you sure you want to remove the certificate ${ title={`Are you sure you want to remove the certificate ${
@@ -2,19 +2,24 @@ import { useMemo, useState } from "react";
import { import {
faBan, faBan,
faCertificate, faCertificate,
faClockRotateLeft,
faEllipsis, faEllipsis,
faEye, faEye,
faFileExport, faFileExport,
faLink,
faRedo, faRedo,
faTrash faTrash
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns"; import { format } from "date-fns";
import { CircleQuestionMarkIcon } from "lucide-react";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions"; import { ProjectPermissionCan } from "@app/components/permissions";
import {
CertificateDisplayName,
getCertificateDisplayName
} from "@app/components/utilities/certificateDisplayUtils";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -38,12 +43,12 @@ import {
ProjectPermissionSub, ProjectPermissionSub,
useProject useProject
} from "@app/context"; } from "@app/context";
import { useListWorkspaceCertificates, useUpdateRenewalConfig } from "@app/hooks/api"; import { useUpdateRenewalConfig } from "@app/hooks/api";
import { caSupportsCapability } from "@app/hooks/api/ca/constants"; import { caSupportsCapability } from "@app/hooks/api/ca/constants";
import { CaCapability, CaType } from "@app/hooks/api/ca/enums"; import { CaCapability, CaType } from "@app/hooks/api/ca/enums";
import { useListCasByProjectId } from "@app/hooks/api/ca/queries"; import { useListCasByProjectId } from "@app/hooks/api/ca/queries";
import { CertStatus } from "@app/hooks/api/certificates/enums"; import { CertStatus } from "@app/hooks/api/certificates/enums";
import { TCertificate } from "@app/hooks/api/certificates/types"; import { useListWorkspaceCertificates } from "@app/hooks/api/projects";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
import { getCertValidUntilBadgeDetails } from "./CertificatesTable.utils"; import { getCertValidUntilBadgeDetails } from "./CertificatesTable.utils";
@@ -55,93 +60,6 @@ const isExpiringWithinOneDay = (notAfter: string): boolean => {
return expiryDate <= oneDayFromNow; return expiryDate <= oneDayFromNow;
}; };
const getAutoRenewalInfo = (certificate: TCertificate) => {
if (certificate.renewedByCertificateId) {
return { text: "Renewed", variant: "neutral" as const };
}
const isRevoked = certificate.status === CertStatus.REVOKED;
const isExpired = new Date(certificate.notAfter) < new Date();
const hasNoProfile = !certificate.profileId;
const isExpiringWithinDay = isExpiringWithinOneDay(certificate.notAfter);
if (isRevoked) {
return {
text: "Not Available",
variant: "neutral" as const,
tooltip: "Renewal is not available for revoked certificates"
};
}
if (isExpired) {
return {
text: "Not Available",
variant: "neutral" as const,
tooltip: "Renewal is not available for expired certificates"
};
}
if (hasNoProfile) {
return {
text: "Not Available",
variant: "neutral" as const,
tooltip: "Renewal requires a certificate profile"
};
}
if (certificate.hasPrivateKey === false) {
return {
text: "Not Available",
variant: "neutral" as const,
tooltip: "Renewal is not available for certificates with externally generated private keys"
};
}
if (isExpiringWithinDay) {
return {
text: "Not Available",
variant: "neutral" as const,
tooltip: "Auto-renewal is not available for certificates expiring within 24 hours"
};
}
if (certificate.renewalError) {
return {
text: "Failed",
variant: "danger" as const,
tooltip: certificate.renewalError
};
}
if (!certificate.renewBeforeDays) {
return { text: "Auto-Renewal Disabled", variant: "warning" as const };
}
const notAfterDate = new Date(certificate.notAfter);
const renewalDate = new Date(
notAfterDate.getTime() - certificate.renewBeforeDays * 24 * 60 * 60 * 1000
);
const now = new Date();
if (renewalDate <= now) {
return { text: "Due Now", variant: "danger" as const };
}
const daysUntilRenewal = Math.floor(
(renewalDate.getTime() - now.getTime()) / (24 * 60 * 60 * 1000)
);
if (daysUntilRenewal === 0) {
return { text: "Renews today", variant: "warning" as const };
}
if (daysUntilRenewal <= 7) {
return { text: `Renews in ${daysUntilRenewal}d`, variant: "warning" as const };
}
return { text: `Renews in ${daysUntilRenewal}d`, variant: "success" as const };
};
type Props = { type Props = {
handlePopUpOpen: ( handlePopUpOpen: (
popUpName: keyof UsePopUpState< popUpName: keyof UsePopUpState<
@@ -151,7 +69,8 @@ type Props = {
"revokeCertificate", "revokeCertificate",
"certificateCert", "certificateCert",
"manageRenewal", "manageRenewal",
"renewCertificate" "renewCertificate",
"managePkiSyncs"
] ]
>, >,
data?: { data?: {
@@ -232,20 +151,18 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
<Table> <Table>
<THead> <THead>
<Tr> <Tr>
<Th>Common Name</Th> <Th className="w-1/2">SAN / CN</Th>
<Th>Status</Th> <Th className="w-1/6">Status</Th>
<Th>Not Before</Th> <Th className="w-1/6">Not Before</Th>
<Th>Not After</Th> <Th className="w-1/6">Not After</Th>
<Th>Renewal Status</Th> <Th className="w-12" />
<Th />
</Tr> </Tr>
</THead> </THead>
<TBody> <TBody>
{isPending && <TableSkeleton columns={5} innerKey="project-cas" />} {isPending && <TableSkeleton columns={4} innerKey="project-cas" />}
{!isPending && {!isPending &&
data?.certificates.map((certificate) => { data?.certificates.map((certificate) => {
const { variant, label } = getCertValidUntilBadgeDetails(certificate.notAfter); const { variant, label } = getCertValidUntilBadgeDetails(certificate.notAfter);
const autoRenewalInfo = getAutoRenewalInfo(certificate);
const isRevoked = certificate.status === CertStatus.REVOKED; const isRevoked = certificate.status === CertStatus.REVOKED;
const isExpired = new Date(certificate.notAfter) < new Date(); const isExpired = new Date(certificate.notAfter) < new Date();
@@ -254,9 +171,24 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
const isAutoRenewalEnabled = Boolean( const isAutoRenewalEnabled = Boolean(
certificate.renewBeforeDays && certificate.renewBeforeDays > 0 certificate.renewBeforeDays && certificate.renewBeforeDays > 0
); );
const canShowAutoRenewalIcon = Boolean(
certificate.profileId &&
certificate.hasPrivateKey !== false &&
!certificate.renewedByCertificateId &&
!isRevoked &&
!isExpired &&
!isExpiringWithinDay
);
// Still need originalDisplayName for other uses in the component
const { originalDisplayName } = getCertificateDisplayName(certificate, 64, "—");
return ( return (
<Tr className="h-10" key={`certificate-${certificate.id}`}> <Tr className="group h-10" key={`certificate-${certificate.id}`}>
<Td>{certificate.commonName}</Td> <Td className="max-w-0">
<CertificateDisplayName cert={certificate} maxLength={64} fallback="—" />
</Td>
<Td> <Td>
{certificate.status === CertStatus.REVOKED ? ( {certificate.status === CertStatus.REVOKED ? (
<Badge variant="danger">Revoked</Badge> <Badge variant="danger">Revoked</Badge>
@@ -274,22 +206,64 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
? format(new Date(certificate.notAfter), "yyyy-MM-dd") ? format(new Date(certificate.notAfter), "yyyy-MM-dd")
: "-"} : "-"}
</Td> </Td>
<Td> <Td className="flex items-center justify-end gap-2">
{autoRenewalInfo && <div
(autoRenewalInfo.tooltip ? ( className={`transition-opacity ${(() => {
<div className="flex items-center gap-2"> if (!canShowAutoRenewalIcon) return "";
<Tooltip content={autoRenewalInfo.tooltip}> if (isAutoRenewalEnabled) return "opacity-100";
<Badge variant={autoRenewalInfo.variant}> return "opacity-0 group-hover:opacity-100";
{autoRenewalInfo.text} })()}`}
<CircleQuestionMarkIcon /> >
</Badge> {canShowAutoRenewalIcon && (
</Tooltip> <Tooltip
</div> content={(() => {
) : ( if (hasFailed && certificate.renewalError) {
<Badge variant={autoRenewalInfo.variant}>{autoRenewalInfo.text}</Badge> return `Auto-renewal failed: ${certificate.renewalError}`;
))} }
</Td> if (isAutoRenewalEnabled) {
<Td className="flex justify-end"> const expiryDate = new Date(certificate.notAfter);
const now = new Date();
const daysUntilExpiry = Math.ceil(
(expiryDate.getTime() - now.getTime()) / (24 * 60 * 60 * 1000)
);
const daysUntilRenewal = Math.max(
0,
daysUntilExpiry - (certificate.renewBeforeDays || 0)
);
return `Auto-renews in ${daysUntilRenewal}d`;
}
return "Set auto renewal";
})()}
>
<button
type="button"
className={(() => {
if (hasFailed) return "pr-1 text-red-500 hover:text-red-400";
return "pr-1 text-primary-500 hover:text-primary-400";
})()}
aria-label="Certificate auto-renewal"
onClick={(e) => {
e.stopPropagation();
if (hasFailed) return;
handlePopUpOpen("manageRenewal", {
certificateId: certificate.id,
commonName: originalDisplayName,
profileId: certificate.profileId || "",
renewBeforeDays: certificate.renewBeforeDays || 7,
ttlDays: Math.ceil(
(new Date(certificate.notAfter).getTime() -
new Date(certificate.notBefore).getTime()) /
(24 * 60 * 60 * 1000)
)
});
}}
>
<FontAwesomeIcon icon={faClockRotateLeft} />
</button>
</Tooltip>
)}
</div>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg"> <DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400"> <div className="hover:text-primary-400 data-[state=open]:text-primary-400">
@@ -483,6 +457,33 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
</ProjectPermissionCan> </ProjectPermissionCan>
); );
})()} })()}
{/* PKI Sync management - only for active certificates that are not renewed */}
{certificate.status === CertStatus.ACTIVE &&
!certificate.renewedByCertificateId && (
<ProjectPermissionCan
I={ProjectPermissionCertificateActions.Edit}
a={ProjectPermissionSub.Certificates}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={async () =>
handlePopUpOpen("managePkiSyncs", {
certificateId: certificate.id,
commonName: certificate.commonName
})
}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faLink} />}
>
Manage PKI Syncs
</DropdownMenuItem>
)}
</ProjectPermissionCan>
)}
{/* Only show revoke button if CA supports revocation */} {/* Only show revoke button if CA supports revocation */}
{(() => { {(() => {
const caType = caCapabilityMap[certificate.caId]; const caType = caCapabilityMap[certificate.caId];
@@ -1,19 +0,0 @@
import { createFileRoute } from "@tanstack/react-router";
import { CertificatesPage } from "./CertificatesPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/certificates"
)({
component: CertificatesPage,
beforeLoad: ({ context }) => {
return {
breadcrumbs: [
...context.breadcrumbs,
{
label: "Certificates"
}
]
};
}
});
@@ -12,7 +12,6 @@ import {
faToggleOff, faToggleOff,
faToggleOn, faToggleOn,
faTrash, faTrash,
faTriangleExclamation,
faXmark faXmark
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
@@ -47,7 +46,6 @@ import { useToggle } from "@app/hooks";
import { PkiSyncStatus, TPkiSync, usePkiSyncOption } from "@app/hooks/api/pkiSyncs"; import { PkiSyncStatus, TPkiSync, usePkiSyncOption } from "@app/hooks/api/pkiSyncs";
import { PkiSyncDestinationCol } from "./PkiSyncDestinationCol"; import { PkiSyncDestinationCol } from "./PkiSyncDestinationCol";
import { PkiSyncTableCell } from "./PkiSyncTableCell";
type Props = { type Props = {
pkiSync: TPkiSync; pkiSync: TPkiSync;
@@ -163,23 +161,6 @@ export const PkiSyncRow = ({
<p className="truncate text-xs leading-4 text-bunker-300">{destinationDetails.name}</p> <p className="truncate text-xs leading-4 text-bunker-300">{destinationDetails.name}</p>
</div> </div>
</Td> </Td>
{subscriberId ? (
<PkiSyncTableCell
primaryText={pkiSync.subscriber?.name || subscriberId}
secondaryText="PKI Subscriber"
/>
) : (
<Td>
<Tooltip content="The PKI subscriber for this sync has been deleted. Configure a new source or remove this sync.">
<div className="w-min">
<Badge variant="warning">
<FontAwesomeIcon icon={faTriangleExclamation} />
<span>Source Deleted</span>
</Badge>
</div>
</Tooltip>
</Td>
)}
<PkiSyncDestinationCol pkiSync={pkiSync} /> <PkiSyncDestinationCol pkiSync={pkiSync} />
<Td> <Td>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@@ -57,7 +57,6 @@ import { PkiSyncRow } from "./PkiSyncRow";
enum PkiSyncsOrderBy { enum PkiSyncsOrderBy {
Destination = "destination", Destination = "destination",
Source = "source",
Name = "name", Name = "name",
Status = "status" Status = "status"
} }
@@ -160,14 +159,6 @@ export const PkiSyncsTable = ({ pkiSyncs }: Props) => {
const [syncOne, syncTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a]; const [syncOne, syncTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
switch (orderBy) { switch (orderBy) {
case PkiSyncsOrderBy.Source:
return (syncOne.subscriber?.name ?? syncOne.subscriberId ?? "")
.toLowerCase()
.localeCompare(
syncTwo.subscriber?.name?.toLowerCase() ??
syncTwo.subscriberId?.toLowerCase() ??
""
);
case PkiSyncsOrderBy.Destination: case PkiSyncsOrderBy.Destination:
return getPkiSyncDestinationColValues(syncOne) return getPkiSyncDestinationColValues(syncOne)
.primaryText.toLowerCase() .primaryText.toLowerCase()
@@ -370,7 +361,7 @@ export const PkiSyncsTable = ({ pkiSyncs }: Props) => {
<THead> <THead>
<Tr> <Tr>
<Th className="w-2" /> <Th className="w-2" />
<Th className="w-1/4"> <Th className="w-1/2">
<div className="flex items-center"> <div className="flex items-center">
Name Name
<IconButton <IconButton
@@ -383,20 +374,7 @@ export const PkiSyncsTable = ({ pkiSyncs }: Props) => {
</IconButton> </IconButton>
</div> </div>
</Th> </Th>
<Th className="w-1/3"> <Th className="w-1/4">
<div className="flex items-center">
Source
<IconButton
variant="plain"
className={getClassName(PkiSyncsOrderBy.Source)}
ariaLabel="sort"
onClick={() => handleSort(PkiSyncsOrderBy.Source)}
>
<FontAwesomeIcon icon={getColSortIcon(PkiSyncsOrderBy.Source)} />
</IconButton>
</div>
</Th>
<Th className="w-1/3">
<div className="flex items-center"> <div className="flex items-center">
Destination Destination
<IconButton <IconButton
@@ -409,7 +387,7 @@ export const PkiSyncsTable = ({ pkiSyncs }: Props) => {
</IconButton> </IconButton>
</div> </div>
</Th> </Th>
<Th className="min-w-42"> <Th className="w-1/4 min-w-42">
<div className="flex items-center"> <div className="flex items-center">
Status Status
<IconButton <IconButton
@@ -12,13 +12,14 @@ import { ProjectPermissionSub, useProject } from "@app/context";
import { ProjectPermissionPkiSyncActions } from "@app/context/ProjectPermissionContext/types"; import { ProjectPermissionPkiSyncActions } from "@app/context/ProjectPermissionContext/types";
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
import { useListPkiSyncs } from "@app/hooks/api/pkiSyncs"; import { useListPkiSyncs } from "@app/hooks/api/pkiSyncs";
import { IntegrationsListPageTabs } from "@app/types/integrations";
import { PkiSyncsTable } from "./PkiSyncTable"; import { PkiSyncsTable } from "./PkiSyncTable";
export const PkiSyncsTab = () => { export const PkiSyncsTab = () => {
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["addSync"] as const); const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["addSync"] as const);
const { addSync, ...search } = useSearch({ const { addSync, connectionId, connectionName, ...search } = useSearch({
from: ROUTE_PATHS.CertManager.IntegrationsListPage.id from: ROUTE_PATHS.CertManager.IntegrationsListPage.id
}); });
@@ -45,6 +46,42 @@ export const PkiSyncsTab = () => {
navigateToBase(); navigateToBase();
}, [addSync, handlePopUpOpen, navigateToBase]); }, [addSync, handlePopUpOpen, navigateToBase]);
useEffect(() => {
const storedFormData = localStorage.getItem("pkiSyncFormData");
if (storedFormData && !popUp.addSync.isOpen) {
try {
const parsedData = JSON.parse(storedFormData);
if (connectionId && connectionName) {
const initialData = {
...parsedData,
connection: { id: connectionId, name: connectionName }
};
handlePopUpOpen("addSync", { destination: parsedData.destination, initialData });
navigate({
to: ROUTE_PATHS.CertManager.IntegrationsListPage.path,
params: { projectId: currentProject?.id },
search: { selectedTab: IntegrationsListPageTabs.PkiSyncs },
replace: true
});
} else {
handlePopUpOpen("addSync", { destination: parsedData.destination });
}
localStorage.removeItem("pkiSyncFormData");
} catch (error) {
console.error("Failed to parse stored PKI sync form data:", error);
localStorage.removeItem("pkiSyncFormData");
handlePopUpOpen("addSync");
}
}
}, [
handlePopUpOpen,
popUp.addSync.isOpen,
connectionId,
connectionName,
navigate,
currentProject?.id
]);
const { data: pkiSyncs = [], isPending: isPkiSyncsPending } = useListPkiSyncs( const { data: pkiSyncs = [], isPending: isPkiSyncsPending } = useListPkiSyncs(
currentProject?.id || "", currentProject?.id || "",
{ {
@@ -94,7 +131,8 @@ export const PkiSyncsTab = () => {
<PkiSyncsTable pkiSyncs={pkiSyncs} /> <PkiSyncsTable pkiSyncs={pkiSyncs} />
</div> </div>
<CreatePkiSyncModal <CreatePkiSyncModal
selectSync={popUp.addSync.data} selectSync={popUp.addSync.data?.destination || popUp.addSync.data}
initialData={popUp.addSync.data?.initialData}
isOpen={popUp.addSync.isOpen} isOpen={popUp.addSync.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("addSync", isOpen)} onOpenChange={(isOpen) => handlePopUpToggle("addSync", isOpen)}
/> />
@@ -9,7 +9,9 @@ import { IntegrationsListPage } from "./IntegrationsListPage";
const IntegrationsListPageQuerySchema = z.object({ const IntegrationsListPageQuerySchema = z.object({
selectedTab: z.nativeEnum(IntegrationsListPageTabs).optional(), selectedTab: z.nativeEnum(IntegrationsListPageTabs).optional(),
addSync: z.nativeEnum(PkiSync).optional() addSync: z.nativeEnum(PkiSync).optional(),
connectionId: z.string().optional(),
connectionName: z.string().optional()
}); });
export const Route = createFileRoute( export const Route = createFileRoute(
@@ -59,9 +59,9 @@ export const PkiCollectionPage = () => {
}); });
handlePopUpClose("deletePkiCollection"); handlePopUpClose("deletePkiCollection");
navigate({ navigate({
to: "/projects/cert-management/$projectId/certificates", to: "/projects/cert-management/$projectId/policies",
params: { params: {
projectId projectId: params.projectId
} }
}); });
} catch { } catch {
@@ -77,9 +77,9 @@ export const PkiCollectionPage = () => {
{data && ( {data && (
<div className="mx-auto mb-6 w-full max-w-8xl"> <div className="mx-auto mb-6 w-full max-w-8xl">
<Link <Link
to="/projects/cert-management/$projectId/certificates" to="/projects/cert-management/$projectId/policies"
params={{ params={{
projectId projectId: params.projectId
}} }}
className="mb-4 flex items-center gap-x-2 text-sm text-mineshaft-400" className="mb-4 flex items-center gap-x-2 text-sm text-mineshaft-400"
> >
@@ -5,13 +5,9 @@ import { z } from "zod";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2"; import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2";
import { useProject } from "@app/context"; import { useProject } from "@app/context";
import { import { CaStatus, useAddItemToPkiCollection, useListWorkspaceCas } from "@app/hooks/api";
CaStatus,
useAddItemToPkiCollection,
useListWorkspaceCas,
useListWorkspaceCertificates
} from "@app/hooks/api";
import { PkiItemType, pkiItemTypeToNameMap } from "@app/hooks/api/pkiCollections/constants"; import { PkiItemType, pkiItemTypeToNameMap } from "@app/hooks/api/pkiCollections/constants";
import { useListWorkspaceCertificates } from "@app/hooks/api/projects";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = z const schema = z
@@ -13,7 +13,7 @@ export const Route = createFileRoute(
{ {
label: "Certificate Collections", label: "Certificate Collections",
link: linkOptions({ link: linkOptions({
to: "/projects/cert-management/$projectId/certificates", to: "/projects/cert-management/$projectId/policies",
params: { params: {
projectId: params.projectId projectId: params.projectId
} }
@@ -18,10 +18,10 @@ import { IntegrationsListPageTabs } from "@app/types/integrations";
import { import {
PkiSyncActionTriggers, PkiSyncActionTriggers,
PkiSyncAuditLogsSection, PkiSyncAuditLogsSection,
PkiSyncCertificatesSection,
PkiSyncDestinationSection, PkiSyncDestinationSection,
PkiSyncDetailsSection, PkiSyncDetailsSection,
PkiSyncOptionsSection, PkiSyncOptionsSection
PkiSyncSourceSection
} from "./components"; } from "./components";
const PageContent = () => { const PageContent = () => {
@@ -62,7 +62,6 @@ const PageContent = () => {
const destinationDetails = PKI_SYNC_MAP[pkiSync.destination]; const destinationDetails = PKI_SYNC_MAP[pkiSync.destination];
const handleEditDetails = () => handlePopUpOpen("editSync", PkiSyncEditFields.Details); const handleEditDetails = () => handlePopUpOpen("editSync", PkiSyncEditFields.Details);
const handleEditSource = () => handlePopUpOpen("editSync", PkiSyncEditFields.Source);
const handleEditOptions = () => handlePopUpOpen("editSync", PkiSyncEditFields.Options); const handleEditOptions = () => handlePopUpOpen("editSync", PkiSyncEditFields.Options);
const handleEditDestination = () => handlePopUpOpen("editSync", PkiSyncEditFields.Destination); const handleEditDestination = () => handlePopUpOpen("editSync", PkiSyncEditFields.Destination);
@@ -103,7 +102,6 @@ const PageContent = () => {
<div className="flex justify-center"> <div className="flex justify-center">
<div className="mr-4 flex w-72 flex-col gap-4"> <div className="mr-4 flex w-72 flex-col gap-4">
<PkiSyncDetailsSection pkiSync={pkiSync} onEditDetails={handleEditDetails} /> <PkiSyncDetailsSection pkiSync={pkiSync} onEditDetails={handleEditDetails} />
<PkiSyncSourceSection pkiSync={pkiSync} onEditSource={handleEditSource} />
<PkiSyncOptionsSection pkiSync={pkiSync} onEditOptions={handleEditOptions} /> <PkiSyncOptionsSection pkiSync={pkiSync} onEditOptions={handleEditOptions} />
</div> </div>
<div className="flex flex-1 flex-col gap-4"> <div className="flex flex-1 flex-col gap-4">
@@ -111,6 +109,7 @@ const PageContent = () => {
pkiSync={pkiSync} pkiSync={pkiSync}
onEditDestination={handleEditDestination} onEditDestination={handleEditDestination}
/> />
<PkiSyncCertificatesSection pkiSync={pkiSync} />
<PkiSyncAuditLogsSection pkiSync={pkiSync} /> <PkiSyncAuditLogsSection pkiSync={pkiSync} />
</div> </div>
</div> </div>
@@ -26,7 +26,7 @@ export const PkiSyncAuditLogsSection = ({ pkiSync }: Props) => {
return ( return (
<div className="flex max-h-full w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3"> <div className="flex max-h-full w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-2"> <div className="flex items-center justify-between border-b border-mineshaft-400 pb-2">
<h3 className="font-medium text-mineshaft-100">Sync Logs</h3> <h3 className="text-lg font-medium text-mineshaft-100">Sync Logs</h3>
{subscription.auditLogs && ( {subscription.auditLogs && (
<p className="text-xs text-bunker-300"> <p className="text-xs text-bunker-300">
Displaying audit logs from the last {Math.min(auditLogsRetentionDays, 60)} days Displaying audit logs from the last {Math.min(auditLogsRetentionDays, 60)} days
@@ -0,0 +1,328 @@
import { useState } from "react";
import { subject } from "@casl/ability";
import {
faCertificate,
faClockRotateLeft,
faEdit,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { CertificateManagementModal } from "@app/components/pki-syncs/CertificateManagementModal";
import {
CertificateDisplayName,
getCertificateDisplayName
} from "@app/components/utilities/certificateDisplayUtils";
import {
DeleteActionModal,
EmptyState,
IconButton,
Pagination,
Table,
TableContainer,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { Badge } from "@app/components/v3";
import { ProjectPermissionSub } from "@app/context";
import { ProjectPermissionPkiSyncActions } from "@app/context/ProjectPermissionContext/types";
import { useListPkiSyncCertificates, useRemoveCertificatesFromPkiSync } from "@app/hooks/api";
import { CertificateSyncStatus, TPkiSync } from "@app/hooks/api/pkiSyncs";
type Props = {
pkiSync: TPkiSync;
};
const getSyncStatusVariant = (status?: CertificateSyncStatus | null) => {
if (status === CertificateSyncStatus.Succeeded) return "success";
if (status === CertificateSyncStatus.Failed) return "danger";
if (status === CertificateSyncStatus.Syncing) return "neutral";
return "project";
};
const getSyncStatusText = (status?: CertificateSyncStatus | null) => {
if (status === CertificateSyncStatus.Succeeded) return "Synced";
if (status === CertificateSyncStatus.Failed) return "Failed";
if (status === CertificateSyncStatus.Syncing) return "Syncing";
if (status === CertificateSyncStatus.Pending) return "Pending";
return "Unknown";
};
const getCertificateStatusVariant = (isExpired: boolean, isRevoked: boolean) => {
if (isRevoked) return "danger";
if (isExpired) return "danger";
return "success";
};
const getCertificateStatusText = (isExpired: boolean, isRevoked: boolean) => {
if (isRevoked) return "Revoked";
if (isExpired) return "Expired";
return "Active";
};
export const PkiSyncCertificatesSection = ({ pkiSync }: Props) => {
const [isManageModalOpen, setIsManageModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [certificateToDelete, setCertificateToDelete] = useState<{
id: string;
displayName: string;
} | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 10;
const { data, refetch: refetchSyncCertificates } = useListPkiSyncCertificates(pkiSync.id, {
offset: (currentPage - 1) * pageSize,
limit: pageSize
});
const syncCertificates = data?.certificates || [];
const totalCount = data?.totalCount || 0;
const removeCertificatesFromSync = useRemoveCertificatesFromPkiSync();
const permissionSubject = subject(ProjectPermissionSub.PkiSyncs, {
subscriberId: pkiSync.subscriberId || ""
});
const handleRemoveCertificate = async (certificateId: string) => {
try {
await removeCertificatesFromSync.mutateAsync({
pkiSyncId: pkiSync.id,
certificateIds: [certificateId]
});
await refetchSyncCertificates();
createNotification({
text: "Certificate removed from sync",
type: "success"
});
setIsDeleteModalOpen(false);
setCertificateToDelete(null);
} catch {
createNotification({
text: "Failed to remove certificate from sync",
type: "error"
});
}
};
const handleDeleteClick = (certificateId: string, displayName: string) => {
setCertificateToDelete({ id: certificateId, displayName });
setIsDeleteModalOpen(true);
};
const totalPages = Math.ceil(totalCount / pageSize);
return (
<div>
<div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-2">
<h3 className="text-lg font-medium text-mineshaft-100">Certificates</h3>
<ProjectPermissionCan I={ProjectPermissionPkiSyncActions.Edit} a={permissionSubject}>
{(isAllowed) => (
<IconButton
variant="plain"
colorSchema="secondary"
isDisabled={!isAllowed}
ariaLabel="Edit certificates"
onClick={() => setIsManageModalOpen(true)}
>
<FontAwesomeIcon icon={faEdit} />
</IconButton>
)}
</ProjectPermissionCan>
</div>
<div>
<div className="space-y-4">
<TableContainer>
<Table>
<THead>
<Tr>
<Th className="w-2/8">SAN / CN</Th>
<Th className="w-3/16">Certificate Status</Th>
<Th className="w-3/16">Serial Number</Th>
<Th className="w-1/8">Sync Status</Th>
<Th className="w-1/8">Expires At</Th>
<Th className="w-1/8" />
</Tr>
</THead>
<TBody>
{syncCertificates.map((syncCert) => {
const isExpired = syncCert.certificateNotAfter
? new Date(syncCert.certificateNotAfter) < new Date()
: false;
const isRevoked = syncCert.certificateStatus === "revoked";
// Calculate auto-renewal timeline
const hasAutoRenewal = Boolean(
syncCert.certificateRenewBeforeDays &&
syncCert.certificateRenewBeforeDays > 0 &&
!syncCert.certificateRenewalError &&
syncCert.certificateNotAfter
);
const daysUntilRenewal =
hasAutoRenewal && syncCert.certificateNotAfter
? (() => {
const expiryDate = new Date(syncCert.certificateNotAfter);
const renewalDate = new Date(
expiryDate.getTime() -
syncCert.certificateRenewBeforeDays! * 24 * 60 * 60 * 1000
);
const now = new Date();
const diffInMs = renewalDate.getTime() - now.getTime();
return Math.max(0, Math.ceil(diffInMs / (24 * 60 * 60 * 1000)));
})()
: null;
const { originalDisplayName } = getCertificateDisplayName(
{
altNames: syncCert.certificateAltNames,
commonName: syncCert.certificateCommonName
},
34,
"Unknown"
);
return (
<Tr key={syncCert.id}>
<Td className="max-w-0">
<CertificateDisplayName
cert={{
altNames: syncCert.certificateAltNames,
commonName: syncCert.certificateCommonName
}}
maxLength={34}
fallback="Unknown"
/>
</Td>
<Td>
<Badge variant={getCertificateStatusVariant(isExpired, isRevoked)}>
{getCertificateStatusText(isExpired, isRevoked)}
</Badge>
</Td>
<Td className="max-w-0">
<div
className="truncate text-xs"
title={syncCert.certificateSerialNumber || "Unknown"}
>
{(() => {
const serial = syncCert.certificateSerialNumber;
if (!serial || serial === "Unknown") return "Unknown";
if (serial.length <= 8) return serial;
return `${serial.substring(0, 4)}...${serial.substring(serial.length - 4)}`;
})()}
</div>
</Td>
<Td>
{syncCert.lastSyncMessage &&
syncCert.syncStatus === CertificateSyncStatus.Failed ? (
<Tooltip content={syncCert.lastSyncMessage}>
<Badge variant="danger">Failed</Badge>
</Tooltip>
) : (
<Badge variant={getSyncStatusVariant(syncCert.syncStatus)}>
{getSyncStatusText(syncCert.syncStatus)}
</Badge>
)}
</Td>
<Td>
<span
className={`text-sm ${isExpired ? "text-red-400" : "text-bunker-300"}`}
>
{syncCert.certificateNotAfter
? new Date(syncCert.certificateNotAfter).toLocaleDateString()
: "Unknown"}
</span>
</Td>
<Td className="flex items-center justify-end gap-2 pr-4">
{hasAutoRenewal && daysUntilRenewal !== null && (
<Tooltip content={`Auto-renews in ${daysUntilRenewal}d`}>
<div className="text-primary-500">
<FontAwesomeIcon icon={faClockRotateLeft} size="sm" />
</div>
</Tooltip>
)}
<ProjectPermissionCan
I={ProjectPermissionPkiSyncActions.Edit}
a={permissionSubject}
>
{(isAllowed) => (
<IconButton
size="xs"
variant="plain"
colorSchema="danger"
ariaLabel="Remove certificate"
isDisabled={!isAllowed}
onClick={() =>
handleDeleteClick(syncCert.certificateId, originalDisplayName)
}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
)}
</ProjectPermissionCan>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{syncCertificates.length === 0 && (
<EmptyState
title="No certificates are part of this certificate sync"
icon={faCertificate}
/>
)}
</TableContainer>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex justify-center">
<Pagination
count={totalCount}
page={currentPage}
perPage={pageSize}
onChangePage={(page: number) => setCurrentPage(page)}
onChangePerPage={() => {}}
/>
</div>
)}
</div>
</div>
</div>
<CertificateManagementModal
pkiSync={pkiSync}
isOpen={isManageModalOpen}
onClose={() => setIsManageModalOpen(false)}
onCertificatesUpdated={() => {
refetchSyncCertificates();
}}
/>
<DeleteActionModal
isOpen={isDeleteModalOpen}
onClose={() => {
setIsDeleteModalOpen(false);
setCertificateToDelete(null);
}}
title="Remove Certificate from Sync"
subTitle={`Are you sure you want to remove "${certificateToDelete?.displayName}" from this PKI sync?`}
deleteKey="confirm"
onDeleteApproved={async () => {
if (certificateToDelete) {
await handleRemoveCertificate(certificateToDelete.id);
}
}}
buttonText="Remove Certificate"
/>
</div>
);
};
@@ -11,12 +11,15 @@ import { ProjectPermissionPkiSyncActions } from "@app/context/ProjectPermissionC
import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs"; import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs";
import { PkiSync, TPkiSync } from "@app/hooks/api/pkiSyncs"; import { PkiSync, TPkiSync } from "@app/hooks/api/pkiSyncs";
import { AzureKeyVaultPkiSyncDestinationSection } from "./PkiSyncDestinationSection/index"; import {
AwsCertificateManagerPkiSyncDestinationSection,
AzureKeyVaultPkiSyncDestinationSection
} from "./PkiSyncDestinationSection/index";
const GenericFieldLabel = ({ label, children }: { label: string; children: React.ReactNode }) => ( const GenericFieldLabel = ({ label, children }: { label: string; children: React.ReactNode }) => (
<div> <div className="mb-4">
<label className="text-sm text-bunker-300">{label}</label> <p className="text-sm font-medium text-mineshaft-300">{label}</p>
<div className="mt-1">{children}</div> <div className="text-sm text-mineshaft-300">{children}</div>
</div> </div>
); );
@@ -32,6 +35,9 @@ export const PkiSyncDestinationSection = ({ pkiSync, onEditDestination }: Props)
let DestinationComponents: ReactNode; let DestinationComponents: ReactNode;
switch (destination) { switch (destination) {
case PkiSync.AwsCertificateManager:
DestinationComponents = <AwsCertificateManagerPkiSyncDestinationSection pkiSync={pkiSync} />;
break;
case PkiSync.AzureKeyVault: case PkiSync.AzureKeyVault:
DestinationComponents = <AzureKeyVaultPkiSyncDestinationSection pkiSync={pkiSync} />; DestinationComponents = <AzureKeyVaultPkiSyncDestinationSection pkiSync={pkiSync} />;
break; break;
@@ -47,7 +53,7 @@ export const PkiSyncDestinationSection = ({ pkiSync, onEditDestination }: Props)
return ( return (
<div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3"> <div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-2"> <div className="flex items-center justify-between border-b border-mineshaft-400 pb-2">
<h3 className="font-medium text-mineshaft-100">Destination Configuration</h3> <h3 className="text-lg font-medium text-mineshaft-100">Destination Configuration</h3>
<ProjectPermissionCan I={ProjectPermissionPkiSyncActions.Edit} a={permissionSubject}> <ProjectPermissionCan I={ProjectPermissionPkiSyncActions.Edit} a={permissionSubject}>
{(isAllowed) => ( {(isAllowed) => (
<IconButton <IconButton
@@ -62,7 +68,7 @@ export const PkiSyncDestinationSection = ({ pkiSync, onEditDestination }: Props)
)} )}
</ProjectPermissionCan> </ProjectPermissionCan>
</div> </div>
<div className="flex w-full flex-wrap gap-8"> <div className="flex w-full flex-wrap gap-8 pt-2">
<GenericFieldLabel label={`${destinationDetails.name} Connection`}> <GenericFieldLabel label={`${destinationDetails.name} Connection`}>
{pkiSync.appConnectionName || "Default Connection"} {pkiSync.appConnectionName || "Default Connection"}
</GenericFieldLabel> </GenericFieldLabel>
@@ -0,0 +1,21 @@
import { TPkiSync } from "@app/hooks/api/pkiSyncs";
const GenericFieldLabel = ({ label, children }: { label: string; children: React.ReactNode }) => (
<div className="mb-4">
<p className="text-sm font-medium text-mineshaft-300">{label}</p>
<div className="text-sm text-mineshaft-300">{children}</div>
</div>
);
type Props = {
pkiSync: TPkiSync;
};
export const AwsCertificateManagerPkiSyncDestinationSection = ({ pkiSync }: Props) => {
const region =
pkiSync.destinationConfig && "region" in pkiSync.destinationConfig
? pkiSync.destinationConfig.region
: undefined;
return <GenericFieldLabel label="AWS Region">{region || "Not specified"}</GenericFieldLabel>;
};
@@ -2,9 +2,9 @@
import { TAzureKeyVaultPkiSync } from "@app/hooks/api/pkiSyncs/types/azure-key-vault-sync"; import { TAzureKeyVaultPkiSync } from "@app/hooks/api/pkiSyncs/types/azure-key-vault-sync";
const GenericFieldLabel = ({ label, children }: { label: string; children: React.ReactNode }) => ( const GenericFieldLabel = ({ label, children }: { label: string; children: React.ReactNode }) => (
<div> <div className="mb-4">
<label className="text-sm text-bunker-300">{label}</label> <p className="text-sm font-medium text-mineshaft-300">{label}</p>
<div className="mt-1">{children}</div> <div className="text-sm text-mineshaft-300">{children}</div>
</div> </div>
); );
@@ -1 +1,2 @@
export { AwsCertificateManagerPkiSyncDestinationSection } from "./AwsCertificateManagerPkiSyncDestinationSection";
export { AzureKeyVaultPkiSyncDestinationSection } from "./AzureKeyVaultPkiSyncDestinationSection"; export { AzureKeyVaultPkiSyncDestinationSection } from "./AzureKeyVaultPkiSyncDestinationSection";
@@ -21,9 +21,9 @@ const GenericFieldLabel = ({
children: React.ReactNode; children: React.ReactNode;
labelClassName?: string; labelClassName?: string;
}) => ( }) => (
<div> <div className="mb-4">
<label className={`text-sm text-bunker-300 ${labelClassName || ""}`}>{label}</label> <p className={`text-sm font-medium text-mineshaft-300 ${labelClassName || ""}`}>{label}</p>
<div className="mt-1">{children}</div> <div className="text-sm text-mineshaft-300">{children}</div>
</div> </div>
); );
@@ -57,7 +57,7 @@ export const PkiSyncDetailsSection = ({ pkiSync, onEditDetails }: Props) => {
return ( return (
<div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3"> <div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-2"> <div className="flex items-center justify-between border-b border-mineshaft-400 pb-2">
<h3 className="font-medium text-mineshaft-100">Details</h3> <h3 className="text-lg font-medium text-mineshaft-100">Details</h3>
<ProjectPermissionCan I={ProjectPermissionPkiSyncActions.Edit} a={permissionSubject}> <ProjectPermissionCan I={ProjectPermissionPkiSyncActions.Edit} a={permissionSubject}>
{(isAllowed) => ( {(isAllowed) => (
<IconButton <IconButton
@@ -72,31 +72,27 @@ export const PkiSyncDetailsSection = ({ pkiSync, onEditDetails }: Props) => {
)} )}
</ProjectPermissionCan> </ProjectPermissionCan>
</div> </div>
<div> <div className="pt-2">
<div className="space-y-3"> <GenericFieldLabel label="Name">{name}</GenericFieldLabel>
<GenericFieldLabel label="Name">{name}</GenericFieldLabel> <GenericFieldLabel label="Description">{description || "None"}</GenericFieldLabel>
<GenericFieldLabel label="Description">{description || "None"}</GenericFieldLabel> {subscriber && (
<GenericFieldLabel label="Source Subscriber"> <GenericFieldLabel label="Source Subscriber">{subscriber.name}</GenericFieldLabel>
{subscriber ? subscriber.name : "Subscriber deleted"} )}
{syncStatus && (
<GenericFieldLabel label="Status">
<PkiSyncStatusBadge status={syncStatus} />
</GenericFieldLabel> </GenericFieldLabel>
{syncStatus && ( )}
<GenericFieldLabel label="Status"> {lastSyncedAt && (
<PkiSyncStatusBadge status={syncStatus} /> <GenericFieldLabel label="Last Synced">
</GenericFieldLabel> {format(new Date(lastSyncedAt), "yyyy-MM-dd, h:mm aaa")}
)} </GenericFieldLabel>
{lastSyncedAt && ( )}
<GenericFieldLabel label="Last Synced"> {syncStatus === PkiSyncStatus.Failed && failureMessage && (
{format(new Date(lastSyncedAt), "yyyy-MM-dd, h:mm aaa")} <GenericFieldLabel labelClassName="text-red" label="Last Sync Error">
</GenericFieldLabel> <p className="rounded-sm bg-mineshaft-600 p-2 text-xs break-words">{failureMessage}</p>
)} </GenericFieldLabel>
{syncStatus === PkiSyncStatus.Failed && failureMessage && ( )}
<GenericFieldLabel labelClassName="text-red" label="Last Sync Error">
<p className="rounded-sm bg-mineshaft-600 p-2 text-xs break-words">
{failureMessage}
</p>
</GenericFieldLabel>
)}
</div>
</div> </div>
</div> </div>
); );
@@ -3,13 +3,27 @@ import { faEdit } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { ProjectPermissionCan } from "@app/components/permissions"; import { ProjectPermissionCan } from "@app/components/permissions";
import { GenericFieldLabel } from "@app/components/secret-syncs";
import { IconButton } from "@app/components/v2"; import { IconButton } from "@app/components/v2";
import { Badge } from "@app/components/v3"; import { Badge } from "@app/components/v3";
import { ProjectPermissionSub } from "@app/context"; import { ProjectPermissionSub } from "@app/context";
import { ProjectPermissionPkiSyncActions } from "@app/context/ProjectPermissionContext/types"; import { ProjectPermissionPkiSyncActions } from "@app/context/ProjectPermissionContext/types";
import { TPkiSync } from "@app/hooks/api/pkiSyncs"; import { TPkiSync } from "@app/hooks/api/pkiSyncs";
const GenericFieldLabel = ({
label,
children,
labelClassName
}: {
label: string;
children: React.ReactNode;
labelClassName?: string;
}) => (
<div className="mb-4">
<p className={`text-sm font-medium text-mineshaft-300 ${labelClassName || ""}`}>{label}</p>
<div className="text-sm text-mineshaft-300">{children}</div>
</div>
);
type Props = { type Props = {
pkiSync: TPkiSync; pkiSync: TPkiSync;
onEditOptions: VoidFunction; onEditOptions: VoidFunction;
@@ -28,7 +42,7 @@ export const PkiSyncOptionsSection = ({ pkiSync, onEditOptions }: Props) => {
<div> <div>
<div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3"> <div className="flex w-full flex-col gap-3 rounded-lg border border-mineshaft-600 bg-mineshaft-900 px-4 py-3">
<div className="flex items-center justify-between border-b border-mineshaft-400 pb-2"> <div className="flex items-center justify-between border-b border-mineshaft-400 pb-2">
<h3 className="font-medium text-mineshaft-100">Sync Options</h3> <h3 className="text-lg font-medium text-mineshaft-100">Sync Options</h3>
<ProjectPermissionCan I={ProjectPermissionPkiSyncActions.Edit} a={permissionSubject}> <ProjectPermissionCan I={ProjectPermissionPkiSyncActions.Edit} a={permissionSubject}>
{(isAllowed) => ( {(isAllowed) => (
<IconButton <IconButton
@@ -43,21 +57,19 @@ export const PkiSyncOptionsSection = ({ pkiSync, onEditOptions }: Props) => {
)} )}
</ProjectPermissionCan> </ProjectPermissionCan>
</div> </div>
<div> <div className="pt-1">
<div className="space-y-3"> {/* Hidden for now - Import certificates functionality disabled
{/* Hidden for now - Import certificates functionality disabled
<GenericFieldLabel label="Certificate Import"> <GenericFieldLabel label="Certificate Import">
<Badge variant={canImportCertificates ? "success" : "danger"}> <Badge variant={canImportCertificates ? "success" : "danger"}>
{canImportCertificates ? "Enabled" : "Disabled"} {canImportCertificates ? "Enabled" : "Disabled"}
</Badge> </Badge>
</GenericFieldLabel> </GenericFieldLabel>
*/} */}
<GenericFieldLabel label="Certificate Removal"> <GenericFieldLabel label="Inactive Certificate Removal" labelClassName="mb-1">
<Badge variant={canRemoveCertificates ? "success" : "danger"}> <Badge variant={canRemoveCertificates ? "success" : "danger"}>
{canRemoveCertificates ? "Enabled" : "Disabled"} {canRemoveCertificates ? "Enabled" : "Disabled"}
</Badge> </Badge>
</GenericFieldLabel> </GenericFieldLabel>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -1,5 +1,6 @@
export { PkiSyncActionTriggers } from "./PkiSyncActionTriggers"; export { PkiSyncActionTriggers } from "./PkiSyncActionTriggers";
export { PkiSyncAuditLogsSection } from "./PkiSyncAuditLogsSection"; export { PkiSyncAuditLogsSection } from "./PkiSyncAuditLogsSection";
export { PkiSyncCertificatesSection } from "./PkiSyncCertificatesSection";
export { PkiSyncDestinationSection } from "./PkiSyncDestinationSection"; export { PkiSyncDestinationSection } from "./PkiSyncDestinationSection";
export { PkiSyncDetailsSection } from "./PkiSyncDetailsSection"; export { PkiSyncDetailsSection } from "./PkiSyncDetailsSection";
export { PkiSyncOptionsSection } from "./PkiSyncOptionsSection"; export { PkiSyncOptionsSection } from "./PkiSyncOptionsSection";
@@ -2,17 +2,20 @@ import { useState } from "react";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ProjectPermissionCan } from "@app/components/permissions";
import { ContentLoader, PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; import { ContentLoader, PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useProject } from "@app/context"; import { useProject } from "@app/context";
import { ProjectType } from "@app/hooks/api/projects/types"; import { ProjectType } from "@app/hooks/api/projects/types";
import { CertificateProfilesTab } from "./components/CertificateProfilesTab"; import { CertificateProfilesTab } from "./components/CertificateProfilesTab";
import { CertificatesTab } from "./components/CertificatesTab";
import { CertificateTemplatesV2Tab } from "./components/CertificateTemplatesV2Tab"; import { CertificateTemplatesV2Tab } from "./components/CertificateTemplatesV2Tab";
import { PkiCollectionsTab } from "./components/PkiCollectionsTab";
enum TabSections { enum TabSections {
CertificateProfiles = "profiles", CertificateProfiles = "profiles",
CertificateTemplatesV2 = "templates-v2" CertificateTemplatesV2 = "templates-v2",
Certificates = "certificates",
PkiCollections = "pki-collections"
} }
export const PoliciesPage = () => { export const PoliciesPage = () => {
@@ -25,59 +28,54 @@ export const PoliciesPage = () => {
} }
return ( return (
<ProjectPermissionCan <div className="mx-auto flex h-full flex-col justify-between bg-bunker-800 text-white">
I={ProjectPermissionActions.Read} <Helmet>
a={ProjectPermissionSub.CertificateAuthorities} <title>{t("common.head-title", { title: "Certificate Management" })}</title>
> </Helmet>
{(isAllowed) => { <div className="mx-auto mb-6 w-full max-w-8xl">
if (!isAllowed) { <PageHeader
return ( scope={ProjectType.CertificateManager}
<div className="mx-auto flex h-full flex-col justify-center bg-bunker-800 text-white"> title="Certificate Management"
<div className="mx-auto mb-6 w-full max-w-8xl text-center"> description="Streamline certificate management by creating and maintaining templates, profiles, and certificates in one place"
<p>You don&apos;t have permission to access certificate policies.</p> />
</div>
</div>
);
}
return ( <Tabs
<div className="mx-auto flex flex-col justify-between bg-bunker-800 text-white"> orientation="vertical"
<Helmet> value={activeTab}
<title>{t("common.head-title", { title: "Certificate Policies" })}</title> onValueChange={(value) => setActiveTab(value as TabSections)}
</Helmet> >
<div className="mx-auto mb-6 w-full max-w-8xl"> <TabList>
<PageHeader <Tab variant="project" value={TabSections.CertificateProfiles}>
scope={ProjectType.CertificateManager} Certificate Profiles
title="Certificate Policies" </Tab>
description="Manage certificate templates and profiles for unified certificate issuance" <Tab variant="project" value={TabSections.CertificateTemplatesV2}>
/> Certificate Templates
</Tab>
<Tab variant="project" value={TabSections.Certificates}>
Certificates
</Tab>
<Tab variant="project" value={TabSections.PkiCollections}>
Certificate Collections
</Tab>
</TabList>
<Tabs <TabPanel value={TabSections.CertificateProfiles}>
orientation="vertical" <CertificateProfilesTab />
value={activeTab} </TabPanel>
onValueChange={(value) => setActiveTab(value as TabSections)}
>
<TabList>
<Tab variant="project" value={TabSections.CertificateProfiles}>
Certificate Profiles
</Tab>
<Tab variant="project" value={TabSections.CertificateTemplatesV2}>
Certificate Templates
</Tab>
</TabList>
<TabPanel value={TabSections.CertificateProfiles}> <TabPanel value={TabSections.CertificateTemplatesV2}>
<CertificateProfilesTab /> <CertificateTemplatesV2Tab />
</TabPanel> </TabPanel>
<TabPanel value={TabSections.CertificateTemplatesV2}> <TabPanel value={TabSections.Certificates}>
<CertificateTemplatesV2Tab /> <CertificatesTab />
</TabPanel> </TabPanel>
</Tabs>
</div> <TabPanel value={TabSections.PkiCollections}>
</div> <PkiCollectionsTab />
); </TabPanel>
}} </Tabs>
</ProjectPermissionCan> </div>
</div>
); );
}; };
@@ -418,7 +418,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
name="enrollmentType" name="enrollmentType"
render={({ field: { onChange, ...field }, fieldState: { error } }) => ( render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl <FormControl
label="Enrollment Type" label="Enrollment Method"
isRequired isRequired
isError={Boolean(error)} isError={Boolean(error)}
errorText={error?.message} errorText={error?.message}
@@ -29,8 +29,7 @@ export const ProfileList = ({ onEditProfile, onDeleteProfile }: Props) => {
projectId: currentProject?.id || "", projectId: currentProject?.id || "",
limit: 100, limit: 100,
offset: 0, offset: 0,
includeConfigs: true, includeConfigs: true
includeMetrics: true
}); });
const profiles = data?.certificateProfiles || []; const profiles = data?.certificateProfiles || [];
@@ -42,10 +41,9 @@ export const ProfileList = ({ onEditProfile, onDeleteProfile }: Props) => {
<THead> <THead>
<Tr> <Tr>
<Th>Name</Th> <Th>Name</Th>
<Th>Enrollment Type</Th> <Th>Enrollment Method</Th>
<Th>Issuing CA</Th> <Th>Issuing CA</Th>
<Th>Certificate Template</Th> <Th>Certificate Template</Th>
<Th>Certificates</Th>
<Th className="w-5" /> <Th className="w-5" />
</Tr> </Tr>
</THead> </THead>
@@ -67,10 +65,9 @@ export const ProfileList = ({ onEditProfile, onDeleteProfile }: Props) => {
<THead> <THead>
<Tr> <Tr>
<Th>Name</Th> <Th>Name</Th>
<Th>Enrollment Type</Th> <Th>Enrollment Method</Th>
<Th>Issuing CA</Th> <Th>Issuing CA</Th>
<Th>Certificate Template</Th> <Th>Certificate Template</Th>
<Th>Certificates</Th>
<Th className="w-5" /> <Th className="w-5" />
</Tr> </Tr>
</THead> </THead>
@@ -33,43 +33,6 @@ import { TCertificateProfile } from "@app/hooks/api/certificateProfiles";
import { useGetCertificateTemplateV2ById } from "@app/hooks/api/certificateTemplates/queries"; import { useGetCertificateTemplateV2ById } from "@app/hooks/api/certificateTemplates/queries";
import { CertificateIssuanceModal } from "@app/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal"; import { CertificateIssuanceModal } from "@app/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal";
const MetricsBadges = ({
metrics
}: {
metrics?: {
totalCertificates: number;
activeCertificates: number;
expiringCertificates: number;
expiredCertificates: number;
revokedCertificates: number;
};
}) => {
if (!metrics) {
return <Badge variant="warning">No metrics</Badge>;
}
if (metrics.totalCertificates === 0) {
return <Badge variant="warning">No certificates</Badge>;
}
return (
<>
{metrics.activeCertificates > 0 && (
<Badge variant="success">{metrics.activeCertificates} active</Badge>
)}
{metrics.expiringCertificates > 0 && (
<Badge variant="warning">{metrics.expiringCertificates} expiring</Badge>
)}
{metrics.expiredCertificates > 0 && (
<Badge variant="danger">{metrics.expiredCertificates} expired</Badge>
)}
{metrics.revokedCertificates > 0 && (
<Badge variant="danger">{metrics.revokedCertificates} revoked</Badge>
)}
</>
);
};
interface Props { interface Props {
profile: TCertificateProfile; profile: TCertificateProfile;
onEditProfile: (profile: TCertificateProfile) => void; onEditProfile: (profile: TCertificateProfile) => void;
@@ -118,8 +81,8 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) =
const getEnrollmentTypeBadge = (enrollmentType: string) => { const getEnrollmentTypeBadge = (enrollmentType: string) => {
const config = { const config = {
api: { variant: "success" as const, label: "API" }, api: { variant: "ghost" as const, label: "API" },
est: { variant: "warning" as const, label: "EST" } est: { variant: "ghost" as const, label: "EST" }
} as const; } as const;
const configKey = Object.keys(config).includes(enrollmentType) const configKey = Object.keys(config).includes(enrollmentType)
@@ -153,11 +116,6 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) =
{templateData?.name || profile.certificateTemplateId} {templateData?.name || profile.certificateTemplateId}
</span> </span>
</Td> </Td>
<Td>
<div className="flex flex-wrap gap-1">
<MetricsBadges metrics={profile.metrics} />
</div>
</Td>
<Td className="text-right"> <Td className="text-right">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg"> <DropdownMenuTrigger asChild className="rounded-lg">
@@ -0,0 +1,5 @@
import { CertificatesSection } from "../../../CertificatesPage/components/CertificatesSection";
export const CertificatesTab = () => {
return <CertificatesSection />;
};
@@ -0,0 +1 @@
export { CertificatesTab } from "./CertificatesTab";
@@ -0,0 +1,5 @@
import { PkiCollectionSection } from "../../../AlertingPage/components/PkiCollectionSection";
export const PkiCollectionsTab = () => {
return <PkiCollectionSection />;
};
@@ -0,0 +1 @@
export { PkiCollectionsTab } from "./PkiCollectionsTab";
@@ -1,2 +1,4 @@
export { CertificateProfilesTab } from "./CertificateProfilesTab"; export { CertificateProfilesTab } from "./CertificateProfilesTab";
export { CertificatesTab } from "./CertificatesTab";
export { CertificateTemplatesV2Tab } from "./CertificateTemplatesV2Tab"; export { CertificateTemplatesV2Tab } from "./CertificateTemplatesV2Tab";
export { PkiCollectionsTab } from "./PkiCollectionsTab";
@@ -633,7 +633,9 @@ export const OAuthCallbackPage = () => {
connectionName: data.connection.name, connectionName: data.connection.name,
...(data.returnUrl.includes("integrations") ...(data.returnUrl.includes("integrations")
? { ? {
selectedTab: IntegrationsListPageTabs.SecretSyncs selectedTab: localStorage.getItem("pkiSyncFormData")
? IntegrationsListPageTabs.PkiSyncs
: IntegrationsListPageTabs.SecretSyncs
} }
: {}) : {})
} }

Some files were not shown because too many files have changed in this diff Show More