Merge branch 'main' into fix/remove-duplicate-error-notifications

This commit is contained in:
Victor Santos
2025-11-03 18:41:54 -03:00
283 changed files with 6603 additions and 1582 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
@@ -25,7 +25,7 @@ import { KmsDataKey } from "@app/services/kms/kms-types";
import { TNotificationServiceFactory } from "@app/services/notification/notification-service"; import { TNotificationServiceFactory } from "@app/services/notification/notification-service";
import { NotificationType } from "@app/services/notification/notification-types"; import { NotificationType } from "@app/services/notification/notification-types";
import { TOrgDALFactory } from "@app/services/org/org-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal";
import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; import { TSmtpService } from "@app/services/smtp/smtp-service";
import { TLicenseServiceFactory } from "../license/license-service"; import { TLicenseServiceFactory } from "../license/license-service";
import { PamResource } from "../pam-resource/pam-resource-enums"; import { PamResource } from "../pam-resource/pam-resource-enums";
@@ -61,8 +61,7 @@ export const gatewayV2ServiceFactory = ({
relayDAL, relayDAL,
permissionService, permissionService,
orgDAL, orgDAL,
notificationService, notificationService
smtpService
}: TGatewayV2ServiceFactoryDep) => { }: TGatewayV2ServiceFactoryDep) => {
const $validateIdentityAccessToGateway = async (orgId: string, actorId: string, actorAuthMethod: ActorAuthMethod) => { const $validateIdentityAccessToGateway = async (orgId: string, actorId: string, actorAuthMethod: ActorAuthMethod) => {
const orgLicensePlan = await licenseService.getPlan(orgId); const orgLicensePlan = await licenseService.getPlan(orgId);
@@ -931,15 +930,17 @@ export const gatewayV2ServiceFactory = ({
})) }))
); );
await smtpService.sendMail({ // Temporarily disabled email notifications due to excessive noise. Will be revised later
recipients: admins.map((admin) => admin.user.email).filter((v): v is string => !!v), //
subjectLine: "Gateway Health Alert", // await smtpService.sendMail({
substitutions: { // recipients: admins.map((admin) => admin.user.email).filter((v): v is string => !!v),
type: "gateway", // subjectLine: "Gateway Health Alert",
names: gatewayNames // substitutions: {
}, // type: "gateway",
template: SmtpTemplates.HealthAlert // names: gatewayNames
}); // },
// template: SmtpTemplates.HealthAlert
// });
await Promise.all(gateways.map((gw) => gatewayV2DAL.updateById(gw.id, { healthAlertedAt: new Date() }))); await Promise.all(gateways.map((gw) => gatewayV2DAL.updateById(gw.id, { healthAlertedAt: new Date() })));
} catch (error) { } catch (error) {
@@ -1,6 +1,5 @@
import knex from "knex"; import knex from "knex";
import mysql, { Connection } from "mysql2/promise"; import mysql, { Connection } from "mysql2/promise";
import * as pg from "pg";
import tls, { PeerCertificate } from "tls"; import tls, { PeerCertificate } from "tls";
import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic-secret-fns"; import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic-secret-fns";
@@ -97,7 +96,7 @@ const makeSqlConnection = (
try { try {
await client.raw(SIMPLE_QUERY); await client.raw(SIMPLE_QUERY);
} catch (error) { } catch (error) {
if (error instanceof pg.DatabaseError) { if (error instanceof Error) {
// Hacky way to know if we successfully hit the database. // Hacky way to know if we successfully hit the database.
// TODO: potentially two approaches to solve the problem. // TODO: potentially two approaches to solve the problem.
// 1. change the work flow, add account first then resource // 1. change the work flow, add account first then resource
+11 -9
View File
@@ -1268,15 +1268,17 @@ export const relayServiceFactory = ({
})) }))
); );
await smtpService.sendMail({ // Temporarily disabled email notifications due to excessive noise. Will be revised later
recipients: admins.map((admin) => admin.user.email).filter((v): v is string => !!v), //
subjectLine: "Relay Health Alert", // await smtpService.sendMail({
substitutions: { // recipients: admins.map((admin) => admin.user.email).filter((v): v is string => !!v),
type: "relay", // subjectLine: "Relay Health Alert",
names: relayNames // substitutions: {
}, // type: "relay",
template: SmtpTemplates.HealthAlert // names: relayNames
}); // },
// template: SmtpTemplates.HealthAlert
// });
} }
await Promise.all(relays.map((r) => relayDAL.updateById(r.id, { healthAlertedAt: new Date() }))); await Promise.all(relays.map((r) => relayDAL.updateById(r.id, { healthAlertedAt: new Date() })));
+10
View File
@@ -2379,6 +2379,12 @@ export const AppConnections = {
}, },
LARAVEL_FORGE: { LARAVEL_FORGE: {
apiToken: "The API token used to authenticate with Laravel Forge." apiToken: "The API token used to authenticate with Laravel Forge."
},
CHEF: {
serverUrl: "The URL of the Chef server to connect to.",
orgName: "The short name of the Chef organization to connect to.",
userName: "The username used to access Chef.",
privateKey: "The private key used to access Chef."
} }
} }
}; };
@@ -2624,6 +2630,10 @@ export const SecretSyncs = {
siteId: "The ID of the Netlify site to sync secrets to.", siteId: "The ID of the Netlify site to sync secrets to.",
context: "The Netlify context to sync secrets to." context: "The Netlify context to sync secrets to."
}, },
CHEF: {
dataBagName: "The name of the Chef data bag to sync secrets to.",
dataBagItemName: "The name of the Chef data bag item to sync secrets to."
},
NORTHFLANK: { NORTHFLANK: {
projectId: "The ID of the Northflank project to sync secrets to.", projectId: "The ID of the Northflank project to sync secrets to.",
projectName: "The name of the Northflank project to sync secrets to.", projectName: "The name of the Northflank project to sync secrets to.",
+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,
@@ -48,6 +48,7 @@ import {
ChecklyConnectionListItemSchema, ChecklyConnectionListItemSchema,
SanitizedChecklyConnectionSchema SanitizedChecklyConnectionSchema
} from "@app/services/app-connection/checkly"; } from "@app/services/app-connection/checkly";
import { ChefConnectionListItemSchema, SanitizedChefConnectionSchema } from "@app/services/app-connection/chef";
import { import {
CloudflareConnectionListItemSchema, CloudflareConnectionListItemSchema,
SanitizedCloudflareConnectionSchema SanitizedCloudflareConnectionSchema
@@ -168,7 +169,8 @@ const SanitizedAppConnectionSchema = z.union([
...SanitizedOktaConnectionSchema.options, ...SanitizedOktaConnectionSchema.options,
...SanitizedAzureADCSConnectionSchema.options, ...SanitizedAzureADCSConnectionSchema.options,
...SanitizedRedisConnectionSchema.options, ...SanitizedRedisConnectionSchema.options,
...SanitizedLaravelForgeConnectionSchema.options ...SanitizedLaravelForgeConnectionSchema.options,
...SanitizedChefConnectionSchema.options
]); ]);
const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [
@@ -212,7 +214,8 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [
OktaConnectionListItemSchema, OktaConnectionListItemSchema,
AzureADCSConnectionListItemSchema, AzureADCSConnectionListItemSchema,
RedisConnectionListItemSchema, RedisConnectionListItemSchema,
LaravelForgeConnectionListItemSchema LaravelForgeConnectionListItemSchema,
ChefConnectionListItemSchema
]); ]);
export const registerAppConnectionRouter = async (server: FastifyZodProvider) => { export const registerAppConnectionRouter = async (server: FastifyZodProvider) => {
@@ -0,0 +1,85 @@
import z from "zod";
import { readLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import {
CreateChefConnectionSchema,
SanitizedChefConnectionSchema,
UpdateChefConnectionSchema
} from "@app/services/app-connection/chef";
import { AuthMode } from "@app/services/auth/auth-type";
import { registerAppConnectionEndpoints } from "./app-connection-endpoints";
export const registerChefConnectionRouter = async (server: FastifyZodProvider) => {
registerAppConnectionEndpoints({
app: AppConnection.Chef,
server,
sanitizedResponseSchema: SanitizedChefConnectionSchema,
createSchema: CreateChefConnectionSchema,
updateSchema: UpdateChefConnectionSchema
});
server.route({
method: "GET",
url: `/:connectionId/data-bags`,
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
connectionId: z.string().uuid()
}),
response: {
200: z
.object({
name: z.string()
})
.array()
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const { connectionId } = req.params;
const dataBags = await server.services.appConnection.chef.listDataBags(connectionId, req.permission);
return dataBags;
}
});
server.route({
method: "GET",
url: `/:connectionId/data-bag-items`,
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
connectionId: z.string().uuid()
}),
querystring: z.object({
dataBagName: z.string()
}),
response: {
200: z
.object({
name: z.string()
})
.array()
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const { connectionId } = req.params;
const { dataBagName } = req.query;
const dataBagItems = await server.services.appConnection.chef.listDataBagItems(
connectionId,
dataBagName,
req.permission
);
return dataBagItems;
}
});
};
@@ -13,6 +13,7 @@ import { registerAzureKeyVaultConnectionRouter } from "./azure-key-vault-connect
import { registerBitbucketConnectionRouter } from "./bitbucket-connection-router"; import { registerBitbucketConnectionRouter } from "./bitbucket-connection-router";
import { registerCamundaConnectionRouter } from "./camunda-connection-router"; import { registerCamundaConnectionRouter } from "./camunda-connection-router";
import { registerChecklyConnectionRouter } from "./checkly-connection-router"; import { registerChecklyConnectionRouter } from "./checkly-connection-router";
import { registerChefConnectionRouter } from "./chef-connection-router";
import { registerCloudflareConnectionRouter } from "./cloudflare-connection-router"; import { registerCloudflareConnectionRouter } from "./cloudflare-connection-router";
import { registerDatabricksConnectionRouter } from "./databricks-connection-router"; import { registerDatabricksConnectionRouter } from "./databricks-connection-router";
import { registerDigitalOceanConnectionRouter } from "./digital-ocean-connection-router"; import { registerDigitalOceanConnectionRouter } from "./digital-ocean-connection-router";
@@ -86,5 +87,6 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record<AppConnection, (server:
[AppConnection.Netlify]: registerNetlifyConnectionRouter, [AppConnection.Netlify]: registerNetlifyConnectionRouter,
[AppConnection.Northflank]: registerNorthflankConnectionRouter, [AppConnection.Northflank]: registerNorthflankConnectionRouter,
[AppConnection.Okta]: registerOktaConnectionRouter, [AppConnection.Okta]: registerOktaConnectionRouter,
[AppConnection.Redis]: registerRedisConnectionRouter [AppConnection.Redis]: registerRedisConnectionRouter,
[AppConnection.Chef]: registerChefConnectionRouter
}; };
@@ -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({
@@ -0,0 +1,13 @@
import { ChefSyncSchema, CreateChefSyncSchema, UpdateChefSyncSchema } from "@app/services/secret-sync/chef";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
import { registerSyncSecretsEndpoints } from "./secret-sync-endpoints";
export const registerChefSyncRouter = async (server: FastifyZodProvider) =>
registerSyncSecretsEndpoints({
destination: SecretSync.Chef,
server,
responseSchema: ChefSyncSchema,
createSchema: CreateChefSyncSchema,
updateSchema: UpdateChefSyncSchema
});
@@ -10,6 +10,7 @@ import { registerAzureKeyVaultSyncRouter } from "./azure-key-vault-sync-router";
import { registerBitbucketSyncRouter } from "./bitbucket-sync-router"; import { registerBitbucketSyncRouter } from "./bitbucket-sync-router";
import { registerCamundaSyncRouter } from "./camunda-sync-router"; import { registerCamundaSyncRouter } from "./camunda-sync-router";
import { registerChecklySyncRouter } from "./checkly-sync-router"; import { registerChecklySyncRouter } from "./checkly-sync-router";
import { registerChefSyncRouter } from "./chef-sync-router";
import { registerCloudflarePagesSyncRouter } from "./cloudflare-pages-sync-router"; import { registerCloudflarePagesSyncRouter } from "./cloudflare-pages-sync-router";
import { registerCloudflareWorkersSyncRouter } from "./cloudflare-workers-sync-router"; import { registerCloudflareWorkersSyncRouter } from "./cloudflare-workers-sync-router";
import { registerDatabricksSyncRouter } from "./databricks-sync-router"; import { registerDatabricksSyncRouter } from "./databricks-sync-router";
@@ -67,5 +68,6 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record<SecretSync, (server: Fastif
[SecretSync.Netlify]: registerNetlifySyncRouter, [SecretSync.Netlify]: registerNetlifySyncRouter,
[SecretSync.Northflank]: registerNorthflankSyncRouter, [SecretSync.Northflank]: registerNorthflankSyncRouter,
[SecretSync.Bitbucket]: registerBitbucketSyncRouter, [SecretSync.Bitbucket]: registerBitbucketSyncRouter,
[SecretSync.LaravelForge]: registerLaravelForgeSyncRouter [SecretSync.LaravelForge]: registerLaravelForgeSyncRouter,
[SecretSync.Chef]: registerChefSyncRouter
}; };
@@ -24,6 +24,7 @@ import { AzureKeyVaultSyncListItemSchema, AzureKeyVaultSyncSchema } from "@app/s
import { BitbucketSyncListItemSchema, BitbucketSyncSchema } from "@app/services/secret-sync/bitbucket"; import { BitbucketSyncListItemSchema, BitbucketSyncSchema } from "@app/services/secret-sync/bitbucket";
import { CamundaSyncListItemSchema, CamundaSyncSchema } from "@app/services/secret-sync/camunda"; import { CamundaSyncListItemSchema, CamundaSyncSchema } from "@app/services/secret-sync/camunda";
import { ChecklySyncListItemSchema, ChecklySyncSchema } from "@app/services/secret-sync/checkly/checkly-sync-schemas"; import { ChecklySyncListItemSchema, ChecklySyncSchema } from "@app/services/secret-sync/checkly/checkly-sync-schemas";
import { ChefSyncListItemSchema, ChefSyncSchema } from "@app/services/secret-sync/chef";
import { import {
CloudflarePagesSyncListItemSchema, CloudflarePagesSyncListItemSchema,
CloudflarePagesSyncSchema CloudflarePagesSyncSchema
@@ -88,7 +89,8 @@ const SecretSyncSchema = z.discriminatedUnion("destination", [
NetlifySyncSchema, NetlifySyncSchema,
NorthflankSyncSchema, NorthflankSyncSchema,
BitbucketSyncSchema, BitbucketSyncSchema,
LaravelForgeSyncSchema LaravelForgeSyncSchema,
ChefSyncSchema
]); ]);
const SecretSyncOptionsSchema = z.discriminatedUnion("destination", [ const SecretSyncOptionsSchema = z.discriminatedUnion("destination", [
@@ -123,7 +125,8 @@ const SecretSyncOptionsSchema = z.discriminatedUnion("destination", [
NetlifySyncListItemSchema, NetlifySyncListItemSchema,
NorthflankSyncListItemSchema, NorthflankSyncListItemSchema,
BitbucketSyncListItemSchema, BitbucketSyncListItemSchema,
LaravelForgeSyncListItemSchema LaravelForgeSyncListItemSchema,
ChefSyncListItemSchema
]); ]);
export const registerSecretSyncRouter = async (server: FastifyZodProvider) => { export const registerSecretSyncRouter = async (server: FastifyZodProvider) => {
@@ -39,6 +39,7 @@ export enum AppConnection {
Okta = "okta", Okta = "okta",
Redis = "redis", Redis = "redis",
LaravelForge = "laravel-forge", LaravelForge = "laravel-forge",
Chef = "chef",
Northflank = "northflank" Northflank = "northflank"
} }
@@ -68,6 +68,7 @@ import {
} from "./bitbucket"; } from "./bitbucket";
import { CamundaConnectionMethod, getCamundaConnectionListItem, validateCamundaConnectionCredentials } from "./camunda"; import { CamundaConnectionMethod, getCamundaConnectionListItem, validateCamundaConnectionCredentials } from "./camunda";
import { ChecklyConnectionMethod, getChecklyConnectionListItem, validateChecklyConnectionCredentials } from "./checkly"; import { ChecklyConnectionMethod, getChecklyConnectionListItem, validateChecklyConnectionCredentials } from "./checkly";
import { ChefConnectionMethod, getChefConnectionListItem, validateChefConnectionCredentials } from "./chef";
import { CloudflareConnectionMethod } from "./cloudflare/cloudflare-connection-enum"; import { CloudflareConnectionMethod } from "./cloudflare/cloudflare-connection-enum";
import { import {
getCloudflareConnectionListItem, getCloudflareConnectionListItem,
@@ -210,7 +211,8 @@ export const listAppConnectionOptions = (projectType?: ProjectType) => {
getNetlifyConnectionListItem(), getNetlifyConnectionListItem(),
getNorthflankConnectionListItem(), getNorthflankConnectionListItem(),
getOktaConnectionListItem(), getOktaConnectionListItem(),
getRedisConnectionListItem() getRedisConnectionListItem(),
getChefConnectionListItem()
] ]
.filter((option) => { .filter((option) => {
switch (projectType) { switch (projectType) {
@@ -341,6 +343,7 @@ export const validateAppConnectionCredentials = async (
[AppConnection.Netlify]: validateNetlifyConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Netlify]: validateNetlifyConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Northflank]: validateNorthflankConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Northflank]: validateNorthflankConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Okta]: validateOktaConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Okta]: validateOktaConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Chef]: validateChefConnectionCredentials as TAppConnectionCredentialsValidator,
[AppConnection.Redis]: validateRedisConnectionCredentials as TAppConnectionCredentialsValidator [AppConnection.Redis]: validateRedisConnectionCredentials as TAppConnectionCredentialsValidator
}; };
@@ -409,6 +412,8 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) =>
case RenderConnectionMethod.ApiKey: case RenderConnectionMethod.ApiKey:
case ChecklyConnectionMethod.ApiKey: case ChecklyConnectionMethod.ApiKey:
return "API Key"; return "API Key";
case ChefConnectionMethod.UserKey:
return "User Key";
case SupabaseConnectionMethod.AccessToken: case SupabaseConnectionMethod.AccessToken:
return "Access Token"; return "Access Token";
default: default:
@@ -483,7 +488,8 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record<
[AppConnection.Northflank]: platformManagedCredentialsNotSupported, [AppConnection.Northflank]: platformManagedCredentialsNotSupported,
[AppConnection.Okta]: platformManagedCredentialsNotSupported, [AppConnection.Okta]: platformManagedCredentialsNotSupported,
[AppConnection.Redis]: platformManagedCredentialsNotSupported, [AppConnection.Redis]: platformManagedCredentialsNotSupported,
[AppConnection.LaravelForge]: platformManagedCredentialsNotSupported [AppConnection.LaravelForge]: platformManagedCredentialsNotSupported,
[AppConnection.Chef]: platformManagedCredentialsNotSupported
}; };
export const enterpriseAppCheck = async ( export const enterpriseAppCheck = async (
@@ -41,6 +41,7 @@ export const APP_CONNECTION_NAME_MAP: Record<AppConnection, string> = {
[AppConnection.Netlify]: "Netlify", [AppConnection.Netlify]: "Netlify",
[AppConnection.Okta]: "Okta", [AppConnection.Okta]: "Okta",
[AppConnection.Redis]: "Redis", [AppConnection.Redis]: "Redis",
[AppConnection.Chef]: "Chef",
[AppConnection.Northflank]: "Northflank" [AppConnection.Northflank]: "Northflank"
}; };
@@ -85,5 +86,6 @@ export const APP_CONNECTION_PLAN_MAP: Record<AppConnection, AppConnectionPlanTyp
[AppConnection.Netlify]: AppConnectionPlanType.Regular, [AppConnection.Netlify]: AppConnectionPlanType.Regular,
[AppConnection.Okta]: AppConnectionPlanType.Regular, [AppConnection.Okta]: AppConnectionPlanType.Regular,
[AppConnection.Redis]: AppConnectionPlanType.Regular, [AppConnection.Redis]: AppConnectionPlanType.Regular,
[AppConnection.Chef]: AppConnectionPlanType.Regular,
[AppConnection.Northflank]: AppConnectionPlanType.Regular [AppConnection.Northflank]: AppConnectionPlanType.Regular
}; };
@@ -67,6 +67,8 @@ import { ValidateCamundaConnectionCredentialsSchema } from "./camunda";
import { camundaConnectionService } from "./camunda/camunda-connection-service"; import { camundaConnectionService } from "./camunda/camunda-connection-service";
import { ValidateChecklyConnectionCredentialsSchema } from "./checkly"; import { ValidateChecklyConnectionCredentialsSchema } from "./checkly";
import { checklyConnectionService } from "./checkly/checkly-connection-service"; import { checklyConnectionService } from "./checkly/checkly-connection-service";
import { ValidateChefConnectionCredentialsSchema } from "./chef";
import { chefConnectionService } from "./chef/chef-connection-service";
import { ValidateCloudflareConnectionCredentialsSchema } from "./cloudflare/cloudflare-connection-schema"; import { ValidateCloudflareConnectionCredentialsSchema } from "./cloudflare/cloudflare-connection-schema";
import { cloudflareConnectionService } from "./cloudflare/cloudflare-connection-service"; import { cloudflareConnectionService } from "./cloudflare/cloudflare-connection-service";
import { ValidateDatabricksConnectionCredentialsSchema } from "./databricks"; import { ValidateDatabricksConnectionCredentialsSchema } from "./databricks";
@@ -174,7 +176,8 @@ const VALIDATE_APP_CONNECTION_CREDENTIALS_MAP: Record<AppConnection, TValidateAp
[AppConnection.Netlify]: ValidateNetlifyConnectionCredentialsSchema, [AppConnection.Netlify]: ValidateNetlifyConnectionCredentialsSchema,
[AppConnection.Northflank]: ValidateNorthflankConnectionCredentialsSchema, [AppConnection.Northflank]: ValidateNorthflankConnectionCredentialsSchema,
[AppConnection.Okta]: ValidateOktaConnectionCredentialsSchema, [AppConnection.Okta]: ValidateOktaConnectionCredentialsSchema,
[AppConnection.Redis]: ValidateRedisConnectionCredentialsSchema [AppConnection.Redis]: ValidateRedisConnectionCredentialsSchema,
[AppConnection.Chef]: ValidateChefConnectionCredentialsSchema
}; };
export const appConnectionServiceFactory = ({ export const appConnectionServiceFactory = ({
@@ -881,6 +884,7 @@ export const appConnectionServiceFactory = ({
netlify: netlifyConnectionService(connectAppConnectionById), netlify: netlifyConnectionService(connectAppConnectionById),
northflank: northflankConnectionService(connectAppConnectionById), northflank: northflankConnectionService(connectAppConnectionById),
okta: oktaConnectionService(connectAppConnectionById), okta: oktaConnectionService(connectAppConnectionById),
laravelForge: laravelForgeConnectionService(connectAppConnectionById) laravelForge: laravelForgeConnectionService(connectAppConnectionById),
chef: chefConnectionService(connectAppConnectionById)
}; };
}; };
@@ -82,6 +82,12 @@ import {
TChecklyConnectionInput, TChecklyConnectionInput,
TValidateChecklyConnectionCredentialsSchema TValidateChecklyConnectionCredentialsSchema
} from "./checkly"; } from "./checkly";
import {
TChefConnection,
TChefConnectionConfig,
TChefConnectionInput,
TValidateChefConnectionCredentialsSchema
} from "./chef";
import { import {
TCloudflareConnection, TCloudflareConnection,
TCloudflareConnectionConfig, TCloudflareConnectionConfig,
@@ -282,6 +288,7 @@ export type TAppConnection = { id: string } & (
| TNorthflankConnection | TNorthflankConnection
| TOktaConnection | TOktaConnection
| TRedisConnection | TRedisConnection
| TChefConnection
); );
export type TAppConnectionRaw = NonNullable<Awaited<ReturnType<TAppConnectionDALFactory["findById"]>>>; export type TAppConnectionRaw = NonNullable<Awaited<ReturnType<TAppConnectionDALFactory["findById"]>>>;
@@ -330,6 +337,7 @@ export type TAppConnectionInput = { id: string } & (
| TNorthflankConnectionInput | TNorthflankConnectionInput
| TOktaConnectionInput | TOktaConnectionInput
| TRedisConnectionInput | TRedisConnectionInput
| TChefConnectionInput
); );
export type TSqlConnectionInput = export type TSqlConnectionInput =
@@ -395,7 +403,8 @@ export type TAppConnectionConfig =
| TNetlifyConnectionConfig | TNetlifyConnectionConfig
| TNorthflankConnectionConfig | TNorthflankConnectionConfig
| TOktaConnectionConfig | TOktaConnectionConfig
| TRedisConnectionConfig; | TRedisConnectionConfig
| TChefConnectionConfig;
export type TValidateAppConnectionCredentialsSchema = export type TValidateAppConnectionCredentialsSchema =
| TValidateAwsConnectionCredentialsSchema | TValidateAwsConnectionCredentialsSchema
@@ -438,7 +447,8 @@ export type TValidateAppConnectionCredentialsSchema =
| TValidateNetlifyConnectionCredentialsSchema | TValidateNetlifyConnectionCredentialsSchema
| TValidateNorthflankConnectionCredentialsSchema | TValidateNorthflankConnectionCredentialsSchema
| TValidateOktaConnectionCredentialsSchema | TValidateOktaConnectionCredentialsSchema
| TValidateRedisConnectionCredentialsSchema; | TValidateRedisConnectionCredentialsSchema
| TValidateChefConnectionCredentialsSchema;
export type TListAwsConnectionKmsKeys = { export type TListAwsConnectionKmsKeys = {
connectionId: string; connectionId: string;
@@ -0,0 +1,3 @@
export enum ChefConnectionMethod {
UserKey = "user-key"
}
@@ -0,0 +1,288 @@
import { AxiosError } from "axios";
import crypto from "crypto";
import { request } from "@app/lib/config/request";
import { BadRequestError } from "@app/lib/errors";
import { removeTrailingSlash } from "@app/lib/fn";
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator";
import { IntegrationUrls } from "@app/services/integration-auth/integration-list";
import { TChefDataBagItemContent } from "../../secret-sync/chef/chef-sync-types";
import { AppConnection } from "../app-connection-enums";
import { ChefConnectionMethod } from "./chef-connection-enums";
import {
TChefConnection,
TChefConnectionConfig,
TChefDataBag,
TChefDataBagItem,
TGetChefDataBagItem,
TUpdateChefDataBagItem
} from "./chef-connection-types";
export const getChefServerUrl = async (serverUrl?: string) => {
const chefServerUrl = serverUrl ? removeTrailingSlash(serverUrl) : IntegrationUrls.CHEF_API_URL;
await blockLocalAndPrivateIpAddresses(chefServerUrl);
return chefServerUrl;
};
// Helper to ensure private key is in proper PEM format
const formatPrivateKey = (key: string): string => {
let formattedKey = key.trim();
// Ensure proper line breaks in PEM format (handle escaped newlines)
formattedKey = formattedKey.replace(/\\n/g, "\n");
// Remove any extra whitespace between lines
formattedKey = formattedKey.replace(/\n\s+/g, "\n");
// If key doesn't have headers, add PKCS#1 RSA headers
if (!formattedKey.includes("BEGIN")) {
formattedKey = `-----BEGIN RSA PRIVATE KEY-----\n${formattedKey}\n-----END RSA PRIVATE KEY-----`;
}
// Ensure the key has proper line breaks after headers and before footers
formattedKey = formattedKey.replace(/(-----BEGIN[^-]+-----)\s*/g, "$1\n").replace(/\s*(-----END[^-]+-----)/g, "\n$1");
// Remove any duplicate newlines
formattedKey = formattedKey.replace(/\n{3,}/g, "\n\n");
return formattedKey;
};
const getChefAuthHeaders = (
method: string,
path: string,
body: string,
userId: string,
privateKey: string,
apiVersion: "1.0" | "1.3" = "1.3"
) => {
const timestamp = new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); // Remove milliseconds from timestamp
// Calculate content hash based on version
let contentHash: string;
if (apiVersion === "1.3") {
contentHash = crypto.createHash("sha256").update(body).digest("base64");
} else {
contentHash = crypto.createHash("sha1").update(body).digest("base64");
}
// Build canonical request based on version
let canonicalRequest: string;
if (apiVersion === "1.3") {
canonicalRequest = [
`Method:${method}`,
`Path:${path}`,
`X-Ops-Content-Hash:${contentHash}`,
"X-Ops-Sign:version=1.3",
`X-Ops-Timestamp:${timestamp}`,
`X-Ops-UserId:${userId}`,
"X-Ops-Server-API-Version:1"
].join("\n");
} else {
const hashedPath = crypto.createHash("sha1").update(path).digest("base64");
canonicalRequest = [
`Method:${method}`,
`Hashed Path:${hashedPath}`,
`X-Ops-Content-Hash:${contentHash}`,
`X-Ops-Timestamp:${timestamp}`,
`X-Ops-UserId:${userId}`
].join("\n");
}
// Format the private key properly
const formattedKey = formatPrivateKey(privateKey);
// Sign the canonical request
const sign = crypto.createSign(apiVersion === "1.3" ? "RSA-SHA256" : "RSA-SHA1");
sign.update(canonicalRequest);
const signature = sign.sign(formattedKey, "base64");
// Split signature into 60-character chunks
const authHeaders: Record<string, string> = {};
const signatureLines = signature.match(/.{1,60}/g) || [];
signatureLines.forEach((line, index) => {
authHeaders[`X-Ops-Authorization-${index + 1}`] = line;
});
return {
Accept: "application/json",
"Content-Type": "application/json",
"X-Chef-Version": "14.0.0",
"X-Ops-Timestamp": timestamp,
"X-Ops-UserId": userId,
"X-Ops-Sign": apiVersion === "1.3" ? "version=1.3" : "algorithm=sha1;version=1.0",
"X-Ops-Content-Hash": contentHash,
...(apiVersion === "1.3" && { "X-Ops-Server-API-Version": "1" }),
...authHeaders
};
};
export const getChefConnectionListItem = () => {
return {
name: "Chef" as const,
app: AppConnection.Chef as const,
methods: Object.values(ChefConnectionMethod) as [ChefConnectionMethod.UserKey]
};
};
export const validateChefConnectionCredentials = async (config: TChefConnectionConfig) => {
const { credentials: inputCredentials } = config;
try {
const path = `/organizations/${inputCredentials.orgName}/users/${inputCredentials.userName}`;
const hostServerUrl = await getChefServerUrl(inputCredentials.serverUrl);
const headers = getChefAuthHeaders("GET", path, "", inputCredentials.userName, inputCredentials.privateKey);
await request.get(`${hostServerUrl}${path}`, {
headers
});
} catch (error: unknown) {
if (error instanceof AxiosError) {
throw new BadRequestError({
message: `Failed to validate Chef credentials: ${error.message || "Unknown error"}`
});
}
throw new BadRequestError({
message: "Unable to validate Chef connection: verify credentials"
});
}
return inputCredentials;
};
export const listChefDataBags = async (appConnection: TChefConnection): Promise<TChefDataBag[]> => {
const {
credentials: { serverUrl, userName, privateKey, orgName }
} = appConnection;
try {
const path = `/organizations/${orgName}/data`;
const body = "";
const hostServerUrl = await getChefServerUrl(serverUrl);
const headers = getChefAuthHeaders("GET", path, body, userName, privateKey);
const res = await request.get<Record<string, string>>(`${hostServerUrl}${path}`, {
headers
});
return Object.keys(res.data).map((name) => ({
name
}));
} catch (error) {
if (error instanceof AxiosError) {
throw new BadRequestError({
message: `Failed to list Chef data bags: ${error.message || "Unknown error"}`
});
}
throw new BadRequestError({
message: "Unable to list Chef data bags"
});
}
};
export const listChefDataBagItems = async (
appConnection: TChefConnection,
dataBagName: string
): Promise<TChefDataBagItem[]> => {
const {
credentials: { serverUrl, userName, privateKey, orgName }
} = appConnection;
try {
const path = `/organizations/${orgName}/data/${dataBagName}`;
const body = "";
const hostServerUrl = await getChefServerUrl(serverUrl);
const headers = getChefAuthHeaders("GET", path, body, userName, privateKey);
const res = await request.get<Record<string, string>>(`${hostServerUrl}${path}`, {
headers
});
return Object.keys(res.data).map((name) => ({
name
}));
} catch (error) {
if (error instanceof AxiosError) {
throw new BadRequestError({
message: `Failed to list Chef data bag items: ${error.message || "Unknown error"}`
});
}
throw new BadRequestError({
message: "Unable to list Chef data bag items"
});
}
};
export const getChefDataBagItem = async ({
serverUrl,
userName,
privateKey,
orgName,
dataBagName,
dataBagItemName
}: TGetChefDataBagItem): Promise<TChefDataBagItemContent> => {
try {
const path = `/organizations/${orgName}/data/${dataBagName}/${dataBagItemName}`;
const body = "";
const hostServerUrl = await getChefServerUrl(serverUrl);
const headers = getChefAuthHeaders("GET", path, body, userName, privateKey);
const res = await request.get<TChefDataBagItemContent>(`${hostServerUrl}${path}`, {
headers
});
return res.data;
} catch (error) {
if (error instanceof AxiosError) {
throw new BadRequestError({
message: `Failed to get Chef data bag item: ${error.message || "Unknown error"}`
});
}
throw new BadRequestError({
message: "Unable to get Chef data bag item"
});
}
};
export const updateChefDataBagItem = async ({
serverUrl,
userName,
privateKey,
orgName,
dataBagName,
dataBagItemName,
data
}: TUpdateChefDataBagItem): Promise<void> => {
try {
const path = `/organizations/${orgName}/data/${dataBagName}/${dataBagItemName}`;
const body = JSON.stringify(data);
const hostServerUrl = await getChefServerUrl(serverUrl);
const headers = getChefAuthHeaders("PUT", path, body, userName, privateKey);
await request.put(`${hostServerUrl}${path}`, data, {
headers
});
} catch (error) {
if (error instanceof AxiosError) {
throw new BadRequestError({
message: `Failed to update Chef data bag item: ${error.message || "Unknown error"}`
});
}
throw new BadRequestError({
message: "Unable to update Chef data bag item"
});
}
};
@@ -0,0 +1,77 @@
import z from "zod";
import { AppConnections } from "@app/lib/api-docs";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import {
BaseAppConnectionSchema,
GenericCreateAppConnectionFieldsSchema,
GenericUpdateAppConnectionFieldsSchema
} from "@app/services/app-connection/app-connection-schemas";
import { ChefConnectionMethod } from "./chef-connection-enums";
export const ChefConnectionUserKeyCredentialsSchema = z.object({
serverUrl: z
.string()
.trim()
.url("Valid Chef Server URL required")
.optional()
.describe(AppConnections.CREDENTIALS.CHEF.serverUrl),
orgName: z
.string()
.trim()
.min(1, "Organization name required")
.max(256, "Organization name cannot exceed 256 characters")
.describe(AppConnections.CREDENTIALS.CHEF.orgName),
userName: z
.string()
.trim()
.min(1, "User name required")
.max(256, "User name cannot exceed 256 characters")
.describe(AppConnections.CREDENTIALS.CHEF.userName),
privateKey: z
.string()
.trim()
.min(1, "Private key required")
.max(16384, "Private key cannot exceed 16384 characters")
.describe(AppConnections.CREDENTIALS.CHEF.privateKey)
});
const BaseChefConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.Chef) });
export const ChefConnectionSchema = BaseChefConnectionSchema.extend({
method: z.literal(ChefConnectionMethod.UserKey),
credentials: ChefConnectionUserKeyCredentialsSchema
});
export const SanitizedChefConnectionSchema = z.discriminatedUnion("method", [
BaseChefConnectionSchema.extend({
method: z.literal(ChefConnectionMethod.UserKey),
credentials: ChefConnectionUserKeyCredentialsSchema.pick({ serverUrl: true, orgName: true, userName: true })
})
]);
export const ValidateChefConnectionCredentialsSchema = z.discriminatedUnion("method", [
z.object({
method: z.literal(ChefConnectionMethod.UserKey).describe(AppConnections.CREATE(AppConnection.Chef).method),
credentials: ChefConnectionUserKeyCredentialsSchema.describe(AppConnections.CREATE(AppConnection.Chef).credentials)
})
]);
export const CreateChefConnectionSchema = ValidateChefConnectionCredentialsSchema.and(
GenericCreateAppConnectionFieldsSchema(AppConnection.Chef)
);
export const UpdateChefConnectionSchema = z
.object({
credentials: ChefConnectionUserKeyCredentialsSchema.optional().describe(
AppConnections.UPDATE(AppConnection.Chef).credentials
)
})
.and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Chef));
export const ChefConnectionListItemSchema = z.object({
name: z.literal("Chef"),
app: z.literal(AppConnection.Chef),
methods: z.nativeEnum(ChefConnectionMethod).array()
});
@@ -0,0 +1,39 @@
import { ForbiddenRequestError } from "@app/lib/errors";
import { OrgServiceActor } from "@app/lib/types";
import { AppConnection } from "../app-connection-enums";
import { listChefDataBagItems, listChefDataBags } from "./chef-connection-fns";
import { TChefConnection } from "./chef-connection-types";
type TGetAppConnectionFunc = (
app: AppConnection,
connectionId: string,
actor: OrgServiceActor
) => Promise<TChefConnection>;
export const chefConnectionService = (getAppConnection: TGetAppConnectionFunc) => {
const listDataBags = async (appConnectionId: string, actor: OrgServiceActor) => {
const appConnection = await getAppConnection(AppConnection.Chef, appConnectionId, actor);
if (!appConnection) {
throw new ForbiddenRequestError({ message: "App connection not found" });
}
return listChefDataBags(appConnection);
};
const listDataBagItems = async (appConnectionId: string, dataBagName: string, actor: OrgServiceActor) => {
const appConnection = await getAppConnection(AppConnection.Chef, appConnectionId, actor);
if (!appConnection) {
throw new ForbiddenRequestError({ message: "App connection not found" });
}
return listChefDataBagItems(appConnection, dataBagName);
};
return {
listDataBags,
listDataBagItems
};
};
@@ -0,0 +1,50 @@
import z from "zod";
import { DiscriminativePick } from "@app/lib/types";
import { TChefDataBagItemContent } from "@app/services/secret-sync/chef";
import { AppConnection } from "../app-connection-enums";
import {
ChefConnectionSchema,
CreateChefConnectionSchema,
ValidateChefConnectionCredentialsSchema
} from "./chef-connection-schemas";
export type TChefConnection = z.infer<typeof ChefConnectionSchema>;
export type TChefConnectionInput = z.infer<typeof CreateChefConnectionSchema> & {
app: AppConnection.Chef;
};
export type TValidateChefConnectionCredentialsSchema = typeof ValidateChefConnectionCredentialsSchema;
export type TChefConnectionConfig = DiscriminativePick<TChefConnectionInput, "method" | "app" | "credentials"> & {
orgName: string;
};
export type TChefDataBag = {
name: string;
};
export type TChefDataBagItem = {
name: string;
};
export type TGetChefDataBagItem = {
serverUrl?: string;
userName: string;
privateKey: string;
orgName: string;
dataBagName: string;
dataBagItemName: string;
};
export type TUpdateChefDataBagItem = {
serverUrl?: string;
userName: string;
privateKey: string;
orgName: string;
dataBagName: string;
dataBagItemName: string;
data: TChefDataBagItemContent;
};
@@ -0,0 +1,4 @@
export * from "./chef-connection-enums";
export * from "./chef-connection-fns";
export * from "./chef-connection-schemas";
export * from "./chef-connection-types";
@@ -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
@@ -104,7 +104,8 @@ export enum IntegrationUrls {
GCP_SERVICE_USAGE_URL = "https://serviceusage.googleapis.com", GCP_SERVICE_USAGE_URL = "https://serviceusage.googleapis.com",
GCP_CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform", GCP_CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform",
GITHUB_USER_INSTALLATIONS = "https://api.github.com/user/installations" GITHUB_USER_INSTALLATIONS = "https://api.github.com/user/installations",
CHEF_API_URL = "https://api.chef.io"
} }
export const getIntegrationOptions = async () => { export const getIntegrationOptions = async () => {
@@ -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
}); });
} }
if (targetArn) {
activeExternalIdentifiers.add(targetArn);
}
}
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);
}
}
}); });
// Identify expired/removed certificates that need to be cleaned up from ACM Object.values(acmCertificates).forEach((acmCert) => {
const certificatesToRemove = Object.values(acmCertificates) if (acmCert.arn && acmCert.Tags) {
.filter((acmCert) => { const hasInfisicalTag = acmCert.Tags.some((tag) => tag.Key === INFISICAL_CERTIFICATE_TAG && tag.Value);
if (!acmCert.arn || !acmCert.Tags) {
return false;
}
const certNameTag = findInfisicalCertificateTag(acmCert.Tags); if (hasInfisicalTag) {
if (!certNameTag || !certNameTag.Value) { const isTrackedInSyncRecords = existingSyncRecords.some(
return false; (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,21 +746,32 @@ 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,
@@ -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;
}
}
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) { 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
}); });
} }
if (targetCertName) {
activeExternalIdentifiers.add(targetCertName);
}
}
const certificatesToRemove: string[] = [];
if (canRemoveCertificates) {
existingSyncRecords.forEach((syncRecord) => {
if (syncRecord.externalIdentifier && !activeExternalIdentifiers.has(syncRecord.externalIdentifier)) {
if (vaultCertificates[syncRecord.externalIdentifier]) {
certificatesToRemove.push(syncRecord.externalIdentifier);
}
}
}); });
// Identify expired/removed certificates that need to be cleaned up from Azure Key Vault Object.keys(vaultCertificates).forEach((certificateName) => {
// Only remove certificates that were managed by Infisical (match naming schema) const isInfisicalManaged = isInfisicalManagedCertificate(certificateName, pkiSync);
const certificatesToRemove = Object.keys(vaultCertificates).filter(
(vaultCertName) => if (isInfisicalManaged) {
isInfisicalManagedCertificate(vaultCertName, pkiSync) && const isTrackedInSyncRecords = existingSyncRecords.some(
!activeCertificateNames.includes(vaultCertName) && (record) => record.externalIdentifier === certificateName
!disabledAzureKeyVaultCertificateKeys.includes(vaultCertName)
); );
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:
+151 -29
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;
}
};
@@ -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,7 +960,16 @@ export const projectServiceFactory = ({
ProjectPermissionSub.Certificates ProjectPermissionSub.Certificates
); );
const certificates = await certificateDAL.findWithPrivateKeyInfo( const certificates = forPkiSync
? await certificateDAL.findActiveCertificatesForSync(
{
projectId,
...(friendlyName && { friendlyName }),
...(commonName && { commonName })
},
{ offset, limit }
)
: await certificateDAL.findWithPrivateKeyInfo(
{ {
projectId, projectId,
...(friendlyName && { friendlyName }), ...(friendlyName && { friendlyName }),
@@ -961,7 +978,13 @@ export const projectServiceFactory = ({
{ offset, limit, sort: [["notAfter", "desc"]] } { offset, limit, sort: [["notAfter", "desc"]] }
); );
const count = await certificateDAL.countCertificatesInProject({ const count = forPkiSync
? await certificateDAL.countActiveCertificatesForSync({
projectId,
friendlyName,
commonName
})
: await certificateDAL.countCertificatesInProject({
projectId, projectId,
friendlyName, friendlyName,
commonName commonName
@@ -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;
@@ -0,0 +1,10 @@
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
import { TSecretSyncListItem } from "@app/services/secret-sync/secret-sync-types";
export const CHEF_SYNC_LIST_OPTION: TSecretSyncListItem = {
name: "Chef",
destination: SecretSync.Chef,
connection: AppConnection.Chef,
canImportSecrets: true
};
@@ -0,0 +1,151 @@
import { getChefDataBagItem, updateChefDataBagItem } from "@app/services/app-connection/chef";
import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns";
import { TSecretMap } from "@app/services/secret-sync/secret-sync-types";
import {
ChefSecret,
TChefDataBagItemContent,
TChefSecret,
TChefSecrets,
TChefSyncWithCredentials,
TGetChefSecrets
} from "./chef-sync-types";
const getChefSecretsRaw = async ({
serverUrl,
userName,
privateKey,
orgName,
dataBagName,
dataBagItemName
}: TGetChefSecrets): Promise<TChefDataBagItemContent> => {
const dataBagItem = await getChefDataBagItem({
serverUrl,
userName,
privateKey,
orgName,
dataBagName,
dataBagItemName
});
// Ensure the data bag item has an id field
if (!dataBagItem.id) {
dataBagItem.id = dataBagItemName;
}
return dataBagItem;
};
const getChefSecrets = async (secretSync: TChefSyncWithCredentials): Promise<TChefSecrets> => {
const {
connection,
destinationConfig: { dataBagName, dataBagItemName }
} = secretSync;
const { serverUrl, userName, privateKey, orgName } = connection.credentials;
const dataBagItem = await getChefSecretsRaw({
serverUrl,
orgName,
userName,
privateKey,
dataBagName,
dataBagItemName
});
const { id, ...existingSecrets } = dataBagItem;
// Convert data bag item to key-value pairs
const secrets: ChefSecret[] = [];
Object.entries(existingSecrets).forEach(([key, value]) => {
if (key !== "id" && value !== null && value !== undefined) {
secrets.push({ key, value: String(value) });
}
});
return { id, secrets };
};
const updateChefSecrets = async (
secretSync: TChefSyncWithCredentials,
id: string,
secrets: Record<string, TChefSecret>
) => {
const {
connection,
destinationConfig: { dataBagName, dataBagItemName }
} = secretSync;
const { serverUrl, userName, privateKey, orgName } = connection.credentials;
// Chef data bag items must have an 'id' field
const dataBagItemContent: TChefDataBagItemContent = {
id,
...secrets
};
await updateChefDataBagItem({
serverUrl,
orgName,
userName,
privateKey,
dataBagName,
dataBagItemName,
data: dataBagItemContent
});
};
export const ChefSyncFns = {
async syncSecrets(secretSync: TChefSyncWithCredentials, secretMap: TSecretMap) {
const {
environment,
syncOptions: { disableSecretDeletion, keySchema }
} = secretSync;
const { id, secrets } = await getChefSecrets(secretSync);
// Create a map of the existing secrets
const updatedSecretsMap = new Map(secrets.map((secret) => [secret.key, secret.value]));
// Add/update new secrets
for (const [key, { value }] of Object.entries(secretMap)) {
updatedSecretsMap.set(key, value);
}
// Delete secrets if not disabled
if (!disableSecretDeletion) {
secrets.forEach((secret) => {
if (!matchesSchema(secret.key, environment?.slug || "", keySchema)) return;
if (!secretMap[secret.key]) {
updatedSecretsMap.delete(secret.key);
}
});
}
// Convert map to object for Chef API
const updatedSecrets = Object.fromEntries(updatedSecretsMap.entries());
await updateChefSecrets(secretSync, id, updatedSecrets);
},
async getSecrets(secretSync: TChefSyncWithCredentials): Promise<TSecretMap> {
const { secrets } = await getChefSecrets(secretSync);
return Object.fromEntries(secrets.map((secret) => [secret.key, { value: secret.value }]));
},
async removeSecrets(secretSync: TChefSyncWithCredentials, secretMap: TSecretMap) {
const { id, secrets: existingSecrets } = await getChefSecrets(secretSync);
const newSecrets = existingSecrets.filter((secret) => !Object.hasOwn(secretMap, secret.key));
if (newSecrets.length === existingSecrets.length) {
return;
}
const updatedSecrets = Object.fromEntries(newSecrets.map((secret) => [secret.key, secret.value]));
await updateChefSecrets(secretSync, id, updatedSecrets);
}
};
@@ -0,0 +1,46 @@
import { z } from "zod";
import { SecretSyncs } from "@app/lib/api-docs";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
import {
BaseSecretSyncSchema,
GenericCreateSecretSyncFieldsSchema,
GenericUpdateSecretSyncFieldsSchema
} from "@app/services/secret-sync/secret-sync-schemas";
import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types";
const ChefSyncDestinationConfigSchema = z.object({
dataBagName: z
.string()
.min(1, "Data Bag Name is required")
.max(256, "Data Bag Name cannot exceed 256 characters")
.describe(SecretSyncs.DESTINATION_CONFIG.CHEF.dataBagName),
dataBagItemName: z
.string()
.min(1, "Data Bag Item Name is required")
.max(256, "Data Bag Item Name cannot exceed 256 characters")
.describe(SecretSyncs.DESTINATION_CONFIG.CHEF.dataBagItemName)
});
const ChefSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true };
export const ChefSyncSchema = BaseSecretSyncSchema(SecretSync.Chef, ChefSyncOptionsConfig).extend({
destination: z.literal(SecretSync.Chef),
destinationConfig: ChefSyncDestinationConfigSchema
});
export const CreateChefSyncSchema = GenericCreateSecretSyncFieldsSchema(SecretSync.Chef, ChefSyncOptionsConfig).extend({
destinationConfig: ChefSyncDestinationConfigSchema
});
export const UpdateChefSyncSchema = GenericUpdateSecretSyncFieldsSchema(SecretSync.Chef, ChefSyncOptionsConfig).extend({
destinationConfig: ChefSyncDestinationConfigSchema.optional()
});
export const ChefSyncListItemSchema = z.object({
name: z.literal("Chef"),
connection: z.literal(AppConnection.Chef),
destination: z.literal(SecretSync.Chef),
canImportSecrets: z.literal(true)
});
@@ -0,0 +1,41 @@
import z from "zod";
import { TChefConnection } from "@app/services/app-connection/chef";
import { ChefSyncListItemSchema, ChefSyncSchema, CreateChefSyncSchema } from "./chef-sync-schemas";
export type TChefSyncListItem = z.infer<typeof ChefSyncListItemSchema>;
export type TChefSync = z.infer<typeof ChefSyncSchema>;
export type TChefSyncInput = z.infer<typeof CreateChefSyncSchema>;
export type TChefSyncWithCredentials = TChefSync & {
connection: TChefConnection;
};
export type TGetChefSecrets = {
serverUrl?: string;
userName: string;
privateKey: string;
orgName: string;
dataBagName: string;
dataBagItemName: string;
};
export type TChefSecret = string | number | boolean | null;
export type TChefDataBagItemContent = {
id: string;
[key: string]: TChefSecret;
};
export type TChefSecrets = {
id: string;
secrets: ChefSecret[];
};
export type ChefSecret = {
key: string;
value: string;
};
@@ -0,0 +1,4 @@
export * from "./chef-sync-constants";
export * from "./chef-sync-fns";
export * from "./chef-sync-schemas";
export * from "./chef-sync-types";
@@ -30,7 +30,8 @@ export enum SecretSync {
Netlify = "netlify", Netlify = "netlify",
Northflank = "northflank", Northflank = "northflank",
Bitbucket = "bitbucket", Bitbucket = "bitbucket",
LaravelForge = "laravel-forge" LaravelForge = "laravel-forge",
Chef = "chef"
} }
export enum SecretSyncInitialSyncBehavior { export enum SecretSyncInitialSyncBehavior {
@@ -34,6 +34,7 @@ import { BITBUCKET_SYNC_LIST_OPTION, BitbucketSyncFns } from "./bitbucket";
import { CAMUNDA_SYNC_LIST_OPTION, camundaSyncFactory } from "./camunda"; import { CAMUNDA_SYNC_LIST_OPTION, camundaSyncFactory } from "./camunda";
import { CHECKLY_SYNC_LIST_OPTION } from "./checkly/checkly-sync-constants"; import { CHECKLY_SYNC_LIST_OPTION } from "./checkly/checkly-sync-constants";
import { ChecklySyncFns } from "./checkly/checkly-sync-fns"; import { ChecklySyncFns } from "./checkly/checkly-sync-fns";
import { CHEF_SYNC_LIST_OPTION, ChefSyncFns } from "./chef";
import { CLOUDFLARE_PAGES_SYNC_LIST_OPTION } from "./cloudflare-pages/cloudflare-pages-constants"; import { CLOUDFLARE_PAGES_SYNC_LIST_OPTION } from "./cloudflare-pages/cloudflare-pages-constants";
import { CloudflarePagesSyncFns } from "./cloudflare-pages/cloudflare-pages-fns"; import { CloudflarePagesSyncFns } from "./cloudflare-pages/cloudflare-pages-fns";
import { CLOUDFLARE_WORKERS_SYNC_LIST_OPTION, CloudflareWorkersSyncFns } from "./cloudflare-workers"; import { CLOUDFLARE_WORKERS_SYNC_LIST_OPTION, CloudflareWorkersSyncFns } from "./cloudflare-workers";
@@ -49,8 +50,7 @@ import { HC_VAULT_SYNC_LIST_OPTION, HCVaultSyncFns } from "./hc-vault";
import { HEROKU_SYNC_LIST_OPTION, HerokuSyncFns } from "./heroku"; import { HEROKU_SYNC_LIST_OPTION, HerokuSyncFns } from "./heroku";
import { HUMANITEC_SYNC_LIST_OPTION } from "./humanitec"; import { HUMANITEC_SYNC_LIST_OPTION } from "./humanitec";
import { HumanitecSyncFns } from "./humanitec/humanitec-sync-fns"; import { HumanitecSyncFns } from "./humanitec/humanitec-sync-fns";
import { LARAVEL_FORGE_SYNC_LIST_OPTION } from "./laravel-forge"; import { LARAVEL_FORGE_SYNC_LIST_OPTION, LaravelForgeSyncFns } from "./laravel-forge";
import { LaravelForgeSyncFns } from "./laravel-forge/laravel-forge-sync-fns";
import { NETLIFY_SYNC_LIST_OPTION, NetlifySyncFns } from "./netlify"; import { NETLIFY_SYNC_LIST_OPTION, NetlifySyncFns } from "./netlify";
import { NORTHFLANK_SYNC_LIST_OPTION, NorthflankSyncFns } from "./northflank"; import { NORTHFLANK_SYNC_LIST_OPTION, NorthflankSyncFns } from "./northflank";
import { RAILWAY_SYNC_LIST_OPTION } from "./railway/railway-sync-constants"; import { RAILWAY_SYNC_LIST_OPTION } from "./railway/railway-sync-constants";
@@ -96,7 +96,8 @@ const SECRET_SYNC_LIST_OPTIONS: Record<SecretSync, TSecretSyncListItem> = {
[SecretSync.Netlify]: NETLIFY_SYNC_LIST_OPTION, [SecretSync.Netlify]: NETLIFY_SYNC_LIST_OPTION,
[SecretSync.Northflank]: NORTHFLANK_SYNC_LIST_OPTION, [SecretSync.Northflank]: NORTHFLANK_SYNC_LIST_OPTION,
[SecretSync.Bitbucket]: BITBUCKET_SYNC_LIST_OPTION, [SecretSync.Bitbucket]: BITBUCKET_SYNC_LIST_OPTION,
[SecretSync.LaravelForge]: LARAVEL_FORGE_SYNC_LIST_OPTION [SecretSync.LaravelForge]: LARAVEL_FORGE_SYNC_LIST_OPTION,
[SecretSync.Chef]: CHEF_SYNC_LIST_OPTION
}; };
export const listSecretSyncOptions = () => { export const listSecretSyncOptions = () => {
@@ -286,6 +287,8 @@ export const SecretSyncFns = {
return BitbucketSyncFns.syncSecrets(secretSync, schemaSecretMap); return BitbucketSyncFns.syncSecrets(secretSync, schemaSecretMap);
case SecretSync.LaravelForge: case SecretSync.LaravelForge:
return LaravelForgeSyncFns.syncSecrets(secretSync, schemaSecretMap); return LaravelForgeSyncFns.syncSecrets(secretSync, schemaSecretMap);
case SecretSync.Chef:
return ChefSyncFns.syncSecrets(secretSync, schemaSecretMap);
default: default:
throw new Error( throw new Error(
`Unhandled sync destination for sync secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` `Unhandled sync destination for sync secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}`
@@ -408,6 +411,9 @@ export const SecretSyncFns = {
case SecretSync.LaravelForge: case SecretSync.LaravelForge:
secretMap = await LaravelForgeSyncFns.getSecrets(secretSync); secretMap = await LaravelForgeSyncFns.getSecrets(secretSync);
break; break;
case SecretSync.Chef:
secretMap = await ChefSyncFns.getSecrets(secretSync);
break;
default: default:
throw new Error( throw new Error(
`Unhandled sync destination for get secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` `Unhandled sync destination for get secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}`
@@ -505,6 +511,8 @@ export const SecretSyncFns = {
return BitbucketSyncFns.removeSecrets(secretSync, schemaSecretMap); return BitbucketSyncFns.removeSecrets(secretSync, schemaSecretMap);
case SecretSync.LaravelForge: case SecretSync.LaravelForge:
return LaravelForgeSyncFns.removeSecrets(secretSync, schemaSecretMap); return LaravelForgeSyncFns.removeSecrets(secretSync, schemaSecretMap);
case SecretSync.Chef:
return ChefSyncFns.removeSecrets(secretSync, schemaSecretMap);
default: default:
throw new Error( throw new Error(
`Unhandled sync destination for remove secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` `Unhandled sync destination for remove secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}`
@@ -34,7 +34,8 @@ export const SECRET_SYNC_NAME_MAP: Record<SecretSync, string> = {
[SecretSync.Netlify]: "Netlify", [SecretSync.Netlify]: "Netlify",
[SecretSync.Northflank]: "Northflank", [SecretSync.Northflank]: "Northflank",
[SecretSync.Bitbucket]: "Bitbucket", [SecretSync.Bitbucket]: "Bitbucket",
[SecretSync.LaravelForge]: "Laravel Forge" [SecretSync.LaravelForge]: "Laravel Forge",
[SecretSync.Chef]: "Chef"
}; };
export const SECRET_SYNC_CONNECTION_MAP: Record<SecretSync, AppConnection> = { export const SECRET_SYNC_CONNECTION_MAP: Record<SecretSync, AppConnection> = {
@@ -69,7 +70,8 @@ export const SECRET_SYNC_CONNECTION_MAP: Record<SecretSync, AppConnection> = {
[SecretSync.Netlify]: AppConnection.Netlify, [SecretSync.Netlify]: AppConnection.Netlify,
[SecretSync.Northflank]: AppConnection.Northflank, [SecretSync.Northflank]: AppConnection.Northflank,
[SecretSync.Bitbucket]: AppConnection.Bitbucket, [SecretSync.Bitbucket]: AppConnection.Bitbucket,
[SecretSync.LaravelForge]: AppConnection.LaravelForge [SecretSync.LaravelForge]: AppConnection.LaravelForge,
[SecretSync.Chef]: AppConnection.Chef
}; };
export const SECRET_SYNC_PLAN_MAP: Record<SecretSync, SecretSyncPlanType> = { export const SECRET_SYNC_PLAN_MAP: Record<SecretSync, SecretSyncPlanType> = {
@@ -104,7 +106,8 @@ export const SECRET_SYNC_PLAN_MAP: Record<SecretSync, SecretSyncPlanType> = {
[SecretSync.Netlify]: SecretSyncPlanType.Regular, [SecretSync.Netlify]: SecretSyncPlanType.Regular,
[SecretSync.Northflank]: SecretSyncPlanType.Regular, [SecretSync.Northflank]: SecretSyncPlanType.Regular,
[SecretSync.Bitbucket]: SecretSyncPlanType.Regular, [SecretSync.Bitbucket]: SecretSyncPlanType.Regular,
[SecretSync.LaravelForge]: SecretSyncPlanType.Regular [SecretSync.LaravelForge]: SecretSyncPlanType.Regular,
[SecretSync.Chef]: SecretSyncPlanType.Regular
}; };
export const SECRET_SYNC_SKIP_FIELDS_MAP: Record<SecretSync, string[]> = { export const SECRET_SYNC_SKIP_FIELDS_MAP: Record<SecretSync, string[]> = {
@@ -148,7 +151,8 @@ export const SECRET_SYNC_SKIP_FIELDS_MAP: Record<SecretSync, string[]> = {
[SecretSync.Netlify]: ["accountName", "siteName"], [SecretSync.Netlify]: ["accountName", "siteName"],
[SecretSync.Northflank]: [], [SecretSync.Northflank]: [],
[SecretSync.Bitbucket]: [], [SecretSync.Bitbucket]: [],
[SecretSync.LaravelForge]: [] [SecretSync.LaravelForge]: [],
[SecretSync.Chef]: []
}; };
const defaultDuplicateCheck: DestinationDuplicateCheckFn = () => true; const defaultDuplicateCheck: DestinationDuplicateCheckFn = () => true;
@@ -209,5 +213,6 @@ export const DESTINATION_DUPLICATE_CHECK_MAP: Record<SecretSync, DestinationDupl
[SecretSync.Netlify]: defaultDuplicateCheck, [SecretSync.Netlify]: defaultDuplicateCheck,
[SecretSync.Northflank]: defaultDuplicateCheck, [SecretSync.Northflank]: defaultDuplicateCheck,
[SecretSync.Bitbucket]: defaultDuplicateCheck, [SecretSync.Bitbucket]: defaultDuplicateCheck,
[SecretSync.LaravelForge]: defaultDuplicateCheck [SecretSync.LaravelForge]: defaultDuplicateCheck,
[SecretSync.Chef]: defaultDuplicateCheck
}; };
@@ -21,6 +21,7 @@ import {
TCamundaSyncListItem, TCamundaSyncListItem,
TCamundaSyncWithCredentials TCamundaSyncWithCredentials
} from "@app/services/secret-sync/camunda"; } from "@app/services/secret-sync/camunda";
import { TChefSync, TChefSyncInput, TChefSyncListItem, TChefSyncWithCredentials } from "@app/services/secret-sync/chef";
import { import {
TDatabricksSync, TDatabricksSync,
TDatabricksSyncInput, TDatabricksSyncInput,
@@ -169,6 +170,7 @@ export type TSecretSync =
| TGitHubSync | TGitHubSync
| TGcpSync | TGcpSync
| TAzureKeyVaultSync | TAzureKeyVaultSync
| TChefSync
| TAzureAppConfigurationSync | TAzureAppConfigurationSync
| TAzureDevOpsSync | TAzureDevOpsSync
| TDatabricksSync | TDatabricksSync
@@ -202,6 +204,7 @@ export type TSecretSyncWithCredentials =
| TGitHubSyncWithCredentials | TGitHubSyncWithCredentials
| TGcpSyncWithCredentials | TGcpSyncWithCredentials
| TAzureKeyVaultSyncWithCredentials | TAzureKeyVaultSyncWithCredentials
| TChefSyncWithCredentials
| TAzureAppConfigurationSyncWithCredentials | TAzureAppConfigurationSyncWithCredentials
| TAzureDevOpsSyncWithCredentials | TAzureDevOpsSyncWithCredentials
| TDatabricksSyncWithCredentials | TDatabricksSyncWithCredentials
@@ -236,6 +239,7 @@ export type TSecretSyncInput =
| TGitHubSyncInput | TGitHubSyncInput
| TGcpSyncInput | TGcpSyncInput
| TAzureKeyVaultSyncInput | TAzureKeyVaultSyncInput
| TChefSyncInput
| TAzureAppConfigurationSyncInput | TAzureAppConfigurationSyncInput
| TAzureDevOpsSyncInput | TAzureDevOpsSyncInput
| TDatabricksSyncInput | TDatabricksSyncInput
@@ -270,6 +274,7 @@ export type TSecretSyncListItem =
| TGitHubSyncListItem | TGitHubSyncListItem
| TGcpSyncListItem | TGcpSyncListItem
| TAzureKeyVaultSyncListItem | TAzureKeyVaultSyncListItem
| TChefSyncListItem
| TAzureAppConfigurationSyncListItem | TAzureAppConfigurationSyncListItem
| TAzureDevOpsSyncListItem | TAzureDevOpsSyncListItem
| TDatabricksSyncListItem | TDatabricksSyncListItem
@@ -0,0 +1,4 @@
---
title: "Available"
openapi: "GET /api/v1/app-connections/chef/available"
---
@@ -0,0 +1,10 @@
---
title: "Create"
openapi: "POST /api/v1/app-connections/chef"
---
<Note>
Check out the configuration docs for [Chef
Connections](/integrations/app-connections/chef) to learn how to obtain the
required credentials.
</Note>
@@ -0,0 +1,4 @@
---
title: "Delete"
openapi: "DELETE /api/v1/app-connections/chef/{connectionId}"
---
@@ -0,0 +1,4 @@
---
title: "Get by ID"
openapi: "GET /api/v1/app-connections/chef/{connectionId}"
---
@@ -0,0 +1,4 @@
---
title: "Get by Name"
openapi: "GET /api/v1/app-connections/chef/connection-name/{connectionName}"
---
@@ -0,0 +1,4 @@
---
title: "List"
openapi: "GET /api/v1/app-connections/chef"
---
@@ -0,0 +1,10 @@
---
title: "Update"
openapi: "PATCH /api/v1/app-connections/chef/{connectionId}"
---
<Note>
Check out the configuration docs for [Chef
Connections](/integrations/app-connections/chef) to learn how to obtain the
required credentials.
</Note>
@@ -0,0 +1,4 @@
---
title: "Create"
openapi: "POST /api/v1/secret-syncs/chef"
---
@@ -0,0 +1,4 @@
---
title: "Delete"
openapi: "DELETE /api/v1/secret-syncs/chef/{syncId}"
---
@@ -0,0 +1,4 @@
---
title: "Get by ID"
openapi: "GET /api/v1/secret-syncs/chef/{syncId}"
---
@@ -0,0 +1,4 @@
---
title: "Get by Name"
openapi: "GET /api/v1/secret-syncs/chef/sync-name/{syncName}"
---
@@ -0,0 +1,4 @@
---
title: "Import Secrets"
openapi: "POST /api/v1/secret-syncs/chef/{syncId}/import-secrets"
---
@@ -0,0 +1,4 @@
---
title: "List"
openapi: "GET /api/v1/secret-syncs/chef"
---
@@ -0,0 +1,4 @@
---
title: "Remove Secrets"
openapi: "POST /api/v1/secret-syncs/chef/{syncId}/remove-secrets"
---
@@ -0,0 +1,4 @@
---
title: "Sync Secrets"
openapi: "POST /api/v1/secret-syncs/chef/{syncId}/sync-secrets"
---
@@ -0,0 +1,4 @@
---
title: "Update"
openapi: "PATCH /api/v1/secret-syncs/chef/{syncId}"
---
+29
View File
@@ -114,6 +114,7 @@
"integrations/app-connections/bitbucket", "integrations/app-connections/bitbucket",
"integrations/app-connections/camunda", "integrations/app-connections/camunda",
"integrations/app-connections/checkly", "integrations/app-connections/checkly",
"integrations/app-connections/chef",
"integrations/app-connections/cloudflare", "integrations/app-connections/cloudflare",
"integrations/app-connections/databricks", "integrations/app-connections/databricks",
"integrations/app-connections/digital-ocean", "integrations/app-connections/digital-ocean",
@@ -540,6 +541,7 @@
"integrations/secret-syncs/bitbucket", "integrations/secret-syncs/bitbucket",
"integrations/secret-syncs/camunda", "integrations/secret-syncs/camunda",
"integrations/secret-syncs/checkly", "integrations/secret-syncs/checkly",
"integrations/secret-syncs/chef",
"integrations/secret-syncs/cloudflare-pages", "integrations/secret-syncs/cloudflare-pages",
"integrations/secret-syncs/cloudflare-workers", "integrations/secret-syncs/cloudflare-workers",
"integrations/secret-syncs/databricks", "integrations/secret-syncs/databricks",
@@ -1658,6 +1660,18 @@
"api-reference/endpoints/app-connections/checkly/delete" "api-reference/endpoints/app-connections/checkly/delete"
] ]
}, },
{
"group": "Chef",
"pages": [
"api-reference/endpoints/app-connections/chef/list",
"api-reference/endpoints/app-connections/chef/available",
"api-reference/endpoints/app-connections/chef/get-by-id",
"api-reference/endpoints/app-connections/chef/get-by-name",
"api-reference/endpoints/app-connections/chef/create",
"api-reference/endpoints/app-connections/chef/update",
"api-reference/endpoints/app-connections/chef/delete"
]
},
{ {
"group": "Cloudflare", "group": "Cloudflare",
"pages": [ "pages": [
@@ -2149,6 +2163,20 @@
"api-reference/endpoints/secret-syncs/checkly/remove-secrets" "api-reference/endpoints/secret-syncs/checkly/remove-secrets"
] ]
}, },
{
"group": "Chef",
"pages": [
"api-reference/endpoints/secret-syncs/chef/list",
"api-reference/endpoints/secret-syncs/chef/get-by-id",
"api-reference/endpoints/secret-syncs/chef/get-by-name",
"api-reference/endpoints/secret-syncs/chef/create",
"api-reference/endpoints/secret-syncs/chef/update",
"api-reference/endpoints/secret-syncs/chef/delete",
"api-reference/endpoints/secret-syncs/chef/sync-secrets",
"api-reference/endpoints/secret-syncs/chef/import-secrets",
"api-reference/endpoints/secret-syncs/chef/remove-secrets"
]
},
{ {
"group": "Cloudflare Pages", "group": "Cloudflare Pages",
"pages": [ "pages": [
@@ -2304,6 +2332,7 @@
"api-reference/endpoints/secret-syncs/laravel-forge/update", "api-reference/endpoints/secret-syncs/laravel-forge/update",
"api-reference/endpoints/secret-syncs/laravel-forge/delete", "api-reference/endpoints/secret-syncs/laravel-forge/delete",
"api-reference/endpoints/secret-syncs/laravel-forge/sync-secrets", "api-reference/endpoints/secret-syncs/laravel-forge/sync-secrets",
"api-reference/endpoints/secret-syncs/laravel-forge/import-secrets",
"api-reference/endpoints/secret-syncs/laravel-forge/remove-secrets" "api-reference/endpoints/secret-syncs/laravel-forge/remove-secrets"
] ]
}, },
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

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