catch up with main

This commit is contained in:
Tuan Dang
2025-11-05 16:58:39 -08:00
587 changed files with 13842 additions and 11543 deletions
+1
View File
@@ -52,3 +52,4 @@ docs/integrations/app-connections/railway.mdx:generic-api-key:156
.github/workflows/validate-db-schemas.yml:generic-api-key:21 .github/workflows/validate-db-schemas.yml:generic-api-key:21
k8-operator/config/samples/universalAuthIdentitySecret.yaml:generic-api-key:8 k8-operator/config/samples/universalAuthIdentitySecret.yaml:generic-api-key:8
docs/integrations/app-connections/redis.mdx:generic-api-key:80 docs/integrations/app-connections/redis.mdx:generic-api-key:80
backend/src/ee/services/app-connections/chef/chef-connection-fns.ts:private-key:42
+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",
@@ -0,0 +1,84 @@
import z from "zod";
import {
CreateChefConnectionSchema,
SanitizedChefConnectionSchema,
UpdateChefConnectionSchema
} from "@app/ee/services/app-connections/chef";
import { readLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { registerAppConnectionEndpoints } from "@app/server/routes/v1/app-connection-routers/app-connection-endpoints";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { AuthMode } from "@app/services/auth/auth-type";
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;
}
});
};
@@ -92,7 +92,8 @@ export const registerPamAccountRouter = async (server: FastifyZodProvider) => {
gatewayClientCertificate: z.string(), gatewayClientCertificate: z.string(),
gatewayClientPrivateKey: z.string(), gatewayClientPrivateKey: z.string(),
gatewayServerCertificateChain: z.string(), gatewayServerCertificateChain: z.string(),
relayHost: z.string() relayHost: z.string(),
metadata: z.record(z.string(), z.string()).optional()
}) })
} }
}, },
+4 -1
View File
@@ -468,7 +468,10 @@ export const registerPITRouter = async (server: FastifyZodProvider) => {
.transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim())) .transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim()))
.optional(), .optional(),
secretComment: z.string().trim().optional().default(""), secretComment: z.string().trim().optional().default(""),
skipMultilineEncoding: z.boolean().optional(), skipMultilineEncoding: z
.boolean()
.nullish()
.transform((val) => (val === null ? false : val)),
metadata: z.record(z.string()).optional(), metadata: z.record(z.string()).optional(),
secretMetadata: ResourceMetadataSchema.optional(), secretMetadata: ResourceMetadataSchema.optional(),
tagIds: z.string().array().optional() tagIds: z.string().array().optional()
@@ -0,0 +1,12 @@
import { ChefSyncSchema, CreateChefSyncSchema, UpdateChefSyncSchema } from "@app/ee/services/secret-sync/chef";
import { registerSyncSecretsEndpoints } from "@app/server/routes/v1/secret-sync-routers/secret-sync-endpoints";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
export const registerChefSyncRouter = async (server: FastifyZodProvider) =>
registerSyncSecretsEndpoints({
destination: SecretSync.Chef,
server,
responseSchema: ChefSyncSchema,
createSchema: CreateChefSyncSchema,
updateSchema: UpdateChefSyncSchema
});
@@ -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 { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { IntegrationUrls } from "@app/services/integration-auth/integration-list";
import { TChefDataBagItemContent } from "../../secret-sync/chef/chef-sync-types";
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,57 @@
import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors";
import { OrgServiceActor } from "@app/lib/types";
import { AppConnection } from "../../../../services/app-connection/app-connection-enums";
import { TLicenseServiceFactory } from "../../license/license-service";
import { listChefDataBagItems, listChefDataBags } from "./chef-connection-fns";
import { TChefConnection } from "./chef-connection-types";
type TGetAppConnectionFunc = (
app: AppConnection,
connectionId: string,
actor: OrgServiceActor
) => Promise<TChefConnection>;
// Enterprise check
export const checkPlan = async (licenseService: Pick<TLicenseServiceFactory, "getPlan">, orgId: string) => {
const plan = await licenseService.getPlan(orgId);
if (!plan.enterpriseAppConnections)
throw new BadRequestError({
message:
"Failed to use app connection due to plan restriction. Upgrade plan to access enterprise app connections."
});
};
export const chefConnectionService = (
getAppConnection: TGetAppConnectionFunc,
licenseService: Pick<TLicenseServiceFactory, "getPlan">
) => {
const listDataBags = async (appConnectionId: string, actor: OrgServiceActor) => {
await checkPlan(licenseService, actor.orgId);
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) => {
await checkPlan(licenseService, actor.orgId);
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 { TChefDataBagItemContent } from "@app/ee/services/secret-sync/chef";
import { DiscriminativePick } from "@app/lib/types";
import { AppConnection } from "../../../../services/app-connection/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";
@@ -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
@@ -3,7 +3,7 @@ import net from "node:net";
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import * as x509 from "@peculiar/x509"; import * as x509 from "@peculiar/x509";
import { OrganizationActionScope, OrgMembershipRole, TRelays } from "@app/db/schemas"; import { OrganizationActionScope, OrgMembershipRole, OrgMembershipStatus, TRelays } from "@app/db/schemas";
import { PgSqlLock } from "@app/keystore/keystore"; import { PgSqlLock } from "@app/keystore/keystore";
import { crypto } from "@app/lib/crypto"; import { crypto } from "@app/lib/crypto";
import { DatabaseErrorCode } from "@app/lib/error-codes"; import { DatabaseErrorCode } from "@app/lib/error-codes";
@@ -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);
@@ -910,7 +909,9 @@ export const gatewayV2ServiceFactory = ({
for await (const [orgId, gateways] of Object.entries(gatewaysByOrg)) { for await (const [orgId, gateways] of Object.entries(gatewaysByOrg)) {
try { try {
const admins = await orgDAL.findOrgMembersByRole(orgId, OrgMembershipRole.Admin); const admins = (await orgDAL.findOrgMembersByRole(orgId, OrgMembershipRole.Admin)).filter(
(admin) => admin.status !== OrgMembershipStatus.Invited
);
if (admins.length === 0) { if (admins.length === 0) {
logger.warn({ orgId }, "Organization has no admins to notify about unhealthy gateway."); logger.warn({ orgId }, "Organization has no admins to notify about unhealthy gateway.");
// eslint-disable-next-line no-continue // eslint-disable-next-line no-continue
@@ -931,15 +932,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) {
@@ -480,6 +480,36 @@ export const pamAccountServiceFactory = ({
throw new NotFoundError({ message: `Gateway connection details for gateway '${gatewayId}' not found.` }); throw new NotFoundError({ message: `Gateway connection details for gateway '${gatewayId}' not found.` });
} }
let metadata;
switch (resourceType) {
case PamResource.Postgres:
case PamResource.MySQL:
{
const connectionCredentials = await decryptResourceConnectionDetails({
encryptedConnectionDetails: resource.encryptedConnectionDetails,
kmsService,
projectId: account.projectId
});
const credentials = await decryptAccountCredentials({
encryptedCredentials: account.encryptedCredentials,
kmsService,
projectId: account.projectId
});
metadata = {
username: credentials.username,
database: connectionCredentials.database,
accountName: account.name,
accountPath
};
}
break;
default:
break;
}
return { return {
sessionId: session.id, sessionId: session.id,
resourceType, resourceType,
@@ -491,7 +521,8 @@ export const pamAccountServiceFactory = ({
gatewayServerCertificateChain: gatewayConnectionDetails.gateway.serverCertificateChain, gatewayServerCertificateChain: gatewayConnectionDetails.gateway.serverCertificateChain,
relayHost: gatewayConnectionDetails.relayHost, relayHost: gatewayConnectionDetails.relayHost,
projectId: account.projectId, projectId: account.projectId,
account account,
metadata
}; };
}; };
@@ -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
@@ -201,11 +201,11 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
.leftJoin(TableName.IdentityMetadata, (queryBuilder) => { .leftJoin(TableName.IdentityMetadata, (queryBuilder) => {
if (actorType === ActorType.USER) { if (actorType === ActorType.USER) {
void queryBuilder void queryBuilder
.on(`${TableName.Membership}.actorUserId`, `${TableName.IdentityMetadata}.userId`) .on(`${TableName.IdentityMetadata}.userId`, db.raw("?", [actorId]))
.andOn(`${TableName.Membership}.scopeOrgId`, `${TableName.IdentityMetadata}.orgId`); .andOn(`${TableName.Membership}.scopeOrgId`, `${TableName.IdentityMetadata}.orgId`);
} else if (actorType === ActorType.IDENTITY) { } else if (actorType === ActorType.IDENTITY) {
void queryBuilder void queryBuilder
.on(`${TableName.Membership}.actorIdentityId`, `${TableName.IdentityMetadata}.identityId`) .on(`${TableName.IdentityMetadata}.identityId`, db.raw("?", [actorId]))
.andOn(`${TableName.Membership}.scopeOrgId`, `${TableName.IdentityMetadata}.orgId`); .andOn(`${TableName.Membership}.scopeOrgId`, `${TableName.IdentityMetadata}.orgId`);
} }
}) })
@@ -488,7 +488,7 @@ export const permissionDALFactory = (db: TDbClient): TPermissionDALFactory => {
}) })
.leftJoin(TableName.IdentityMetadata, (queryBuilder) => { .leftJoin(TableName.IdentityMetadata, (queryBuilder) => {
void queryBuilder void queryBuilder
.on(`${TableName.Membership}.actorUserId`, `${TableName.IdentityMetadata}.userId`) .on(`${TableName.Users}.id`, `${TableName.IdentityMetadata}.userId`)
.andOn(`${TableName.Membership}.scopeOrgId`, `${TableName.IdentityMetadata}.orgId`); .andOn(`${TableName.Membership}.scopeOrgId`, `${TableName.IdentityMetadata}.orgId`);
}) })
.where(`${TableName.Membership}.scopeOrgId`, orgId) .where(`${TableName.Membership}.scopeOrgId`, orgId)
+18 -12
View File
@@ -3,7 +3,7 @@ import { isIP } from "node:net";
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import * as x509 from "@peculiar/x509"; import * as x509 from "@peculiar/x509";
import { OrganizationActionScope, OrgMembershipRole, TRelays } from "@app/db/schemas"; import { OrganizationActionScope, OrgMembershipRole, OrgMembershipStatus, TRelays } from "@app/db/schemas";
import { PgSqlLock } from "@app/keystore/keystore"; import { PgSqlLock } from "@app/keystore/keystore";
import { crypto } from "@app/lib/crypto"; import { crypto } from "@app/lib/crypto";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
@@ -996,7 +996,9 @@ export const relayServiceFactory = ({
); );
if (existingRelay && (existingRelay.host !== host || existingRelay.name !== name)) { if (existingRelay && (existingRelay.host !== host || existingRelay.name !== name)) {
return relayDAL.updateById(existingRelay.id, { host, name }, tx); throw new BadRequestError({
message: `Machine identity already has an existing relay with the name "${existingRelay.name}" and host "${existingRelay.host}". Delete the existing relay or use a different machine identity.`
});
} }
if (!existingRelay) { if (!existingRelay) {
@@ -1248,7 +1250,9 @@ export const relayServiceFactory = ({
}); });
} }
} else { } else {
const admins = await orgDAL.findOrgMembersByRole(orgId, OrgMembershipRole.Admin); const admins = (await orgDAL.findOrgMembersByRole(orgId, OrgMembershipRole.Admin)).filter(
(admin) => admin.status !== OrgMembershipStatus.Invited
);
if (admins.length === 0) { if (admins.length === 0) {
// eslint-disable-next-line no-continue // eslint-disable-next-line no-continue
continue; continue;
@@ -1268,15 +1272,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() })));
@@ -670,6 +670,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
.select( .select(
db.ref("projectId").withSchema(TableName.Environment), db.ref("projectId").withSchema(TableName.Environment),
db.ref("slug").withSchema(TableName.Environment).as("environment"), db.ref("slug").withSchema(TableName.Environment).as("environment"),
db.ref("name").withSchema(TableName.Environment).as("environmentName"),
db.ref("id").withSchema(TableName.SecretApprovalRequestReviewer).as("reviewerId"), db.ref("id").withSchema(TableName.SecretApprovalRequestReviewer).as("reviewerId"),
db.ref("reviewerUserId").withSchema(TableName.SecretApprovalRequestReviewer), db.ref("reviewerUserId").withSchema(TableName.SecretApprovalRequestReviewer),
db.ref("status").withSchema(TableName.SecretApprovalRequestReviewer).as("reviewerStatus"), db.ref("status").withSchema(TableName.SecretApprovalRequestReviewer).as("reviewerStatus"),
@@ -699,30 +700,30 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => {
) )
.as("inner"); .as("inner");
const countQuery = (await (tx || db)
.select(db.raw("count(*) OVER() as total_count"))
.from(innerQuery.clone().distinctOn(`${TableName.SecretApprovalRequest}.id`))) as Array<{
total_count: number;
}>;
const query = (tx || db).select("*").from(innerQuery).orderBy("createdAt", "desc") as typeof innerQuery; const query = (tx || db).select("*").from(innerQuery).orderBy("createdAt", "desc") as typeof innerQuery;
if (search) { if (search) {
void query.where((qb) => { void query.where((qb) => {
void qb void qb
.whereRaw(`CONCAT_WS(' ', ??, ??) ilike ?`, [ .whereRaw(`CONCAT_WS(' ', ??, ??) ilike ?`, [
db.ref("firstName").withSchema("committerUser"), db.ref("committerUserFirstName"),
db.ref("lastName").withSchema("committerUser"), db.ref("committerUserLastName"),
`%${search}%` `%${search}%`
]) ])
.orWhereRaw(`?? ilike ?`, [db.ref("username").withSchema("committerUser"), `%${search}%`]) .orWhereRaw(`?? ilike ?`, [db.ref("committerUserUsername"), `%${search}%`])
.orWhereRaw(`?? ilike ?`, [db.ref("email").withSchema("committerUser"), `%${search}%`]) .orWhereRaw(`?? ilike ?`, [db.ref("committerUserEmail"), `%${search}%`])
.orWhereILike(`${TableName.Environment}.name`, `%${search}%`) .orWhereILike(`environmentName`, `%${search}%`)
.orWhereILike(`${TableName.Environment}.slug`, `%${search}%`) .orWhereILike(`environment`, `%${search}%`)
.orWhereILike(`${TableName.SecretApprovalPolicy}.secretPath`, `%${search}%`); .orWhereILike(`policySecretPath`, `%${search}%`);
}); });
} }
const countQuery = (await (tx || db)
.select(db.raw("count(*) OVER() as total_count"))
.from(query.clone().as("outer"))) as Array<{
total_count: number;
}>;
const rankOffset = offset + 1; const rankOffset = offset + 1;
const docs = await (tx || db) const docs = await (tx || db)
.with("w", query) .with("w", query)
@@ -0,0 +1,11 @@
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,
enterprise: true
};
@@ -0,0 +1,151 @@
import { getChefDataBagItem, updateChefDataBagItem } from "@app/ee/services/app-connections/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,47 @@
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),
enterprise: z.boolean()
});
@@ -0,0 +1,41 @@
import z from "zod";
import { TChefConnection } from "@app/ee/services/app-connections/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";
+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,
@@ -1,6 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import { ProjectType } from "@app/db/schemas"; import { ProjectType } from "@app/db/schemas";
import { ChefConnectionListItemSchema, SanitizedChefConnectionSchema } from "@app/ee/services/app-connections/chef";
import { OCIConnectionListItemSchema, SanitizedOCIConnectionSchema } from "@app/ee/services/app-connections/oci"; import { OCIConnectionListItemSchema, SanitizedOCIConnectionSchema } from "@app/ee/services/app-connections/oci";
import { import {
OracleDBConnectionListItemSchema, OracleDBConnectionListItemSchema,
@@ -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) => {
@@ -1,3 +1,4 @@
import { registerChefConnectionRouter } from "@app/ee/routes/v1/app-connection-routers/chef-connection-router";
import { registerOCIConnectionRouter } from "@app/ee/routes/v1/app-connection-routers/oci-connection-router"; import { registerOCIConnectionRouter } from "@app/ee/routes/v1/app-connection-routers/oci-connection-router";
import { registerOracleDBConnectionRouter } from "@app/ee/routes/v1/app-connection-routers/oracledb-connection-router"; import { registerOracleDBConnectionRouter } from "@app/ee/routes/v1/app-connection-routers/oracledb-connection-router";
import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { AppConnection } from "@app/services/app-connection/app-connection-enums";
@@ -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 };
}
});
}; };
@@ -2,6 +2,7 @@ import { z } from "zod";
import { import {
AccessScope, AccessScope,
OrgMembershipRole,
ProjectMembershipRole, ProjectMembershipRole,
ProjectMembershipsSchema, ProjectMembershipsSchema,
ProjectUserMembershipRolesSchema, ProjectUserMembershipRolesSchema,
@@ -266,6 +267,19 @@ export const registerProjectMembershipRouter = async (server: FastifyZodProvider
onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]), onRequest: verifyAuth([AuthMode.JWT, AuthMode.API_KEY, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => { handler: async (req) => {
const usernamesAndEmails = [...req.body.emails, ...req.body.usernames]; const usernamesAndEmails = [...req.body.emails, ...req.body.usernames];
await server.services.membershipUser.createMembership({
permission: req.permission,
scopeData: {
scope: AccessScope.Organization,
orgId: req.permission.orgId
},
data: {
roles: [{ isTemporary: false, role: OrgMembershipRole.NoAccess }],
usernames: usernamesAndEmails
}
});
const { memberships } = await server.services.membershipUser.createMembership({ const { memberships } = await server.services.membershipUser.createMembership({
permission: req.permission, permission: req.permission,
scopeData: { scopeData: {
@@ -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({
@@ -1,3 +1,4 @@
import { registerChefSyncRouter } from "@app/ee/routes/v1/secret-sync-routers/chef-sync-router";
import { registerOCIVaultSyncRouter } from "@app/ee/routes/v1/secret-sync-routers/oci-vault-sync-router"; import { registerOCIVaultSyncRouter } from "@app/ee/routes/v1/secret-sync-routers/oci-vault-sync-router";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
@@ -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
}; };
@@ -1,6 +1,7 @@
import { z } from "zod"; 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 { ChefSyncListItemSchema, ChefSyncSchema } from "@app/ee/services/secret-sync/chef";
import { OCIVaultSyncListItemSchema, OCIVaultSyncSchema } from "@app/ee/services/secret-sync/oci-vault"; import { OCIVaultSyncListItemSchema, OCIVaultSyncSchema } from "@app/ee/services/secret-sync/oci-vault";
import { ApiDocsTags, SecretSyncs } from "@app/lib/api-docs"; import { ApiDocsTags, SecretSyncs } from "@app/lib/api-docs";
import { readLimit } from "@app/server/config/rateLimiter"; import { readLimit } from "@app/server/config/rateLimiter";
@@ -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"
} }
@@ -1,5 +1,10 @@
import { ProjectType } from "@app/db/schemas"; import { ProjectType } from "@app/db/schemas";
import { TAppConnections } from "@app/db/schemas/app-connections"; import { TAppConnections } from "@app/db/schemas/app-connections";
import {
ChefConnectionMethod,
getChefConnectionListItem,
validateChefConnectionCredentials
} from "@app/ee/services/app-connections/chef";
import { import {
getOCIConnectionListItem, getOCIConnectionListItem,
OCIConnectionMethod, OCIConnectionMethod,
@@ -210,7 +215,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 +347,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 +416,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 +492,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.Enterprise,
[AppConnection.Northflank]: AppConnectionPlanType.Regular [AppConnection.Northflank]: AppConnectionPlanType.Regular
}; };
@@ -1,6 +1,8 @@
import { ForbiddenError, subject } from "@casl/ability"; import { ForbiddenError, subject } from "@casl/ability";
import { ActionProjectType, OrganizationActionScope, TAppConnections } from "@app/db/schemas"; import { ActionProjectType, OrganizationActionScope, TAppConnections } from "@app/db/schemas";
import { ValidateChefConnectionCredentialsSchema } from "@app/ee/services/app-connections/chef";
import { chefConnectionService } from "@app/ee/services/app-connections/chef/chef-connection-service";
import { ValidateOCIConnectionCredentialsSchema } from "@app/ee/services/app-connections/oci"; import { ValidateOCIConnectionCredentialsSchema } from "@app/ee/services/app-connections/oci";
import { ociConnectionService } from "@app/ee/services/app-connections/oci/oci-connection-service"; import { ociConnectionService } from "@app/ee/services/app-connections/oci/oci-connection-service";
import { ValidateOracleDBConnectionCredentialsSchema } from "@app/ee/services/app-connections/oracledb"; import { ValidateOracleDBConnectionCredentialsSchema } from "@app/ee/services/app-connections/oracledb";
@@ -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, licenseService)
}; };
}; };
@@ -1,3 +1,9 @@
import {
TChefConnection,
TChefConnectionConfig,
TChefConnectionInput,
TValidateChefConnectionCredentialsSchema
} from "@app/ee/services/app-connections/chef";
import { import {
TOCIConnection, TOCIConnection,
TOCIConnectionConfig, TOCIConnectionConfig,
@@ -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;
@@ -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
@@ -244,8 +244,8 @@ export const identityTokenAuthServiceFactory = ({
} }
if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.TOKEN_AUTH)) { if (!identityMembershipOrg.identity.authMethods.includes(IdentityAuthMethod.TOKEN_AUTH)) {
throw new BadRequestError({ throw new NotFoundError({
message: "The identity does not have Token Auth attached" message: "Token Auth configuration not found for identity"
}); });
} }
@@ -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
}); });
} }
});
// Identify expired/removed certificates that need to be cleaned up from ACM if (targetArn) {
const certificatesToRemove = Object.values(acmCertificates) activeExternalIdentifiers.add(targetArn);
.filter((acmCert) => { }
if (!acmCert.arn || !acmCert.Tags) { }
return false;
const certificatesToRemove: string[] = [];
if (canRemoveCertificates) {
existingSyncRecords.forEach((syncRecord) => {
if (syncRecord.externalIdentifier && !activeExternalIdentifiers.has(syncRecord.externalIdentifier)) {
const acmCert = acmCertificatesByArn.get(syncRecord.externalIdentifier);
if (acmCert?.arn) {
certificatesToRemove.push(acmCert.arn);
}
} }
});
const certNameTag = findInfisicalCertificateTag(acmCert.Tags); Object.values(acmCertificates).forEach((acmCert) => {
if (!certNameTag || !certNameTag.Value) { if (acmCert.arn && acmCert.Tags) {
return false; const hasInfisicalTag = acmCert.Tags.some((tag) => tag.Key === INFISICAL_CERTIFICATE_TAG && tag.Value);
if (hasInfisicalTag) {
const isTrackedInSyncRecords = existingSyncRecords.some(
(record) => record.externalIdentifier === acmCert.arn
);
const isInActiveSet = activeExternalIdentifiers.has(acmCert.arn);
if (!isTrackedInSyncRecords && !isInActiveSet && !certificatesToRemove.includes(acmCert.arn)) {
certificatesToRemove.push(acmCert.arn);
}
}
} }
});
const isActive = activeCertificateNames.includes(certNameTag.Value); }
return !isActive;
})
.map((acmCert) => acmCert.arn!)
.filter((arn) => arn);
const uploadResults = await executeWithConcurrencyLimit( const uploadResults = await executeWithConcurrencyLimit(
setCertificates, setCertificates,
async ({ key, name, cert, privateKey, certificateChain, existingArn }) => { async ({ key, name, cert, privateKey, certificateChain, existingArn, certificateId }) => {
try { try {
const importParams: AWS.ACM.ImportCertificateRequest = { const importParams: AWS.ACM.ImportCertificateRequest = {
Certificate: cert, Certificate: cert,
PrivateKey: privateKey, PrivateKey: privateKey
Tags: [ };
if (!existingArn) {
importParams.Tags = [
{ {
Key: INFISICAL_CERTIFICATE_TAG, Key: INFISICAL_CERTIFICATE_TAG,
Value: key Value: key
} }
] ];
}; }
if (certificateChain && certificateChain.trim().length > 0) { if (certificateChain && certificateChain.trim().length > 0) {
importParams.CertificateChain = certificateChain; importParams.CertificateChain = certificateChain;
@@ -478,6 +589,57 @@ export const awsCertificateManagerPkiSyncFactory = ({
syncId: pkiSync.id syncId: pkiSync.id
}); });
if (existingArn && response.CertificateArn) {
try {
// Small delay to ensure AWS ACM has processed the certificate import
await new Promise<void>((resolve) => {
setTimeout(() => resolve(), 500);
});
await withRateLimitRetry(
() =>
acm
.addTagsToCertificate({
CertificateArn: response.CertificateArn!,
Tags: [
{
Key: INFISICAL_CERTIFICATE_TAG,
Value: key
}
]
})
.promise(),
{
operation: "add-tags-to-certificate",
syncId: pkiSync.id
}
);
} catch (tagError) {
const errorMessage = tagError instanceof Error ? tagError.message : "Unknown tagging error";
logger.warn(
`Failed to add tags to certificate ${key} (ARN: ${response.CertificateArn}): ${errorMessage}`
);
}
}
if (response.CertificateArn && certificateId) {
const existingCertSync = await certificateSyncDAL.findByPkiSyncAndCertificate(pkiSync.id, certificateId);
if (existingCertSync) {
await certificateSyncDAL.updateById(existingCertSync.id, {
externalIdentifier: response.CertificateArn,
syncStatus: CertificateSyncStatus.Succeeded,
lastSyncedAt: new Date()
});
} else {
await certificateSyncDAL.addCertificates(pkiSync.id, [
{
certificateId,
externalIdentifier: response.CertificateArn
}
]);
}
}
return { key, name, success: true, response }; return { key, name, success: true, response };
} catch (error) { } catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error"; const errorMessage = error instanceof Error ? error.message : "Unknown error";
@@ -520,15 +682,21 @@ export const awsCertificateManagerPkiSyncFactory = ({
const details: { const details: {
failedUploads?: Array<{ name: string; error: string }>; failedUploads?: Array<{ name: string; error: string }>;
failedRemovals?: Array<{ name: string; error: string }>; failedRemovals?: Array<{ name: string; error: string }>;
validationErrors?: Array<{ name: string; error: string }>;
} = {}; } = {};
if (validationErrors.length > 0) {
details.validationErrors = validationErrors;
}
if (failedUploads.length > 0) { if (failedUploads.length > 0) {
details.failedUploads = failedUploads.map((failure, index) => { details.failedUploads = failedUploads.map((failure, index) => {
const certificateName = setCertificates[index]?.name || "unknown"; const certificateRequest = setCertificates[index];
const certificateName = certificateRequest?.name || certificateRequest?.key || "unknown";
let errorMessage = "Unknown error"; let errorMessage = "Unknown error";
if (failure.status === "rejected") { if (failure.status === "rejected") {
errorMessage = failure.reason instanceof Error ? failure.reason.message : "Unknown error"; errorMessage = failure.reason instanceof Error ? failure.reason.message : String(failure.reason);
} }
return { return {
@@ -567,7 +735,8 @@ export const awsCertificateManagerPkiSyncFactory = ({
const removeCertificates = async ( const removeCertificates = async (
pkiSync: TPkiSyncWithCredentials, pkiSync: TPkiSyncWithCredentials,
certificateNames: string[] certificateNames: string[],
deps?: { certificateSyncDAL?: TCertificateSyncDALFactory; certificateMap?: TCertificateMap }
): Promise<RemoveCertificatesResult> => { ): Promise<RemoveCertificatesResult> => {
const destinationConfig = pkiSync.destinationConfig as TAwsCertificateManagerPkiSyncConfig; const destinationConfig = pkiSync.destinationConfig as TAwsCertificateManagerPkiSyncConfig;
const acm = await getAwsAcmClient( const acm = await getAwsAcmClient(
@@ -577,22 +746,33 @@ export const awsCertificateManagerPkiSyncFactory = ({
kmsService kmsService
); );
const { acmCertificates } = await $getAwsAcmCertificates(acm, pkiSync.id); const existingSyncRecords = await certificateSyncDAL.findByPkiSyncId(pkiSync.id);
const certificateArnsToRemove: string[] = []; const certificateArnsToRemove: string[] = [];
const certificateIdToArnMap = new Map<string, string>();
for (const certName of certificateNames) { for (const certName of certificateNames) {
const matchingCerts = Object.values(acmCertificates).filter((acmCert) => const certificateData = deps?.certificateMap?.[certName];
validateCertificateIdentification(certName, acmCert) if (certificateData?.certificateId) {
); const { certificateId } = certificateData;
for (const acmCert of matchingCerts) { if (typeof certificateId === "string") {
if (acmCert.arn) { const syncRecord = existingSyncRecords.find((record) => record.certificateId === certificateId);
certificateArnsToRemove.push(acmCert.arn);
if (syncRecord?.externalIdentifier) {
certificateArnsToRemove.push(syncRecord.externalIdentifier);
certificateIdToArnMap.set(certificateId, syncRecord.externalIdentifier);
}
} }
} }
} }
if (certificateArnsToRemove.length === 0) {
return {
removed: 0,
failed: 0,
skipped: certificateNames.length
};
}
const results = await executeWithConcurrencyLimit( const results = await executeWithConcurrencyLimit(
certificateArnsToRemove, certificateArnsToRemove,
async (certificateArn) => async (certificateArn) =>
@@ -602,6 +782,38 @@ export const awsCertificateManagerPkiSyncFactory = ({
const failedRemovals = results.filter((result) => result.status === "rejected"); const failedRemovals = results.filter((result) => result.status === "rejected");
if (failedRemovals.length > 0 && deps?.certificateSyncDAL) {
for (const failure of failedRemovals) {
if (failure.status === "rejected") {
const failedArn = certificateArnsToRemove[results.indexOf(failure)];
const certificateId = Array.from(certificateIdToArnMap.entries()).find(([, arn]) => arn === failedArn)?.[0];
if (certificateId) {
const errorMessage = failure.reason instanceof Error ? failure.reason.message : "Unknown error";
await deps.certificateSyncDAL.updateSyncStatus(
pkiSync.id,
certificateId,
CertificateSyncStatus.Failed,
`Failed to remove from AWS: ${errorMessage}`
);
}
}
}
}
const successfulRemovals = results.filter((result) => result.status === "fulfilled");
if (successfulRemovals.length > 0) {
const successfulArns = new Set(successfulRemovals.map((_, index) => certificateArnsToRemove[index]));
const certificateIdsToRemove = Array.from(certificateIdToArnMap.entries())
.filter(([, arn]) => successfulArns.has(arn))
.map(([certificateId]) => certificateId);
if (certificateIdsToRemove.length > 0) {
await certificateSyncDAL.removeCertificates(pkiSync.id, certificateIdsToRemove);
}
}
if (failedRemovals.length > 0) { if (failedRemovals.length > 0) {
const failedReasons = failedRemovals.map((failure) => { const failedReasons = failedRemovals.map((failure) => {
if (failure.status === "rejected") { if (failure.status === "rejected") {
@@ -14,6 +14,7 @@ export const AwsCertificateManagerPkiSyncConfigSchema = z.object({
const AwsCertificateManagerPkiSyncOptionsSchema = z.object({ const AwsCertificateManagerPkiSyncOptionsSchema = z.object({
canImportCertificates: z.boolean().default(false), canImportCertificates: z.boolean().default(false),
canRemoveCertificates: z.boolean().default(true), canRemoveCertificates: z.boolean().default(true),
preserveArn: z.boolean().default(true),
certificateNameSchema: z certificateNameSchema: z
.string() .string()
.optional() .optional()
@@ -28,6 +29,9 @@ const AwsCertificateManagerPkiSyncOptionsSchema = z.object({
const testName = schema const testName = schema
.replace(new RE2("\\{\\{certificateId\\}\\}", "g"), "test-cert-id") .replace(new RE2("\\{\\{certificateId\\}\\}", "g"), "test-cert-id")
.replace(new RE2("\\{\\{profileId\\}\\}", "g"), "test-profile-id")
.replace(new RE2("\\{\\{commonName\\}\\}", "g"), "test-common-name")
.replace(new RE2("\\{\\{friendlyName\\}\\}", "g"), "test-friendly-name")
.replace(new RE2("\\{\\{environment\\}\\}", "g"), "test-env"); .replace(new RE2("\\{\\{environment\\}\\}", "g"), "test-env");
const hasForbiddenChars = AWS_CERTIFICATE_MANAGER_CERTIFICATE_NAMING.FORBIDDEN_CHARACTERS.split("").some( const hasForbiddenChars = AWS_CERTIFICATE_MANAGER_CERTIFICATE_NAMING.FORBIDDEN_CHARACTERS.split("").some(
@@ -43,7 +47,7 @@ const AwsCertificateManagerPkiSyncOptionsSchema = z.object({
}, },
{ {
message: message:
"Certificate name schema must include {{certificateId}} placeholder and result in names that contain only alphanumeric characters, spaces, hyphens, and underscores and be 1-256 characters long when compiled for AWS Certificate Manager" "Certificate name schema must include {{certificateId}} placeholder and result in names that contain only alphanumeric characters, spaces, hyphens, and underscores and be 1-256 characters long when compiled for AWS Certificate Manager. Available placeholders: {{certificateId}}, {{profileId}}, {{commonName}}, {{friendlyName}}, {{environment}}"
} }
) )
}); });
@@ -60,9 +64,10 @@ export const CreateAwsCertificateManagerPkiSyncSchema = z.object({
isAutoSyncEnabled: z.boolean().default(true), isAutoSyncEnabled: z.boolean().default(true),
destinationConfig: AwsCertificateManagerPkiSyncConfigSchema, destinationConfig: AwsCertificateManagerPkiSyncConfigSchema,
syncOptions: AwsCertificateManagerPkiSyncOptionsSchema.optional().default({}), syncOptions: AwsCertificateManagerPkiSyncOptionsSchema.optional().default({}),
subscriberId: z.string().optional(), subscriberId: z.string().nullish(),
connectionId: z.string(), connectionId: z.string(),
projectId: z.string().trim().min(1) projectId: z.string().trim().min(1),
certificateIds: z.array(z.string().uuid()).optional()
}); });
export const UpdateAwsCertificateManagerPkiSyncSchema = z.object({ export const UpdateAwsCertificateManagerPkiSyncSchema = z.object({
@@ -71,7 +76,7 @@ export const UpdateAwsCertificateManagerPkiSyncSchema = z.object({
isAutoSyncEnabled: z.boolean().optional(), isAutoSyncEnabled: z.boolean().optional(),
destinationConfig: AwsCertificateManagerPkiSyncConfigSchema.optional(), destinationConfig: AwsCertificateManagerPkiSyncConfigSchema.optional(),
syncOptions: AwsCertificateManagerPkiSyncOptionsSchema.optional(), syncOptions: AwsCertificateManagerPkiSyncOptionsSchema.optional(),
subscriberId: z.string().optional(), subscriberId: z.string().nullish(),
connectionId: z.string().optional() connectionId: z.string().optional()
}); });
@@ -39,6 +39,7 @@ export interface SyncCertificatesResult {
details?: { details?: {
failedUploads?: Array<{ name: string; error: string }>; failedUploads?: Array<{ name: string; error: string }>;
failedRemovals?: Array<{ name: string; error: string }>; failedRemovals?: Array<{ name: string; error: string }>;
validationErrors?: Array<{ name: string; error: string }>;
}; };
} }
@@ -55,4 +56,5 @@ export interface CertificateImportRequest {
privateKey: string; privateKey: string;
certificateChain?: string; certificateChain?: string;
existingArn?: string; existingArn?: string;
certificateId?: string;
} }
@@ -2,10 +2,14 @@
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import * as crypto from "crypto"; import * as crypto from "crypto";
import { TCertificateSyncs } from "@app/db/schemas";
import { request } from "@app/lib/config/request"; import { request } from "@app/lib/config/request";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal";
import { getAzureConnectionAccessToken } from "@app/services/app-connection/azure-key-vault"; import { getAzureConnectionAccessToken } from "@app/services/app-connection/azure-key-vault";
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
import { TCertificateSyncDALFactory } from "@app/services/certificate-sync/certificate-sync-dal";
import { CertificateSyncStatus } from "@app/services/certificate-sync/certificate-sync-enums";
import { createConnectionQueue, RateLimitConfig } from "@app/services/connection-queue"; import { createConnectionQueue, RateLimitConfig } from "@app/services/connection-queue";
import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { matchesCertificateNameSchema } from "@app/services/pki-sync/pki-sync-fns"; import { matchesCertificateNameSchema } from "@app/services/pki-sync/pki-sync-fns";
@@ -32,7 +36,9 @@ const extractCertificateNameFromId = (certificateId: string): string => {
}; };
const isInfisicalManagedCertificate = (certificateName: string, pkiSync: TPkiSyncWithCredentials): boolean => { const isInfisicalManagedCertificate = (certificateName: string, pkiSync: TPkiSyncWithCredentials): boolean => {
const syncOptions = pkiSync.syncOptions as { certificateNameSchema?: string } | undefined; const syncOptions = pkiSync.syncOptions as
| { certificateNameSchema?: string; canRemoveCertificates?: boolean }
| undefined;
const certificateNameSchema = syncOptions?.certificateNameSchema; const certificateNameSchema = syncOptions?.certificateNameSchema;
if (certificateNameSchema) { if (certificateNameSchema) {
@@ -46,6 +52,16 @@ const isInfisicalManagedCertificate = (certificateName: string, pkiSync: TPkiSyn
type TAzureKeyVaultPkiSyncFactoryDeps = { type TAzureKeyVaultPkiSyncFactoryDeps = {
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "updateById">; appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "updateById">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">; kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
certificateSyncDAL: Pick<
TCertificateSyncDALFactory,
| "removeCertificates"
| "addCertificates"
| "findByPkiSyncAndCertificate"
| "updateById"
| "findByPkiSyncId"
| "updateSyncStatus"
>;
certificateDAL: Pick<TCertificateDALFactory, "findById">;
}; };
const parseCertificateX509Props = (certPem: string) => { const parseCertificateX509Props = (certPem: string) => {
@@ -188,7 +204,12 @@ const parseCertificateKeyProps = (certPem: string) => {
} }
}; };
export const azureKeyVaultPkiSyncFactory = ({ kmsService, appConnectionDAL }: TAzureKeyVaultPkiSyncFactoryDeps) => { export const azureKeyVaultPkiSyncFactory = ({
kmsService,
appConnectionDAL,
certificateSyncDAL,
certificateDAL
}: TAzureKeyVaultPkiSyncFactoryDeps) => {
const $getAzureKeyVaultCertificates = async (accessToken: string, vaultBaseUrl: string, syncId = "unknown") => { const $getAzureKeyVaultCertificates = async (accessToken: string, vaultBaseUrl: string, syncId = "unknown") => {
const paginateAzureKeyVaultCertificates = async () => { const paginateAzureKeyVaultCertificates = async () => {
let result: GetAzureKeyVaultCertificate[] = []; let result: GetAzureKeyVaultCertificate[] = [];
@@ -325,48 +346,126 @@ export const azureKeyVaultPkiSyncFactory = ({ kmsService, appConnectionDAL }: TA
pkiSync.id pkiSync.id
); );
const existingSyncRecords = await certificateSyncDAL.findByPkiSyncId(pkiSync.id);
const syncRecordsByCertId = new Map<string, TCertificateSyncs>();
const syncRecordsByExternalId = new Map<string, TCertificateSyncs>();
existingSyncRecords.forEach((record: TCertificateSyncs) => {
if (record.certificateId) {
syncRecordsByCertId.set(record.certificateId, record);
}
if (record.externalIdentifier) {
syncRecordsByExternalId.set(record.externalIdentifier, record);
}
});
const setCertificates: { const setCertificates: {
key: string; key: string;
cert: string; cert: string;
privateKey: string; privateKey: string;
certificateChain?: string; certificateChain?: string;
certificateId?: string;
}[] = []; }[] = [];
// Track which certificates should exist in Azure Key Vault const syncOptions = pkiSync.syncOptions as
const activeCertificateNames = Object.keys(certificateMap); | { certificateNameSchema?: string; canRemoveCertificates?: boolean; enableVersioning?: boolean }
| undefined;
const canRemoveCertificates = syncOptions?.canRemoveCertificates ?? true;
const enableVersioning = syncOptions?.enableVersioning ?? true;
const activeExternalIdentifiers = new Set<string>();
// Iterate through certificates to sync to Azure Key Vault // Iterate through certificates to sync to Azure Key Vault
Object.entries(certificateMap).forEach(([certName, { cert, privateKey, certificateChain }]) => { for (const [certName, { cert, privateKey, certificateChain, certificateId }] of Object.entries(certificateMap)) {
if (disabledAzureKeyVaultCertificateKeys.includes(certName)) { if (disabledAzureKeyVaultCertificateKeys.includes(certName)) {
return; // eslint-disable-next-line no-continue
continue;
} }
const existingCert = vaultCertificates[certName]; if (enableVersioning && typeof certificateId === "string") {
const shouldUpdateCert = !existingCert || existingCert.cert !== cert; const certificate = await certificateDAL.findById(certificateId);
if (certificate?.renewedByCertificateId) {
// eslint-disable-next-line no-continue
continue;
}
}
if (shouldUpdateCert) { let targetCertName = certName;
let shouldCreateNew = false;
if (typeof certificateId === "string") {
const existingSyncRecord = syncRecordsByCertId.get(certificateId);
if (existingSyncRecord?.externalIdentifier) {
const existingAzureCert = vaultCertificates[existingSyncRecord.externalIdentifier];
if (existingAzureCert && enableVersioning) {
targetCertName = existingSyncRecord.externalIdentifier;
activeExternalIdentifiers.add(targetCertName);
const shouldUpdateCert = existingAzureCert.cert !== cert;
if (shouldUpdateCert) {
shouldCreateNew = true;
}
} else if (!existingAzureCert) {
shouldCreateNew = true;
} else if (!enableVersioning) {
shouldCreateNew = true;
}
} else {
shouldCreateNew = true;
}
} else {
shouldCreateNew = true;
}
if (shouldCreateNew || !vaultCertificates[targetCertName] || vaultCertificates[targetCertName].cert !== cert) {
setCertificates.push({ setCertificates.push({
key: certName, key: targetCertName,
cert, cert,
privateKey, privateKey,
certificateChain certificateChain,
certificateId
}); });
} }
});
// Identify expired/removed certificates that need to be cleaned up from Azure Key Vault if (targetCertName) {
// Only remove certificates that were managed by Infisical (match naming schema) activeExternalIdentifiers.add(targetCertName);
const certificatesToRemove = Object.keys(vaultCertificates).filter( }
(vaultCertName) => }
isInfisicalManagedCertificate(vaultCertName, pkiSync) &&
!activeCertificateNames.includes(vaultCertName) && const certificatesToRemove: string[] = [];
!disabledAzureKeyVaultCertificateKeys.includes(vaultCertName)
); if (canRemoveCertificates) {
existingSyncRecords.forEach((syncRecord) => {
if (syncRecord.externalIdentifier && !activeExternalIdentifiers.has(syncRecord.externalIdentifier)) {
if (vaultCertificates[syncRecord.externalIdentifier]) {
certificatesToRemove.push(syncRecord.externalIdentifier);
}
}
});
Object.keys(vaultCertificates).forEach((certificateName) => {
const isInfisicalManaged = isInfisicalManagedCertificate(certificateName, pkiSync);
if (isInfisicalManaged) {
const isTrackedInSyncRecords = existingSyncRecords.some(
(record) => record.externalIdentifier === certificateName
);
const isInActiveSet = activeExternalIdentifiers.has(certificateName);
if (!isTrackedInSyncRecords && !isInActiveSet && !certificatesToRemove.includes(certificateName)) {
certificatesToRemove.push(certificateName);
}
}
});
}
// Upload certificates to Azure Key Vault with rate limiting // Upload certificates to Azure Key Vault with rate limiting
const uploadResults = await executeWithConcurrencyLimit( const uploadResults = await executeWithConcurrencyLimit(
setCertificates, setCertificates,
async ({ key, cert, privateKey, certificateChain }) => { async ({ key, cert, privateKey, certificateChain, certificateId }) => {
try { try {
// Combine private key, certificate, and certificate chain in PEM format for Azure Key Vault // Combine private key, certificate, and certificate chain in PEM format for Azure Key Vault
let combinedPem = ""; let combinedPem = "";
@@ -428,6 +527,31 @@ export const azureKeyVaultPkiSyncFactory = ({ kmsService, appConnectionDAL }: TA
} }
); );
if (certificateId) {
const existingCertSync = await certificateSyncDAL.findByPkiSyncAndCertificate(pkiSync.id, certificateId);
if (existingCertSync) {
await certificateSyncDAL.updateById(existingCertSync.id, {
externalIdentifier: key,
syncStatus: CertificateSyncStatus.Succeeded,
lastSyncedAt: new Date()
});
} else {
await certificateSyncDAL.addCertificates(pkiSync.id, [
{
certificateId,
externalIdentifier: key
}
]);
}
if (enableVersioning) {
const currentCertificate = await certificateDAL.findById(certificateId);
if (currentCertificate?.renewedFromCertificateId) {
await certificateSyncDAL.removeCertificates(pkiSync.id, [currentCertificate.renewedFromCertificateId]);
}
}
}
return { key, success: true, response: response.data as unknown }; return { key, success: true, response: response.data as unknown };
} catch (error) { } catch (error) {
if (error instanceof AxiosError) { if (error instanceof AxiosError) {
@@ -599,19 +723,43 @@ export const azureKeyVaultPkiSyncFactory = ({ kmsService, appConnectionDAL }: TA
}; };
}; };
const removeCertificates = async (pkiSync: TPkiSyncWithCredentials, certificateNames: string[]) => { const removeCertificates = async (
pkiSync: TPkiSyncWithCredentials,
certificateNames: string[],
deps?: { certificateSyncDAL?: TCertificateSyncDALFactory; certificateMap?: TCertificateMap }
) => {
const { accessToken } = await getAzureConnectionAccessToken(pkiSync.connection.id, appConnectionDAL, kmsService); const { accessToken } = await getAzureConnectionAccessToken(pkiSync.connection.id, appConnectionDAL, kmsService);
// Cast destination config to Azure Key Vault config // Cast destination config to Azure Key Vault config
const destinationConfig = pkiSync.destinationConfig as TAzureKeyVaultPkiSyncConfig; const destinationConfig = pkiSync.destinationConfig as TAzureKeyVaultPkiSyncConfig;
// Only remove certificates that are managed by Infisical (match naming schema) const existingSyncRecords = await certificateSyncDAL.findByPkiSyncId(pkiSync.id);
const infisicalManagedCertNames = certificateNames.filter((certName) => const certificateNamesToRemove: string[] = [];
isInfisicalManagedCertificate(certName, pkiSync) const certificateIdToNameMap = new Map<string, string>();
);
for (const certName of certificateNames) {
if (deps?.certificateMap?.[certName]?.certificateId) {
const { certificateId } = deps.certificateMap[certName];
const syncRecord = existingSyncRecords.find((record) => record.certificateId === certificateId);
if (syncRecord?.externalIdentifier && typeof certificateId === "string") {
certificateNamesToRemove.push(syncRecord.externalIdentifier);
certificateIdToNameMap.set(certificateId, syncRecord.externalIdentifier);
}
}
}
if (certificateNamesToRemove.length === 0) {
return {
removed: 0,
failed: 0,
skipped: certificateNames.length
};
}
const results = await executeWithConcurrencyLimit( const results = await executeWithConcurrencyLimit(
infisicalManagedCertNames, certificateNamesToRemove,
async (certName) => { async (certName) => {
try { try {
const response = await request.delete( const response = await request.delete(
@@ -646,8 +794,44 @@ export const azureKeyVaultPkiSyncFactory = ({ kmsService, appConnectionDAL }: TA
}, },
{ operation: "remove-specific-certificates", syncId: pkiSync.id } { operation: "remove-specific-certificates", syncId: pkiSync.id }
); );
const failedRemovals = results.filter((result) => result.status === "rejected"); const failedRemovals = results.filter((result) => result.status === "rejected");
if (failedRemovals.length > 0 && deps?.certificateSyncDAL) {
for (const failure of failedRemovals) {
if (failure.status === "rejected") {
const failedCertName = certificateNamesToRemove[results.indexOf(failure)];
const certificateId = Array.from(certificateIdToNameMap.entries()).find(
([, name]) => name === failedCertName
)?.[0];
if (certificateId) {
const errorMessage = (failure.reason as Error)?.message || "Unknown error";
await deps.certificateSyncDAL.updateSyncStatus(
pkiSync.id,
certificateId,
CertificateSyncStatus.Failed,
`Failed to remove from Azure: ${errorMessage}`
);
}
}
}
}
const successfulRemovals = results.filter((result) => result.status === "fulfilled");
if (successfulRemovals.length > 0) {
const successfulCertNames = new Set(successfulRemovals.map((_, index) => certificateNamesToRemove[index]));
const certificateIdsToRemove = Array.from(certificateIdToNameMap.entries())
.filter(([, name]) => successfulCertNames.has(name))
.map(([certificateId]) => certificateId);
if (certificateIdsToRemove.length > 0) {
await certificateSyncDAL.removeCertificates(pkiSync.id, certificateIdsToRemove);
}
}
if (failedRemovals.length > 0) { if (failedRemovals.length > 0) {
const failedReasons = failedRemovals.map((failure) => { const failedReasons = failedRemovals.map((failure) => {
if (failure.status === "rejected") { if (failure.status === "rejected") {
@@ -660,16 +844,16 @@ export const azureKeyVaultPkiSyncFactory = ({ kmsService, appConnectionDAL }: TA
message: `Failed to remove ${failedRemovals.length} certificate(s) from Azure Key Vault`, message: `Failed to remove ${failedRemovals.length} certificate(s) from Azure Key Vault`,
context: { context: {
failedReasons, failedReasons,
totalCertificates: infisicalManagedCertNames.length, totalCertificates: certificateNamesToRemove.length,
failedCount: failedRemovals.length failedCount: failedRemovals.length
} }
}); });
} }
return { return {
removed: infisicalManagedCertNames.length - failedRemovals.length, removed: certificateNamesToRemove.length - failedRemovals.length,
failed: failedRemovals.length, failed: failedRemovals.length,
skipped: certificateNames.length - infisicalManagedCertNames.length skipped: certificateNames.length - certificateNamesToRemove.length
}; };
}; };
@@ -14,6 +14,7 @@ export const AzureKeyVaultPkiSyncConfigSchema = z.object({
const AzureKeyVaultPkiSyncOptionsSchema = z.object({ const AzureKeyVaultPkiSyncOptionsSchema = z.object({
canImportCertificates: z.boolean().default(false), canImportCertificates: z.boolean().default(false),
canRemoveCertificates: z.boolean().default(true), canRemoveCertificates: z.boolean().default(true),
enableVersioning: z.boolean().default(true),
certificateNameSchema: z certificateNameSchema: z
.string() .string()
.optional() .optional()
@@ -50,9 +51,10 @@ export const CreateAzureKeyVaultPkiSyncSchema = z.object({
isAutoSyncEnabled: z.boolean().default(true), isAutoSyncEnabled: z.boolean().default(true),
destinationConfig: AzureKeyVaultPkiSyncConfigSchema, destinationConfig: AzureKeyVaultPkiSyncConfigSchema,
syncOptions: AzureKeyVaultPkiSyncOptionsSchema.optional().default({}), syncOptions: AzureKeyVaultPkiSyncOptionsSchema.optional().default({}),
subscriberId: z.string().optional(), subscriberId: z.string().nullish(),
connectionId: z.string(), connectionId: z.string(),
projectId: z.string().trim().min(1) projectId: z.string().trim().min(1),
certificateIds: z.array(z.string().uuid()).optional()
}); });
export const UpdateAzureKeyVaultPkiSyncSchema = z.object({ export const UpdateAzureKeyVaultPkiSyncSchema = z.object({
@@ -61,7 +63,7 @@ export const UpdateAzureKeyVaultPkiSyncSchema = z.object({
isAutoSyncEnabled: z.boolean().optional(), isAutoSyncEnabled: z.boolean().optional(),
destinationConfig: AzureKeyVaultPkiSyncConfigSchema.optional(), destinationConfig: AzureKeyVaultPkiSyncConfigSchema.optional(),
syncOptions: AzureKeyVaultPkiSyncOptionsSchema.optional(), syncOptions: AzureKeyVaultPkiSyncOptionsSchema.optional(),
subscriberId: z.string().optional(), subscriberId: z.string().nullish(),
connectionId: z.string().optional() connectionId: z.string().optional()
}); });
+40 -6
View File
@@ -4,6 +4,8 @@ import { z, ZodSchema } from "zod";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { BadRequestError } from "@app/lib/errors"; import { BadRequestError } from "@app/lib/errors";
import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal";
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
import { TCertificateSyncDALFactory } from "@app/services/certificate-sync/certificate-sync-dal";
import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { AWS_CERTIFICATE_MANAGER_PKI_SYNC_LIST_OPTION } from "./aws-certificate-manager/aws-certificate-manager-pki-sync-constants"; import { AWS_CERTIFICATE_MANAGER_PKI_SYNC_LIST_OPTION } from "./aws-certificate-manager/aws-certificate-manager-pki-sync-constants";
@@ -184,6 +186,8 @@ export const PkiSyncFns = {
dependencies: { dependencies: {
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "updateById">; appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "updateById">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">; kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
certificateDAL: TCertificateDALFactory;
certificateSyncDAL: TCertificateSyncDALFactory;
} }
): Promise<{ ): Promise<{
uploaded: number; uploaded: number;
@@ -194,17 +198,28 @@ export const PkiSyncFns = {
failedUploads?: Array<{ name: string; error: string }>; failedUploads?: Array<{ name: string; error: string }>;
failedRemovals?: Array<{ name: string; error: string }>; failedRemovals?: Array<{ name: string; error: string }>;
skippedCertificates?: Array<{ name: string; reason: string }>; skippedCertificates?: Array<{ name: string; reason: string }>;
validationErrors?: Array<{ name: string; error: string }>;
}; };
}> => { }> => {
switch (pkiSync.destination) { switch (pkiSync.destination) {
case PkiSync.AzureKeyVault: { case PkiSync.AzureKeyVault: {
checkPkiSyncDestination(pkiSync, PkiSync.AzureKeyVault); checkPkiSyncDestination(pkiSync, PkiSync.AzureKeyVault);
const azureKeyVaultPkiSync = azureKeyVaultPkiSyncFactory(dependencies); const azureKeyVaultPkiSync = azureKeyVaultPkiSyncFactory({
appConnectionDAL: dependencies.appConnectionDAL,
kmsService: dependencies.kmsService,
certificateDAL: dependencies.certificateDAL,
certificateSyncDAL: dependencies.certificateSyncDAL
});
return azureKeyVaultPkiSync.syncCertificates(pkiSync, certificateMap); return azureKeyVaultPkiSync.syncCertificates(pkiSync, certificateMap);
} }
case PkiSync.AwsCertificateManager: { case PkiSync.AwsCertificateManager: {
checkPkiSyncDestination(pkiSync, PkiSync.AwsCertificateManager); checkPkiSyncDestination(pkiSync, PkiSync.AwsCertificateManager);
const awsCertificateManagerPkiSync = awsCertificateManagerPkiSyncFactory(dependencies); const awsCertificateManagerPkiSync = awsCertificateManagerPkiSyncFactory({
appConnectionDAL: dependencies.appConnectionDAL,
kmsService: dependencies.kmsService,
certificateDAL: dependencies.certificateDAL,
certificateSyncDAL: dependencies.certificateSyncDAL
});
return awsCertificateManagerPkiSync.syncCertificates(pkiSync, certificateMap); return awsCertificateManagerPkiSync.syncCertificates(pkiSync, certificateMap);
} }
default: default:
@@ -218,19 +233,38 @@ export const PkiSyncFns = {
dependencies: { dependencies: {
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "updateById">; appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "updateById">;
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">; kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
certificateSyncDAL: TCertificateSyncDALFactory;
certificateDAL: TCertificateDALFactory;
certificateMap: TCertificateMap;
} }
): Promise<void> => { ): Promise<void> => {
switch (pkiSync.destination) { switch (pkiSync.destination) {
case PkiSync.AzureKeyVault: { case PkiSync.AzureKeyVault: {
checkPkiSyncDestination(pkiSync, PkiSync.AzureKeyVault); checkPkiSyncDestination(pkiSync, PkiSync.AzureKeyVault);
const azureKeyVaultPkiSync = azureKeyVaultPkiSyncFactory(dependencies); const azureKeyVaultPkiSync = azureKeyVaultPkiSyncFactory({
await azureKeyVaultPkiSync.removeCertificates(pkiSync, certificateNames); appConnectionDAL: dependencies.appConnectionDAL,
kmsService: dependencies.kmsService,
certificateDAL: dependencies.certificateDAL,
certificateSyncDAL: dependencies.certificateSyncDAL
});
await azureKeyVaultPkiSync.removeCertificates(pkiSync, certificateNames, {
certificateSyncDAL: dependencies.certificateSyncDAL,
certificateMap: dependencies.certificateMap
});
break; break;
} }
case PkiSync.AwsCertificateManager: { case PkiSync.AwsCertificateManager: {
checkPkiSyncDestination(pkiSync, PkiSync.AwsCertificateManager); checkPkiSyncDestination(pkiSync, PkiSync.AwsCertificateManager);
const awsCertificateManagerPkiSync = awsCertificateManagerPkiSyncFactory(dependencies); const awsCertificateManagerPkiSync = awsCertificateManagerPkiSyncFactory({
await awsCertificateManagerPkiSync.removeCertificates(pkiSync, certificateNames); appConnectionDAL: dependencies.appConnectionDAL,
kmsService: dependencies.kmsService,
certificateDAL: dependencies.certificateDAL,
certificateSyncDAL: dependencies.certificateSyncDAL
});
await awsCertificateManagerPkiSync.removeCertificates(pkiSync, certificateNames, {
certificateSyncDAL: dependencies.certificateSyncDAL,
certificateMap: dependencies.certificateMap
});
break; break;
} }
default: default:
+153 -31
View File
@@ -5,6 +5,7 @@ import { AxiosError } from "axios";
import { Job } from "bullmq"; import { Job } from "bullmq";
import handlebars from "handlebars"; import handlebars from "handlebars";
import { TCertificates } from "@app/db/schemas";
import { EventType, TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-types"; import { EventType, TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-types";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore";
@@ -25,6 +26,8 @@ import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-
import { TCertificateAuthorityCertDALFactory } from "../certificate-authority/certificate-authority-cert-dal"; import { TCertificateAuthorityCertDALFactory } from "../certificate-authority/certificate-authority-cert-dal";
import { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal"; import { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal";
import { getCaCertChain } from "../certificate-authority/certificate-authority-fns"; import { getCaCertChain } from "../certificate-authority/certificate-authority-fns";
import { TCertificateSyncDALFactory } from "../certificate-sync/certificate-sync-dal";
import { CertificateSyncStatus } from "../certificate-sync/certificate-sync-enums";
import { TPkiSyncDALFactory } from "./pki-sync-dal"; import { TPkiSyncDALFactory } from "./pki-sync-dal";
import { PkiSyncStatus } from "./pki-sync-enums"; import { PkiSyncStatus } from "./pki-sync-enums";
import { PkiSyncError } from "./pki-sync-errors"; import { PkiSyncError } from "./pki-sync-errors";
@@ -55,14 +58,12 @@ type TPkiSyncQueueFactoryDep = {
auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">; auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">;
projectDAL: TProjectDALFactory; projectDAL: TProjectDALFactory;
licenseService: Pick<TLicenseServiceFactory, "getPlan">; licenseService: Pick<TLicenseServiceFactory, "getPlan">;
certificateDAL: Pick< certificateDAL: TCertificateDALFactory;
TCertificateDALFactory,
"findLatestActiveCertForSubscriber" | "findAllActiveCertsForSubscriber" | "create"
>;
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "findOne" | "create">; certificateBodyDAL: Pick<TCertificateBodyDALFactory, "findOne" | "create">;
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "findOne" | "create">; certificateSecretDAL: Pick<TCertificateSecretDALFactory, "findOne" | "create">;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findById">; certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findById">;
certificateAuthorityCertDAL: Pick<TCertificateAuthorityCertDALFactory, "findById">; certificateAuthorityCertDAL: Pick<TCertificateAuthorityCertDALFactory, "findById">;
certificateSyncDAL: TCertificateSyncDALFactory;
}; };
type PkiSyncActionJob = Job< type PkiSyncActionJob = Job<
@@ -93,7 +94,8 @@ export const pkiSyncQueueFactory = ({
certificateBodyDAL, certificateBodyDAL,
certificateSecretDAL, certificateSecretDAL,
certificateAuthorityDAL, certificateAuthorityDAL,
certificateAuthorityCertDAL certificateAuthorityCertDAL,
certificateSyncDAL
}: TPkiSyncQueueFactoryDep) => { }: TPkiSyncQueueFactoryDep) => {
const appCfg = getConfig(); const appCfg = getConfig();
@@ -153,25 +155,39 @@ export const pkiSyncQueueFactory = ({
const $getInfisicalCertificates = async ( const $getInfisicalCertificates = async (
pkiSync: TPkiSyncRaw | TPkiSyncWithCredentials pkiSync: TPkiSyncRaw | TPkiSyncWithCredentials
): Promise<TCertificateMap> => { ): Promise<{ certificateMap: TCertificateMap; certificateMetadata: Map<string, { id: string; name: string }> }> => {
const { projectId, subscriberId } = pkiSync; const { projectId, subscriberId, id: pkiSyncId } = pkiSync;
if (!subscriberId) {
throw new PkiSyncError({
message: "Invalid PKI Sync source configuration: subscriber no longer exists. Please update source subscriber.",
shouldRetry: false
});
}
const certificateMap: TCertificateMap = {}; const certificateMap: TCertificateMap = {};
const certificateMetadata = new Map<string, { id: string; name: string }>();
let certificates: Array<{ id: string; projectId: string; caCertId?: string | null }> = [];
try { try {
// Get all active certificates for the subscriber (not just the latest) if (subscriberId) {
const certificates = await certificateDAL.findAllActiveCertsForSubscriber({ const subscriberCertificates = await certificateDAL.findAllActiveCertsForSubscriber({
subscriberId subscriberId
}); });
certificates.push(...subscriberCertificates);
}
const certificateIds = await certificateSyncDAL.findCertificateIdsByPkiSyncId(pkiSyncId);
if (certificateIds.length > 0) {
const directCertificates = await certificateDAL.findActiveCertificatesByIds(certificateIds);
certificates.push(...directCertificates);
}
const uniqueCertificates = certificates.filter(
(cert, index, self) => self.findIndex((c) => c.id === cert.id) === index
);
if (uniqueCertificates.length === 0) {
return { certificateMap, certificateMetadata };
}
certificates = uniqueCertificates;
for (const certificate of certificates) { for (const certificate of certificates) {
const cert = certificate as TCertificates;
try { try {
// Get the certificate body and decrypt the certificate data // Get the certificate body and decrypt the certificate data
const certBody = await certificateBodyDAL.findOne({ certId: certificate.id }); const certBody = await certificateBodyDAL.findOne({ certId: certificate.id });
@@ -246,19 +262,45 @@ export const pkiSyncQueueFactory = ({
if (certificateNameSchema) { if (certificateNameSchema) {
const environment = "global"; const environment = "global";
certificateName = handlebars.compile(certificateNameSchema)({ const templateData = {
certificateId: certificate.id.replace(/-/g, ""), certificateId: certificate.id.replace(/-/g, ""),
profileId: cert.profileId?.replace(/-/g, "") || certificate.id.replace(/-/g, ""),
commonName: cert.commonName || "",
friendlyName: cert.friendlyName || "",
environment environment
}); };
certificateName = handlebars.compile(certificateNameSchema)(templateData);
} else { } else {
certificateName = `Infisical-${certificate.id.replace(/-/g, "")}`; const stableId = cert.profileId
? `${cert.profileId.replace(/-/g, "")}-${(cert.commonName || "").replace(/[^a-zA-Z0-9]/g, "")}`
: certificate.id.replace(/-/g, "");
certificateName = `Infisical-${stableId}`;
}
const alternativeNames: string[] = [];
const legacyName = `Infisical-${certificate.id.replace(/-/g, "")}`;
if (legacyName !== certificateName) {
alternativeNames.push(legacyName);
}
if (cert.renewedFromCertificateId) {
const originalLegacyName = `Infisical-${cert.renewedFromCertificateId.replace(/-/g, "")}`;
alternativeNames.push(originalLegacyName);
} }
certificateMap[certificateName] = { certificateMap[certificateName] = {
cert: certificatePem, cert: certificatePem,
privateKey: certPrivateKey || "", privateKey: certPrivateKey || "",
certificateChain certificateChain,
alternativeNames,
certificateId: certificate.id
}; };
certificateMetadata.set(certificateName, {
id: certificate.id,
name: certificateName
});
} else { } else {
logger.warn({ certificateId: certificate.id, subscriberId }, "Certificate body not found for certificate"); logger.warn({ certificateId: certificate.id, subscriberId }, "Certificate body not found for certificate");
} }
@@ -281,7 +323,7 @@ export const pkiSyncQueueFactory = ({
}); });
} }
return certificateMap; return { certificateMap, certificateMetadata };
}; };
const queuePkiSyncSyncCertificatesById = async (payload: TQueuePkiSyncSyncCertificatesByIdDTO) => const queuePkiSyncSyncCertificatesById = async (payload: TQueuePkiSyncSyncCertificatesByIdDTO) =>
@@ -348,12 +390,17 @@ export const pkiSyncQueueFactory = ({
try { try {
const { const {
connection: { orgId, encryptedCredentials, projectId: appConnectionProjectId } connection: { id: connectionId, orgId, projectId: appConnectionProjectId }
} = pkiSync; } = pkiSync;
const appConnection = await appConnectionDAL.findById(connectionId);
if (!appConnection) {
throw new Error(`App connection not found: ${connectionId}`);
}
const credentials = await decryptAppConnectionCredentials({ const credentials = await decryptAppConnectionCredentials({
orgId, orgId,
encryptedCredentials, encryptedCredentials: appConnection.encryptedCredentials,
kmsService, kmsService,
projectId: appConnectionProjectId projectId: appConnectionProjectId
}); });
@@ -366,11 +413,24 @@ export const pkiSyncQueueFactory = ({
} }
} as TPkiSyncWithCredentials; } as TPkiSyncWithCredentials;
const certificateMap = await $getInfisicalCertificates(pkiSync); const { certificateMap, certificateMetadata } = await $getInfisicalCertificates(pkiSync);
const statusUpdates = Array.from(certificateMetadata.entries()).map(([, metadata]) => ({
pkiSyncId: pkiSync.id,
certificateId: metadata.id,
status: CertificateSyncStatus.Running,
message: "Syncing certificate to destination"
}));
if (statusUpdates.length > 0) {
await certificateSyncDAL.bulkUpdateSyncStatus(statusUpdates);
}
const syncResult = await PkiSyncFns.syncCertificates(pkiSyncWithCredentials, certificateMap, { const syncResult = await PkiSyncFns.syncCertificates(pkiSyncWithCredentials, certificateMap, {
appConnectionDAL, appConnectionDAL,
kmsService kmsService,
certificateDAL,
certificateSyncDAL
}); });
logger.info( logger.info(
@@ -384,6 +444,60 @@ export const pkiSyncQueueFactory = ({
"PKI sync operation completed with certificate cleanup" "PKI sync operation completed with certificate cleanup"
); );
const postSyncUpdates: Array<{
pkiSyncId: string;
certificateId: string;
status: string;
message?: string;
}> = [];
for (const [, metadata] of certificateMetadata.entries()) {
postSyncUpdates.push({
pkiSyncId: pkiSync.id,
certificateId: metadata.id,
status: CertificateSyncStatus.Succeeded,
message: "Certificate successfully synced to destination"
});
}
if (syncResult.details?.validationErrors) {
for (const validationError of syncResult.details.validationErrors) {
const metadata = certificateMetadata.get(validationError.name);
if (metadata) {
const updateIndex = postSyncUpdates.findIndex((u) => u.certificateId === metadata.id);
if (updateIndex >= 0) {
postSyncUpdates[updateIndex] = {
pkiSyncId: pkiSync.id,
certificateId: metadata.id,
status: CertificateSyncStatus.Failed,
message: `${validationError.error}`
};
}
}
}
}
if (syncResult.details?.failedUploads) {
for (const failure of syncResult.details.failedUploads) {
const metadata = certificateMetadata.get(failure.name);
if (metadata) {
const updateIndex = postSyncUpdates.findIndex((u) => u.certificateId === metadata.id);
if (updateIndex >= 0) {
postSyncUpdates[updateIndex] = {
pkiSyncId: pkiSync.id,
certificateId: metadata.id,
status: CertificateSyncStatus.Failed,
message: `Failed to sync certificate: ${failure.error}`
};
}
}
}
}
if (postSyncUpdates.length > 0) {
await certificateSyncDAL.bulkUpdateSyncStatus(postSyncUpdates);
}
isSynced = true; isSynced = true;
} catch (err) { } catch (err) {
logger.error( logger.error(
@@ -550,17 +664,22 @@ export const pkiSyncQueueFactory = ({
try { try {
const { const {
connection: { orgId, encryptedCredentials, projectId: appConnectionProjectId } connection: { id: connectionId, orgId, projectId: appConnectionProjectId }
} = pkiSync; } = pkiSync;
const appConnection = await appConnectionDAL.findById(connectionId);
if (!appConnection) {
throw new Error(`App connection not found: ${connectionId}`);
}
const credentials = await decryptAppConnectionCredentials({ const credentials = await decryptAppConnectionCredentials({
orgId, orgId,
encryptedCredentials, encryptedCredentials: appConnection.encryptedCredentials,
kmsService, kmsService,
projectId: appConnectionProjectId projectId: appConnectionProjectId
}); });
const certificateMap = await $getInfisicalCertificates(pkiSync); const { certificateMap } = await $getInfisicalCertificates(pkiSync);
await PkiSyncFns.removeCertificates( await PkiSyncFns.removeCertificates(
{ {
@@ -573,7 +692,10 @@ export const pkiSyncQueueFactory = ({
Object.keys(certificateMap), Object.keys(certificateMap),
{ {
appConnectionDAL, appConnectionDAL,
kmsService kmsService,
certificateSyncDAL,
certificateDAL,
certificateMap
} }
); );
@@ -1,6 +1,6 @@
import { ForbiddenError, subject } from "@casl/ability"; import { ForbiddenError, subject } from "@casl/ability";
import { ActionProjectType } from "@app/db/schemas"; import { ActionProjectType, TCertificateSyncs } from "@app/db/schemas";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { ProjectPermissionPkiSyncActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { ProjectPermissionPkiSyncActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
@@ -10,17 +10,24 @@ import { AppConnection } from "@app/services/app-connection/app-connection-enums
import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service";
import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal"; import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal";
import { TCertificateDALFactory } from "../certificate/certificate-dal";
import { TCertificateSyncDALFactory } from "../certificate-sync/certificate-sync-dal";
import { CertificateSyncStatus } from "../certificate-sync/certificate-sync-enums";
import { TPkiSyncDALFactory } from "./pki-sync-dal"; import { TPkiSyncDALFactory } from "./pki-sync-dal";
import { PkiSync, PkiSyncStatus } from "./pki-sync-enums"; import { PkiSync, PkiSyncStatus } from "./pki-sync-enums";
import { enterprisePkiSyncCheck, getPkiSyncProviderCapabilities, listPkiSyncOptions } from "./pki-sync-fns"; import { enterprisePkiSyncCheck, getPkiSyncProviderCapabilities, listPkiSyncOptions } from "./pki-sync-fns";
import { PKI_SYNC_CONNECTION_MAP, PKI_SYNC_NAME_MAP } from "./pki-sync-maps"; import { PKI_SYNC_CONNECTION_MAP, PKI_SYNC_NAME_MAP } from "./pki-sync-maps";
import { TPkiSyncQueueFactory } from "./pki-sync-queue"; import { TPkiSyncQueueFactory } from "./pki-sync-queue";
import { import {
TAddCertificatesToPkiSyncDTO,
TCreatePkiSyncDTO, TCreatePkiSyncDTO,
TDeletePkiSyncDTO, TDeletePkiSyncDTO,
TFindPkiSyncByIdDTO, TFindPkiSyncByIdDTO,
TListPkiSyncCertificatesDTO,
TListPkiSyncsByProjectId, TListPkiSyncsByProjectId,
TPkiSync, TPkiSync,
TPkiSyncCertificate,
TRemoveCertificatesFromPkiSyncDTO,
TTriggerPkiSyncImportCertificatesByIdDTO, TTriggerPkiSyncImportCertificatesByIdDTO,
TTriggerPkiSyncRemoveCertificatesByIdDTO, TTriggerPkiSyncRemoveCertificatesByIdDTO,
TTriggerPkiSyncSyncCertificatesByIdDTO, TTriggerPkiSyncSyncCertificatesByIdDTO,
@@ -42,6 +49,17 @@ type TPkiSyncServiceFactoryDep = {
TPkiSyncDALFactory, TPkiSyncDALFactory,
"findById" | "findByProjectIdWithSubscribers" | "findByNameAndProjectId" | "create" | "updateById" | "deleteById" "findById" | "findByProjectIdWithSubscribers" | "findByNameAndProjectId" | "create" | "updateById" | "deleteById"
>; >;
certificateDAL: Pick<TCertificateDALFactory, "findActiveCertificatesByIds">;
certificateSyncDAL: Pick<
TCertificateSyncDALFactory,
| "findByPkiSyncId"
| "findByCertificateId"
| "findCertificateIdsByPkiSyncId"
| "addCertificates"
| "removeCertificates"
| "removeAllCertificatesFromSync"
| "findWithDetails"
>;
pkiSubscriberDAL: Pick<TPkiSubscriberDALFactory, "findById">; pkiSubscriberDAL: Pick<TPkiSubscriberDALFactory, "findById">;
appConnectionService: Pick<TAppConnectionServiceFactory, "connectAppConnectionById">; appConnectionService: Pick<TAppConnectionServiceFactory, "connectAppConnectionById">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">; permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
@@ -56,12 +74,41 @@ export type TPkiSyncServiceFactory = ReturnType<typeof pkiSyncServiceFactory>;
export const pkiSyncServiceFactory = ({ export const pkiSyncServiceFactory = ({
pkiSyncDAL, pkiSyncDAL,
certificateDAL,
certificateSyncDAL,
pkiSubscriberDAL, pkiSubscriberDAL,
appConnectionService, appConnectionService,
permissionService, permissionService,
licenseService, licenseService,
pkiSyncQueue pkiSyncQueue
}: TPkiSyncServiceFactoryDep) => { }: TPkiSyncServiceFactoryDep) => {
const validateCertificatesProjectOwnership = async (certificateIds: string[], expectedProjectId: string) => {
if (certificateIds.length === 0) return;
const certificates = await certificateDAL.findActiveCertificatesByIds(certificateIds);
if (certificates.length !== certificateIds.length) {
const foundIds = certificates.map((cert) => cert.id);
const missingIds = certificateIds.filter((id) => !foundIds.includes(id));
throw new NotFoundError({
message: `Certificates not found or not active: ${missingIds.join(", ")}`
});
}
const invalidProjectCertificates = certificates.filter((cert) => cert.projectId !== expectedProjectId);
if (invalidProjectCertificates.length > 0) {
throw new BadRequestError({
message: `Certificates do not belong to the same project: ${invalidProjectCertificates.map((cert) => cert.id).join(", ")}`
});
}
const invalidRenewedCertificates = certificates.filter((cert) => cert.renewedByCertificateId);
if (invalidRenewedCertificates.length > 0) {
throw new BadRequestError({
message: `Cannot add renewed certificates to PKI sync: ${invalidRenewedCertificates.map((cert) => cert.id).join(", ")}`
});
}
};
const createPkiSync = async ( const createPkiSync = async (
{ {
name, name,
@@ -72,7 +119,8 @@ export const pkiSyncServiceFactory = ({
syncOptions = {}, syncOptions = {},
subscriberId, subscriberId,
connectionId, connectionId,
projectId projectId,
certificateIds = []
}: Omit<TCreatePkiSyncDTO, "auditLogInfo">, }: Omit<TCreatePkiSyncDTO, "auditLogInfo">,
actor: OrgServiceActor actor: OrgServiceActor
): Promise<TPkiSync> => { ): Promise<TPkiSync> => {
@@ -114,6 +162,10 @@ export const pkiSyncServiceFactory = ({
...syncOptions ...syncOptions
}; };
if (certificateIds.length > 0) {
await validateCertificatesProjectOwnership(certificateIds, projectId);
}
try { try {
const pkiSync = await pkiSyncDAL.create({ const pkiSync = await pkiSyncDAL.create({
name, name,
@@ -128,6 +180,13 @@ export const pkiSyncServiceFactory = ({
...(isAutoSyncEnabled && { syncStatus: PkiSyncStatus.Pending }) ...(isAutoSyncEnabled && { syncStatus: PkiSyncStatus.Pending })
}); });
if (certificateIds.length > 0) {
await certificateSyncDAL.addCertificates(
pkiSync.id,
certificateIds.map((id) => ({ certificateId: id }))
);
}
if (pkiSync.isAutoSyncEnabled) { if (pkiSync.isAutoSyncEnabled) {
await pkiSyncQueue.queuePkiSyncSyncCertificatesById({ syncId: pkiSync.id }); await pkiSyncQueue.queuePkiSyncSyncCertificatesById({ syncId: pkiSync.id });
} }
@@ -152,7 +211,8 @@ export const pkiSyncServiceFactory = ({
destinationConfig, destinationConfig,
syncOptions, syncOptions,
subscriberId, subscriberId,
connectionId connectionId,
certificateIds
}: Omit<TUpdatePkiSyncDTO, "auditLogInfo" | "projectId">, }: Omit<TUpdatePkiSyncDTO, "auditLogInfo" | "projectId">,
actor: OrgServiceActor actor: OrgServiceActor
): Promise<TPkiSync> => { ): Promise<TPkiSync> => {
@@ -221,6 +281,20 @@ export const pkiSyncServiceFactory = ({
}; };
} }
if (certificateIds !== undefined) {
if (certificateIds.length > 0) {
await validateCertificatesProjectOwnership(certificateIds, pkiSync.projectId);
}
await certificateSyncDAL.removeAllCertificatesFromSync(id);
if (certificateIds.length > 0) {
await certificateSyncDAL.addCertificates(
id,
certificateIds.map((certId) => ({ certificateId: certId }))
);
}
}
const updatedPkiSync = await pkiSyncDAL.updateById(id, { const updatedPkiSync = await pkiSyncDAL.updateById(id, {
name, name,
description, description,
@@ -266,7 +340,7 @@ export const pkiSyncServiceFactory = ({
}; };
const listPkiSyncsByProjectId = async ( const listPkiSyncsByProjectId = async (
{ projectId }: TListPkiSyncsByProjectId, { projectId, certificateId }: TListPkiSyncsByProjectId,
actor: OrgServiceActor actor: OrgServiceActor
): Promise<TPkiSync[]> => { ): Promise<TPkiSync[]> => {
const { permission } = await permissionService.getProjectPermission({ const { permission } = await permissionService.getProjectPermission({
@@ -282,6 +356,29 @@ export const pkiSyncServiceFactory = ({
const pkiSyncsWithSubscribers = await pkiSyncDAL.findByProjectIdWithSubscribers(projectId); const pkiSyncsWithSubscribers = await pkiSyncDAL.findByProjectIdWithSubscribers(projectId);
if (certificateId) {
const syncsWithCertificateInfo = await Promise.all(
pkiSyncsWithSubscribers.map(async (sync) => {
try {
const certificateSyncs = await certificateSyncDAL.findByPkiSyncId(sync.id);
const hasCertificate = certificateSyncs.some((certSync) => certSync.certificateId === certificateId);
return {
...sync,
hasCertificate
};
} catch (error) {
return {
...sync,
hasCertificate: false
};
}
})
);
return syncsWithCertificateInfo as TPkiSync[];
}
return pkiSyncsWithSubscribers as TPkiSync[]; return pkiSyncsWithSubscribers as TPkiSync[];
}; };
@@ -433,6 +530,145 @@ export const pkiSyncServiceFactory = ({
return listPkiSyncOptions(); return listPkiSyncOptions();
}; };
const addCertificatesToPkiSync = async (
{ pkiSyncId, certificateIds }: Omit<TAddCertificatesToPkiSyncDTO, "auditLogInfo" | "projectId">,
actor: OrgServiceActor
): Promise<{
addedCertificates: TCertificateSyncs[];
pkiSyncInfo: { projectId: string; destination: string; name: string };
}> => {
const pkiSync = await pkiSyncDAL.findById(pkiSyncId);
if (!pkiSync) throw new NotFoundError({ message: "PKI sync not found" });
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorId: actor.id,
actorAuthMethod: actor.authMethod,
actorOrgId: actor.orgId,
actionProjectType: ActionProjectType.CertificateManager,
projectId: pkiSync.projectId
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionPkiSyncActions.Edit, ProjectPermissionSub.PkiSyncs);
await validateCertificatesProjectOwnership(certificateIds, pkiSync.projectId);
const addedCertificates = await certificateSyncDAL.addCertificates(
pkiSyncId,
certificateIds.map((id) => ({ certificateId: id }))
);
if (pkiSync.isAutoSyncEnabled) {
await pkiSyncQueue.queuePkiSyncSyncCertificatesById({ syncId: pkiSyncId });
}
return {
addedCertificates,
pkiSyncInfo: {
projectId: pkiSync.projectId,
destination: pkiSync.destination,
name: pkiSync.name
}
};
};
const removeCertificatesFromPkiSync = async (
{ pkiSyncId, certificateIds }: Omit<TRemoveCertificatesFromPkiSyncDTO, "auditLogInfo" | "projectId">,
actor: OrgServiceActor
): Promise<{ removedCount: number; pkiSyncInfo: { projectId: string; destination: string; name: string } }> => {
const pkiSync = await pkiSyncDAL.findById(pkiSyncId);
if (!pkiSync) throw new NotFoundError({ message: "PKI sync not found" });
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorId: actor.id,
actorAuthMethod: actor.authMethod,
actorOrgId: actor.orgId,
actionProjectType: ActionProjectType.CertificateManager,
projectId: pkiSync.projectId
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionPkiSyncActions.Edit, ProjectPermissionSub.PkiSyncs);
const removedCount = await certificateSyncDAL.removeCertificates(pkiSyncId, certificateIds);
if (pkiSync.isAutoSyncEnabled) {
await pkiSyncQueue.queuePkiSyncSyncCertificatesById({ syncId: pkiSyncId });
}
return {
removedCount,
pkiSyncInfo: {
projectId: pkiSync.projectId,
destination: pkiSync.destination,
name: pkiSync.name
}
};
};
const listPkiSyncCertificates = async (
{ pkiSyncId, offset = 0, limit = 20 }: Omit<TListPkiSyncCertificatesDTO, "projectId">,
actor: OrgServiceActor
): Promise<{
certificates: TPkiSyncCertificate[];
totalCount: number;
pkiSyncInfo: { projectId: string; destination: string; name: string };
}> => {
const pkiSync = await pkiSyncDAL.findById(pkiSyncId);
if (!pkiSync) throw new NotFoundError({ message: "PKI sync not found" });
const { permission } = await permissionService.getProjectPermission({
actor: actor.type,
actorId: actor.id,
actorAuthMethod: actor.authMethod,
actorOrgId: actor.orgId,
actionProjectType: ActionProjectType.CertificateManager,
projectId: pkiSync.projectId
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionPkiSyncActions.Read, ProjectPermissionSub.PkiSyncs);
const result = await certificateSyncDAL.findWithDetails({
pkiSyncId,
offset,
limit
});
const { certificateDetails, totalCount } = result;
const certificates = certificateDetails.map((detail) => ({
id: detail.id,
pkiSyncId: detail.pkiSyncId,
certificateId: detail.certificateId,
syncStatus: (detail.syncStatus as CertificateSyncStatus) || CertificateSyncStatus.Pending,
lastSyncMessage: detail.lastSyncMessage || undefined,
lastSyncedAt: detail.lastSyncedAt || undefined,
createdAt: detail.createdAt,
updatedAt: detail.updatedAt,
certificateSerialNumber: detail.certificateSerialNumber || undefined,
certificateCommonName: detail.certificateCommonName || undefined,
certificateAltNames: detail.certificateAltNames || undefined,
certificateStatus: detail.certificateStatus || undefined,
certificateNotBefore: detail.certificateNotBefore || undefined,
certificateNotAfter: detail.certificateNotAfter || undefined,
certificateRenewBeforeDays: !detail.certificateRenewedByCertificateId
? detail.certificateRenewBeforeDays || undefined
: undefined,
certificateRenewalError: detail.certificateRenewalError || undefined,
pkiSyncName: detail.pkiSyncName || undefined,
pkiSyncDestination: detail.pkiSyncDestination || undefined
}));
return {
certificates,
totalCount,
pkiSyncInfo: {
projectId: pkiSync.projectId,
destination: pkiSync.destination,
name: pkiSync.name
}
};
};
return { return {
createPkiSync, createPkiSync,
updatePkiSync, updatePkiSync,
@@ -442,6 +678,9 @@ export const pkiSyncServiceFactory = ({
triggerPkiSyncSyncCertificatesById, triggerPkiSyncSyncCertificatesById,
triggerPkiSyncImportCertificatesById, triggerPkiSyncImportCertificatesById,
triggerPkiSyncRemoveCertificatesById, triggerPkiSyncRemoveCertificatesById,
getPkiSyncOptions getPkiSyncOptions,
addCertificatesToPkiSync,
removeCertificatesFromPkiSync,
listPkiSyncCertificates
}; };
}; };
@@ -2,6 +2,7 @@ import { Job } from "bullmq";
import { AuditLogInfo } from "@app/ee/services/audit-log/audit-log-types"; import { AuditLogInfo } from "@app/ee/services/audit-log/audit-log-types";
import { QueueJobs } from "@app/queue"; import { QueueJobs } from "@app/queue";
import { CertificateSyncStatus } from "@app/services/certificate-sync/certificate-sync-enums";
import { ResourceMetadataDTO } from "@app/services/resource-metadata/resource-metadata-schema"; import { ResourceMetadataDTO } from "@app/services/resource-metadata/resource-metadata-schema";
import { TPkiSyncDALFactory } from "./pki-sync-dal"; import { TPkiSyncDALFactory } from "./pki-sync-dal";
@@ -70,7 +71,10 @@ export type TPkiSyncListItem = TPkiSync & {
appConnectionApp: string; appConnectionApp: string;
}; };
export type TCertificateMap = Record<string, { cert: string; privateKey: string; certificateChain?: string }>; export type TCertificateMap = Record<
string,
{ cert: string; privateKey: string; certificateChain?: string; alternativeNames?: string[]; certificateId?: string }
>;
export type TCreatePkiSyncDTO = { export type TCreatePkiSyncDTO = {
name: string; name: string;
@@ -79,9 +83,10 @@ export type TCreatePkiSyncDTO = {
isAutoSyncEnabled?: boolean; isAutoSyncEnabled?: boolean;
destinationConfig: Record<string, unknown>; destinationConfig: Record<string, unknown>;
syncOptions?: Record<string, unknown>; syncOptions?: Record<string, unknown>;
subscriberId?: string; subscriberId?: string | null;
connectionId: string; connectionId: string;
projectId: string; projectId: string;
certificateIds?: string[];
auditLogInfo: AuditLogInfo; auditLogInfo: AuditLogInfo;
resourceMetadata?: ResourceMetadataDTO; resourceMetadata?: ResourceMetadataDTO;
}; };
@@ -94,8 +99,9 @@ export type TUpdatePkiSyncDTO = {
isAutoSyncEnabled?: boolean; isAutoSyncEnabled?: boolean;
destinationConfig?: Record<string, unknown>; destinationConfig?: Record<string, unknown>;
syncOptions?: Record<string, unknown>; syncOptions?: Record<string, unknown>;
subscriberId?: string; subscriberId?: string | null;
connectionId?: string; connectionId?: string;
certificateIds?: string[];
auditLogInfo: AuditLogInfo; auditLogInfo: AuditLogInfo;
resourceMetadata?: ResourceMetadataDTO; resourceMetadata?: ResourceMetadataDTO;
}; };
@@ -108,6 +114,7 @@ export type TDeletePkiSyncDTO = {
export type TListPkiSyncsByProjectId = { export type TListPkiSyncsByProjectId = {
projectId: string; projectId: string;
certificateId?: string;
}; };
export type TFindPkiSyncByIdDTO = { export type TFindPkiSyncByIdDTO = {
@@ -133,6 +140,48 @@ export type TTriggerPkiSyncRemoveCertificatesByIdDTO = {
auditLogInfo: AuditLogInfo; auditLogInfo: AuditLogInfo;
}; };
export type TAddCertificatesToPkiSyncDTO = {
pkiSyncId: string;
certificateIds: string[];
projectId?: string;
auditLogInfo: AuditLogInfo;
};
export type TRemoveCertificatesFromPkiSyncDTO = {
pkiSyncId: string;
certificateIds: string[];
projectId?: string;
auditLogInfo: AuditLogInfo;
};
export type TListPkiSyncCertificatesDTO = {
pkiSyncId: string;
projectId?: string;
offset?: number;
limit?: number;
};
export type TPkiSyncCertificate = {
id: string;
pkiSyncId: string;
certificateId: string;
syncStatus: CertificateSyncStatus;
lastSyncMessage?: string;
lastSyncedAt?: Date;
createdAt: Date;
updatedAt: Date;
certificateSerialNumber?: string;
certificateCommonName?: string;
certificateAltNames?: string;
certificateStatus?: string;
certificateNotBefore?: Date;
certificateNotAfter?: Date;
certificateRenewBeforeDays?: number;
certificateRenewalError?: string;
pkiSyncName?: string;
pkiSyncDestination?: string;
};
export type TPkiSyncRaw = NonNullable<Awaited<ReturnType<TPkiSyncDALFactory["findById"]>>>; export type TPkiSyncRaw = NonNullable<Awaited<ReturnType<TPkiSyncDALFactory["findById"]>>>;
export type TQueuePkiSyncSyncCertificatesByIdDTO = { export type TQueuePkiSyncSyncCertificatesByIdDTO = {
@@ -1,5 +1,8 @@
import { Knex } from "knex";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { TCertificateSyncDALFactory } from "../certificate-sync/certificate-sync-dal";
import { TPkiSyncDALFactory } from "./pki-sync-dal"; import { TPkiSyncDALFactory } from "./pki-sync-dal";
import { TPkiSyncQueueFactory } from "./pki-sync-queue"; import { TPkiSyncQueueFactory } from "./pki-sync-queue";
@@ -25,3 +28,78 @@ export const triggerAutoSyncForSubscriber = async (
logger.error(error, `Failed to trigger auto sync for subscriber ${subscriberId}:`); logger.error(error, `Failed to trigger auto sync for subscriber ${subscriberId}:`);
} }
}; };
export const triggerAutoSyncForCertificate = async (
certificateId: string,
dependencies: {
certificateSyncDAL: Pick<TCertificateSyncDALFactory, "findPkiSyncIdsByCertificateId">;
pkiSyncDAL: Pick<TPkiSyncDALFactory, "find">;
pkiSyncQueue: Pick<TPkiSyncQueueFactory, "queuePkiSyncSyncCertificatesById">;
}
) => {
try {
const pkiSyncIds = await dependencies.certificateSyncDAL.findPkiSyncIdsByCertificateId(certificateId);
if (pkiSyncIds.length === 0) {
return;
}
const allPkiSyncs = await dependencies.pkiSyncDAL.find({
isAutoSyncEnabled: true,
$in: {
id: pkiSyncIds
}
});
const syncPromises = allPkiSyncs.map((pkiSync) =>
dependencies.pkiSyncQueue.queuePkiSyncSyncCertificatesById({ syncId: pkiSync.id })
);
await Promise.all(syncPromises);
} catch (error) {
logger.error(error, `Failed to trigger auto sync for certificate ${certificateId}:`);
}
};
export const addRenewedCertificateToSyncs = async (
oldCertificateId: string,
newCertificateId: string,
dependencies: {
certificateSyncDAL: Pick<
TCertificateSyncDALFactory,
"findPkiSyncIdsByCertificateId" | "addCertificates" | "findByPkiSyncAndCertificate"
>;
},
tx?: Knex
) => {
try {
const pkiSyncIds = await dependencies.certificateSyncDAL.findPkiSyncIdsByCertificateId(oldCertificateId);
if (pkiSyncIds.length === 0) {
return;
}
const addPromises = pkiSyncIds.map(async (pkiSyncId) => {
const oldCertificateRecord = await dependencies.certificateSyncDAL.findByPkiSyncAndCertificate(
pkiSyncId,
oldCertificateId
);
await dependencies.certificateSyncDAL.addCertificates(
pkiSyncId,
[
{
certificateId: newCertificateId,
externalIdentifier: oldCertificateRecord?.externalIdentifier || undefined
}
],
tx
);
});
await Promise.all(addPromises);
logger.info(`Successfully added renewed certificate ${newCertificateId} to PKI sync(s)`);
} catch (error) {
logger.error(error, `Failed to add renewed certificate ${newCertificateId} to syncs:`);
throw error;
}
};
+37 -14
View File
@@ -156,7 +156,14 @@ type TProjectServiceFactoryDep = {
>; >;
pkiSubscriberDAL: Pick<TPkiSubscriberDALFactory, "find">; pkiSubscriberDAL: Pick<TPkiSubscriberDALFactory, "find">;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "find" | "findWithAssociatedCa">; certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "find" | "findWithAssociatedCa">;
certificateDAL: Pick<TCertificateDALFactory, "find" | "countCertificatesInProject" | "findWithPrivateKeyInfo">; certificateDAL: Pick<
TCertificateDALFactory,
| "find"
| "countCertificatesInProject"
| "findWithPrivateKeyInfo"
| "findActiveCertificatesForSync"
| "countActiveCertificatesForSync"
>;
certificateTemplateDAL: Pick<TCertificateTemplateDALFactory, "getCertTemplatesByProjectId">; certificateTemplateDAL: Pick<TCertificateTemplateDALFactory, "getCertTemplatesByProjectId">;
pkiAlertDAL: Pick<TPkiAlertDALFactory, "find">; pkiAlertDAL: Pick<TPkiAlertDALFactory, "find">;
pkiCollectionDAL: Pick<TPkiCollectionDALFactory, "find">; pkiCollectionDAL: Pick<TPkiCollectionDALFactory, "find">;
@@ -929,6 +936,7 @@ export const projectServiceFactory = ({
offset = 0, offset = 0,
friendlyName, friendlyName,
commonName, commonName,
forPkiSync = false,
actorId, actorId,
actorOrgId, actorOrgId,
actorAuthMethod, actorAuthMethod,
@@ -952,20 +960,35 @@ export const projectServiceFactory = ({
ProjectPermissionSub.Certificates ProjectPermissionSub.Certificates
); );
const certificates = await certificateDAL.findWithPrivateKeyInfo( const certificates = forPkiSync
{ ? await certificateDAL.findActiveCertificatesForSync(
projectId, {
...(friendlyName && { friendlyName }), projectId,
...(commonName && { commonName }) ...(friendlyName && { friendlyName }),
}, ...(commonName && { commonName })
{ offset, limit, sort: [["notAfter", "desc"]] } },
); { offset, limit }
)
: await certificateDAL.findWithPrivateKeyInfo(
{
projectId,
...(friendlyName && { friendlyName }),
...(commonName && { commonName })
},
{ offset, limit, sort: [["notAfter", "desc"]] }
);
const count = await certificateDAL.countCertificatesInProject({ const count = forPkiSync
projectId, ? await certificateDAL.countActiveCertificatesForSync({
friendlyName, projectId,
commonName friendlyName,
}); commonName
})
: await certificateDAL.countCertificatesInProject({
projectId,
friendlyName,
commonName
});
return { return {
certificates, certificates,
@@ -142,6 +142,7 @@ export type TListProjectCertsDTO = {
limit: number; limit: number;
friendlyName?: string; friendlyName?: string;
commonName?: string; commonName?: string;
forPkiSync?: boolean;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TListProjectAlertsDTO = TProjectPermission; export type TListProjectAlertsDTO = TProjectPermission;
@@ -419,13 +419,14 @@ export const secretFolderDALFactory = (db: TDbClient) => {
.select( .select(
selectAllTableCols(TableName.SecretFolder), selectAllTableCols(TableName.SecretFolder),
db.raw( db.raw(
`DENSE_RANK() OVER (ORDER BY ${TableName.SecretFolder}."name" ${ `DENSE_RANK() OVER (ORDER BY ${TableName.SecretFolder}."name" COLLATE "en-x-icu" ${orderDirection === OrderByDirection.ASC ? "ASC" : "DESC"}) as rank`
orderDirection ?? OrderByDirection.ASC
}) as rank`
), ),
db.ref("slug").withSchema(TableName.Environment).as("environment") db.ref("slug").withSchema(TableName.Environment).as("environment")
) )
.orderBy(`${TableName.SecretFolder}.${orderBy}`, orderDirection); .orderByRaw(
`${TableName.SecretFolder}.?? COLLATE "en-x-icu" ${orderDirection === OrderByDirection.ASC ? "ASC" : "DESC"}`,
[orderBy]
);
if (limit) { if (limit) {
const rankOffset = offset + 1; // ranks start from 1 const rankOffset = offset + 1; // ranks start from 1
@@ -434,7 +435,10 @@ export const secretFolderDALFactory = (db: TDbClient) => {
.select("*") .select("*")
.from<Awaited<typeof query>[number]>("w") .from<Awaited<typeof query>[number]>("w")
.where("w.rank", ">=", rankOffset) .where("w.rank", ">=", rankOffset)
.andWhere("w.rank", "<", rankOffset + limit); .andWhere("w.rank", "<", rankOffset + limit)
.orderByRaw(`"w".?? COLLATE "en-x-icu" ${orderDirection === OrderByDirection.ASC ? "ASC" : "DESC"}`, [
orderBy
]);
} }
const folders = await query; const folders = await query;
@@ -445,7 +449,10 @@ export const secretFolderDALFactory = (db: TDbClient) => {
} }
}; };
const findByEnvsDeep = async ({ parentIds }: TFindFoldersDeepByParentIdsDTO, tx?: Knex) => { const findByEnvsDeep = async (
{ parentIds, orderBy = SecretsOrderBy.Name, orderDirection = OrderByDirection.ASC }: TFindFoldersDeepByParentIdsDTO,
tx?: Knex
) => {
try { try {
const folders = await (tx || db.replicaNode()) const folders = await (tx || db.replicaNode())
.withRecursive("parents", (qb) => .withRecursive("parents", (qb) =>
@@ -480,7 +487,9 @@ export const secretFolderDALFactory = (db: TDbClient) => {
.select<(TSecretFolders & { path: string; depth: number; environment: string })[]>("*") .select<(TSecretFolders & { path: string; depth: number; environment: string })[]>("*")
.from("parents") .from("parents")
.orderBy("depth") .orderBy("depth")
.orderBy(`name`); .orderByRaw(`"parents".?? COLLATE "en-x-icu" ${orderDirection === OrderByDirection.ASC ? "ASC" : "DESC"}`, [
orderBy
]);
return folders; return folders;
} catch (error) { } catch (error) {
@@ -14,6 +14,7 @@ import { PgSqlLock } from "@app/keystore/keystore";
import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { OrderByDirection, OrgServiceActor } from "@app/lib/types"; import { OrderByDirection, OrgServiceActor } from "@app/lib/types";
import { ActorType } from "@app/services/auth/auth-type"; import { ActorType } from "@app/services/auth/auth-type";
import { SecretsOrderBy } from "@app/services/secret/secret-types";
import { buildFolderPath } from "@app/services/secret-folder/secret-folder-fns"; import { buildFolderPath } from "@app/services/secret-folder/secret-folder-fns";
import { import {
@@ -781,7 +782,11 @@ export const secretFolderServiceFactory = ({
if (!parentFolder) return []; if (!parentFolder) return [];
if (recursive) { if (recursive) {
const recursiveFolders = await folderDAL.findByEnvsDeep({ parentIds: [parentFolder.id] }); const recursiveFolders = await folderDAL.findByEnvsDeep({
parentIds: [parentFolder.id],
orderBy: orderBy || SecretsOrderBy.Name,
orderDirection: orderDirection || OrderByDirection.ASC
});
// remove the parent folder // remove the parent folder
return recursiveFolders return recursiveFolders
.filter((folder) => { .filter((folder) => {
@@ -800,19 +805,15 @@ export const secretFolderServiceFactory = ({
})); }));
} }
const folders = await folderDAL.find( const folders = await folderDAL.findByMultiEnv({
{ environmentIds: [env.id],
envId: env.id, parentIds: [parentFolder.id],
parentId: parentFolder.id, search,
isReserved: false, orderBy: orderBy || SecretsOrderBy.Name,
$search: search ? { name: `%${search}%` } : undefined orderDirection: orderDirection || OrderByDirection.ASC,
}, limit,
{ offset
sort: orderBy ? [[orderBy, orderDirection ?? OrderByDirection.ASC]] : undefined, });
limit,
offset
}
);
if (lastSecretModified) { if (lastSecretModified) {
return folders.filter((el) => return folders.filter((el) =>
el.lastSecretModified ? el.lastSecretModified >= new Date(lastSecretModified) : false el.lastSecretModified ? el.lastSecretModified >= new Date(lastSecretModified) : false
@@ -64,6 +64,8 @@ export type TGetFoldersDeepByEnvsDTO = {
export type TFindFoldersDeepByParentIdsDTO = { export type TFindFoldersDeepByParentIdsDTO = {
parentIds: string[]; parentIds: string[];
orderBy?: SecretsOrderBy;
orderDirection?: OrderByDirection;
}; };
export type TCreateManyFoldersDTO = { export type TCreateManyFoldersDTO = {
@@ -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 {
@@ -4,6 +4,7 @@ import handlebars from "handlebars";
import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
import { TGatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2-service"; import { TGatewayV2ServiceFactory } from "@app/ee/services/gateway-v2/gateway-v2-service";
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
import { CHEF_SYNC_LIST_OPTION, ChefSyncFns } from "@app/ee/services/secret-sync/chef";
import { OCI_VAULT_SYNC_LIST_OPTION, OCIVaultSyncFns } from "@app/ee/services/secret-sync/oci-vault"; import { OCI_VAULT_SYNC_LIST_OPTION, OCIVaultSyncFns } from "@app/ee/services/secret-sync/oci-vault";
import { BadRequestError } from "@app/lib/errors"; import { BadRequestError } from "@app/lib/errors";
import { import {
@@ -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.Enterprise
}; };
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
}; };
@@ -1,6 +1,12 @@
import { Job } from "bullmq"; 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 {
TChefSync,
TChefSyncInput,
TChefSyncListItem,
TChefSyncWithCredentials
} from "@app/ee/services/secret-sync/chef";
import { import {
TOCIVaultSync, TOCIVaultSync,
TOCIVaultSyncInput, TOCIVaultSyncInput,
@@ -169,6 +175,7 @@ export type TSecretSync =
| TGitHubSync | TGitHubSync
| TGcpSync | TGcpSync
| TAzureKeyVaultSync | TAzureKeyVaultSync
| TChefSync
| TAzureAppConfigurationSync | TAzureAppConfigurationSync
| TAzureDevOpsSync | TAzureDevOpsSync
| TDatabricksSync | TDatabricksSync
@@ -202,6 +209,7 @@ export type TSecretSyncWithCredentials =
| TGitHubSyncWithCredentials | TGitHubSyncWithCredentials
| TGcpSyncWithCredentials | TGcpSyncWithCredentials
| TAzureKeyVaultSyncWithCredentials | TAzureKeyVaultSyncWithCredentials
| TChefSyncWithCredentials
| TAzureAppConfigurationSyncWithCredentials | TAzureAppConfigurationSyncWithCredentials
| TAzureDevOpsSyncWithCredentials | TAzureDevOpsSyncWithCredentials
| TDatabricksSyncWithCredentials | TDatabricksSyncWithCredentials
@@ -236,6 +244,7 @@ export type TSecretSyncInput =
| TGitHubSyncInput | TGitHubSyncInput
| TGcpSyncInput | TGcpSyncInput
| TAzureKeyVaultSyncInput | TAzureKeyVaultSyncInput
| TChefSyncInput
| TAzureAppConfigurationSyncInput | TAzureAppConfigurationSyncInput
| TAzureDevOpsSyncInput | TAzureDevOpsSyncInput
| TDatabricksSyncInput | TDatabricksSyncInput
@@ -270,6 +279,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}"
---
+33 -1
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",
@@ -814,7 +816,10 @@
"groups": [ "groups": [
{ {
"group": "Infisical PAM", "group": "Infisical PAM",
"pages": ["documentation/platform/pam/overview"] "pages": [
"documentation/platform/pam/overview",
"documentation/platform/pam/session-recording"
]
} }
] ]
} }
@@ -1690,6 +1695,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": [
@@ -2181,6 +2198,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": [
@@ -2336,6 +2367,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"
] ]
}, },
@@ -24,9 +24,11 @@ Infisical is designed to provide comprehensive, centralized, and efficient manag
### 2. Projects ### 2. Projects
- **Definition and Role**: [Projects](/documentation/platform/project) are the highest-level construct within an [organization](/documentation/platform/organization) in Infisical. They serve as the primary container for all functionalities. - **Definition and Role**: [Projects](/documentation/platform/project) are the highest-level construct within an [organization](/documentation/platform/organization) in Infisical. They serve as the primary container for all functionalities.
- **Correspondence to Code Repositories**: Projects typically align with specific code repositories. - **Common Project Mappings**: Projects typically align with applications, services, or code repositories — each being a valid and common approach depending on your organizational structure.
- **Functional Capabilities**: Each project encompasses features for managing secrets, certificates, and encryption keys, serving as the central hub for these resources. - **Functional Capabilities**: Each project encompasses features for managing secrets, certificates, and encryption keys, serving as the central hub for these resources.
<Note>Projects are isolated from one another. Secrets, certificates, and other resources cannot be shared or referenced across different projects. Each project maintains its own separate set of resources.</Note>
### 3. Environments ### 3. Environments
- **Purpose**: Environments are designed for organizing and compartmentalizing secrets within projects. - **Purpose**: Environments are designed for organizing and compartmentalizing secrets within projects.
@@ -40,8 +42,9 @@ Infisical is designed to provide comprehensive, centralized, and efficient manag
### 5. Imports ### 5. Imports
- **Purpose and Benefits**: To promote reusability and avoid redundancy, Infisical supports the use of imports. This allows secrets, folders, or entire environments to be referenced across multiple projects as needed. - **Purpose and Benefits**: To promote reusability and avoid redundancy within a project, Infisical supports the use of imports and references. This allows secrets, folders, or entire environments to be referenced within the same project as needed.
- **Best Practice**: Utilizing [secret imports](/documentation/platform/secret-reference#secret-imports) or [references](/documentation/platform/secret-reference#secret-referencing) ensures consistency and minimizes manual overhead. - **Project Isolation**: Imports and references only work within a single project. Secrets cannot be imported or referenced across different projects, as projects are isolated from one another.
- **Best Practice**: Utilizing [secret imports](/documentation/platform/secret-reference#secret-imports) or [references](/documentation/platform/secret-reference#secret-referencing) ensures consistency and minimizes manual overhead when managing secrets within a project.
### 6. Approval Workflows ### 6. Approval Workflows
@@ -0,0 +1,60 @@
---
title: "Session Recording"
sidebarTitle: "Session Recording"
description: "Learn how Infisical records and stores session activity for auditing and monitoring."
---
Infisical's Privileged Access Management (PAM) provides robust session recording capabilities to help you audit and monitor user activity across your infrastructure.
## How It Works
When a user initiates a session through the Infisical Gateway, a recording of the session begins. The gateway securely caches all recording data in temporary encrypted files on its local system.
Once the session concludes, the gateway transmits the complete recording to the Infisical platform for long-term, centralized storage. This asynchronous process ensures that sessions remain operational even if the connection to the Infisical platform is temporarily lost. After the upload is complete, administrators can search and review the session logs in the Infisical UI.
## What's Captured
The content captured during a session depends on the type of resource being accessed.
### Database Sessions
For database connections, Infisical captures all queries executed and their corresponding responses.
<Note>
Support for additional resource types like SSH and RDP is coming soon.
</Note>
## Viewing Recordings
To review session recordings:
1. Navigate to the **PAM Sessions** page in your project.
2. Click on a session from the list to view its details.
![PAM Sessions](/images/pam/session-recording/sessions-page.png)
The session details page provides key information, including the complete session logs, connection status, the user who initiated it, and more.
![PAM Individual Session](/images/pam/session-recording/individual-session-page.png)
### Searching Logs
You can use the search bar to quickly find relevant information:
- **On the main Sessions page:** Search across all session logs to locate specific queries or outputs.
- **On an individual session page:** Search within that specific session's logs to pinpoint activity.
![PAM Sessions Search](/images/pam/session-recording/sessions-page-search.png)
![PAM Individual Session Search](/images/pam/session-recording/individual-session-page-search.png)
## FAQ
<AccordionGroup>
<Accordion title="Are session recordings encrypted?">
Yes. All session recordings are encrypted at rest by default, ensuring your audit data is always secure.
</Accordion>
<Accordion title="Why aren't recordings streamed in real-time?">
Currently, Infisical uses an asynchronous approach where the gateway records the entire session locally before uploading it. This design makes your PAM sessions more resilient, as they don't depend on a constant, active connection to the Infisical platform. We may introduce live streaming capabilities in a future release.
</Accordion>
</AccordionGroup>
@@ -28,46 +28,54 @@ This section walks you through the complete end-to-end process of setting up Azu
**Certificate Authority** to access the external CAs page. ![External CA **Certificate Authority** to access the external CAs page. ![External CA
Page](/images/platform/pki/azure-adcs/azure-adcs-external-ca-page.png) Page](/images/platform/pki/azure-adcs/azure-adcs-external-ca-page.png)
</Step> </Step>
<Step title="Create New Azure ADCS Certificate Service CA">
Click **Create CA** and configure: - **Type**: Choose **Azure AD Certificate <Step title="Create New Azure ADCS Certificate Service CA">
Service** - **Name**: Friendly name for this CA (e.g., "Production ADCS CA") - Click **Create CA** and configure: - **Type**: Choose **Active Directory
**App Connection**: Choose your ADCS connection from the dropdown ![External Certificate Services (AD CS)** - **Name**: Friendly name for this CA (e.g.,
CA Form](/images/platform/pki/azure-adcs/azure-adcs-external-ca-form.png) "Production ADCS CA") - **App Connection**: Choose your ADCS connection from
</Step> the dropdown ![External CA
<Step title="Certificate Authority Created"> Form](/images/platform/pki/azure-adcs/azure-adcs-external-ca-form.png)
Once created, your Azure ADCS Certificate Authority will appear in the list </Step>
and be ready for use. ![External CA
Created](/images/platform/pki/azure-adcs/azure-adcs-external-ca-created.png) <Step title="Certificate Authority Created">
</Step> Once created, your Azure ADCS Certificate Authority will appear in the list
<Step title="Navigate to Subscribers"> and be ready for use. ![External CA
Go to **Subscribers** to access the subscribers page. ![Subscribers Created](/images/platform/pki/azure-adcs/azure-adcs-external-ca-created.png)
Page](/images/platform/pki/azure-adcs/azure-adcs-subscribers-page.png) </Step>
</Step>
<Step title="Create New Subscriber"> <Step title="Navigate to Subscribers">
Click **Add Subscriber** and configure: - **Name**: Unique subscriber name Go to **Subscribers** to access the subscribers page. ![Subscribers
(e.g., "web-server-certs") - **Certificate Authority**: Select your ADCS CA - Page](/images/platform/pki/azure-adcs/azure-adcs-subscribers-page.png)
**Common Name**: Certificate CN (e.g., "api.example.com") - **Certificate </Step>
Template**: Select from dynamically loaded ADCS templates - **Subject
Alternative Names**: DNS names, IP addresses, or email addresses - **TTL**: <Step title="Create New Subscriber">
Certificate validity period (e.g., "1y" for 1 year) - **Additional Subject Click **Add Subscriber** and configure: - **Name**: Unique subscriber name
Fields**: Organization, OU, locality, state, country, email (if required by (e.g., "web-server-certs") - **Certificate Authority**: Select your ADCS CA
template) ![Subscribers - **Common Name**: Certificate CN (e.g., "api.example.com") - **Certificate
Form](/images/platform/pki/azure-adcs/azure-adcs-subscribers-form.png) Template**: Select from dynamically loaded ADCS templates - **Subject
</Step> Alternative Names**: DNS names, IP addresses, or email addresses - **TTL**:
<Step title="Subscriber Created"> Certificate validity period (e.g., "1y" for 1 year) - **Additional Subject
Your subscriber is now created and ready to issue certificates. ![Subscriber Fields**: Organization, OU, locality, state, country, email (if required by
Created](/images/platform/pki/azure-adcs/azure-adcs-subscribers-created.png) template) ![Subscribers
</Step> Form](/images/platform/pki/azure-adcs/azure-adcs-subscribers-form.png)
<Step title="Issue New Certificate"> </Step>
Click into your subscriber and click **Order Certificate** to generate a new
certificate using your ADCS template. ![Issue New <Step title="Subscriber Created">
Certificate](/images/platform/pki/azure-adcs/azure-adcs-subscriber-issue-new-certificate.png) Your subscriber is now created and ready to issue certificates. ![Subscriber
</Step> Created](/images/platform/pki/azure-adcs/azure-adcs-subscribers-created.png)
<Step title="Certificate Created"> </Step>
Your certificate has been successfully issued by the ADCS server and is ready
for use. ![Certificate <Step title="Issue New Certificate">
Created](/images/platform/pki/azure-adcs/azure-adcs-certificate-created.png) Click into your subscriber and click **Order Certificate** to generate a new
</Step> certificate using your ADCS template. ![Issue New
Certificate](/images/platform/pki/azure-adcs/azure-adcs-subscriber-issue-new-certificate.png)
</Step>
<Step title="Certificate Created">
Your certificate has been successfully issued by the ADCS server and is
ready for use. ![Certificate
Created](/images/platform/pki/azure-adcs/azure-adcs-certificate-created.png)
</Step>
<Step title="View Certificate Details"> <Step title="View Certificate Details">
Navigate to **Certificates** to view detailed information about all issued Navigate to **Certificates** to view detailed information about all issued
Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

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