Merge branch 'heads/main' into daniel/redis-secret-rotation-new
@@ -122,7 +122,7 @@ INF_APP_CONNECTION_GITHUB_RADAR_APP_WEBHOOK_SECRET=
|
||||
#gcp app connection
|
||||
INF_APP_CONNECTION_GCP_SERVICE_ACCOUNT_CREDENTIAL=
|
||||
|
||||
# azure app connection
|
||||
# azure app connections
|
||||
INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID=
|
||||
INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_SECRET=
|
||||
|
||||
@@ -135,6 +135,10 @@ INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_SECRET=
|
||||
INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_ID=
|
||||
INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_SECRET=
|
||||
|
||||
# heroku app connection
|
||||
INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_ID=
|
||||
INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_SECRET=
|
||||
|
||||
# datadog
|
||||
SHOULD_USE_DATADOG_TRACER=
|
||||
DATADOG_PROFILING_ENABLED=
|
||||
|
||||
2
backend/src/@types/fastify.d.ts
vendored
@@ -93,6 +93,7 @@ import { TOrgAdminServiceFactory } from "@app/services/org-admin/org-admin-servi
|
||||
import { TPkiAlertServiceFactory } from "@app/services/pki-alert/pki-alert-service";
|
||||
import { TPkiCollectionServiceFactory } from "@app/services/pki-collection/pki-collection-service";
|
||||
import { TPkiSubscriberServiceFactory } from "@app/services/pki-subscriber/pki-subscriber-service";
|
||||
import { TPkiSyncServiceFactory } from "@app/services/pki-sync/pki-sync-service";
|
||||
import { TPkiTemplatesServiceFactory } from "@app/services/pki-templates/pki-templates-service";
|
||||
import { TProjectServiceFactory } from "@app/services/project/project-service";
|
||||
import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service";
|
||||
@@ -267,6 +268,7 @@ declare module "fastify" {
|
||||
certificateEst: TCertificateEstServiceFactory;
|
||||
pkiCollection: TPkiCollectionServiceFactory;
|
||||
pkiSubscriber: TPkiSubscriberServiceFactory;
|
||||
pkiSync: TPkiSyncServiceFactory;
|
||||
secretScanning: TSecretScanningServiceFactory;
|
||||
license: TLicenseServiceFactory;
|
||||
trustedIp: TTrustedIpServiceFactory;
|
||||
|
||||
4
backend/src/@types/knex.d.ts
vendored
@@ -263,6 +263,9 @@ import {
|
||||
TPkiSubscribers,
|
||||
TPkiSubscribersInsert,
|
||||
TPkiSubscribersUpdate,
|
||||
TPkiSyncs,
|
||||
TPkiSyncsInsert,
|
||||
TPkiSyncsUpdate,
|
||||
TProjectBots,
|
||||
TProjectBotsInsert,
|
||||
TProjectBotsUpdate,
|
||||
@@ -680,6 +683,7 @@ declare module "knex/types/tables" {
|
||||
TPkiSubscribersInsert,
|
||||
TPkiSubscribersUpdate
|
||||
>;
|
||||
[TableName.PkiSync]: KnexOriginal.CompositeTableType<TPkiSyncs, TPkiSyncsInsert, TPkiSyncsUpdate>;
|
||||
[TableName.UserGroupMembership]: KnexOriginal.CompositeTableType<
|
||||
TUserGroupMembership,
|
||||
TUserGroupMembershipInsert,
|
||||
|
||||
47
backend/src/db/migrations/20250910193000_pki-sync.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "@app/db/utils";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
if (!(await knex.schema.hasTable(TableName.PkiSync))) {
|
||||
await knex.schema.createTable(TableName.PkiSync, (t) => {
|
||||
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||
t.string("name", 32).notNullable();
|
||||
t.string("description");
|
||||
t.string("destination").notNullable();
|
||||
t.boolean("isAutoSyncEnabled").notNullable().defaultTo(true);
|
||||
t.integer("version").defaultTo(1).notNullable();
|
||||
t.jsonb("destinationConfig").notNullable();
|
||||
t.jsonb("syncOptions").notNullable();
|
||||
t.string("projectId").notNullable();
|
||||
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
|
||||
t.uuid("subscriberId");
|
||||
t.foreign("subscriberId").references("id").inTable(TableName.PkiSubscriber).onDelete("SET NULL");
|
||||
t.uuid("connectionId").notNullable();
|
||||
t.foreign("connectionId").references("id").inTable(TableName.AppConnection);
|
||||
t.timestamps(true, true, true);
|
||||
t.string("syncStatus");
|
||||
t.string("lastSyncJobId");
|
||||
t.string("lastSyncMessage");
|
||||
t.datetime("lastSyncedAt");
|
||||
t.string("importStatus");
|
||||
t.string("lastImportJobId");
|
||||
t.string("lastImportMessage");
|
||||
t.datetime("lastImportedAt");
|
||||
t.string("removeStatus");
|
||||
t.string("lastRemoveJobId");
|
||||
t.string("lastRemoveMessage");
|
||||
t.datetime("lastRemovedAt");
|
||||
|
||||
t.unique(["name", "projectId"], { indexName: "pki_syncs_name_project_id_unique" });
|
||||
});
|
||||
|
||||
await createOnUpdateTrigger(knex, TableName.PkiSync);
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.dropTableIfExists(TableName.PkiSync);
|
||||
await dropOnUpdateTrigger(knex, TableName.PkiSync);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
const hasAllowedNamespaces = await knex.schema.hasColumn(TableName.IdentityKubernetesAuth, "allowedNamespaces");
|
||||
const hasAllowedNames = await knex.schema.hasColumn(TableName.IdentityKubernetesAuth, "allowedNames");
|
||||
const hasAllowedAudience = await knex.schema.hasColumn(TableName.IdentityKubernetesAuth, "allowedAudience");
|
||||
|
||||
if (hasAllowedNamespaces || hasAllowedNames || hasAllowedAudience) {
|
||||
await knex.schema.alterTable(TableName.IdentityKubernetesAuth, (t) => {
|
||||
if (hasAllowedNames) t.string("allowedNames", 1000).notNullable().alter();
|
||||
if (hasAllowedNamespaces) t.string("allowedNamespaces", 1000).notNullable().alter();
|
||||
if (hasAllowedAudience) t.string("allowedAudience", 1000).notNullable().alter();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
const hasAllowedNamespaces = await knex.schema.hasColumn(TableName.IdentityKubernetesAuth, "allowedNamespaces");
|
||||
const hasAllowedNames = await knex.schema.hasColumn(TableName.IdentityKubernetesAuth, "allowedNames");
|
||||
const hasAllowedAudience = await knex.schema.hasColumn(TableName.IdentityKubernetesAuth, "allowedAudience");
|
||||
|
||||
if (hasAllowedNamespaces || hasAllowedNames || hasAllowedAudience) {
|
||||
await knex.schema.alterTable(TableName.IdentityKubernetesAuth, (t) => {
|
||||
if (hasAllowedNames) t.string("allowedNames", 255).notNullable().alter();
|
||||
if (hasAllowedNamespaces) t.string("allowedNamespaces", 255).notNullable().alter();
|
||||
if (hasAllowedAudience) t.string("allowedAudience", 255).notNullable().alter();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "@app/db/schemas";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
if (await knex.schema.hasTable(TableName.SecretApprovalRequestSecretV2)) {
|
||||
await knex.schema.alterTable(TableName.SecretApprovalRequestSecretV2, (t) => {
|
||||
t.boolean("skipMultilineEncoding").alter();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
if (await knex.schema.hasTable(TableName.SecretApprovalRequestSecretV2)) {
|
||||
await knex.schema.alterTable(TableName.SecretApprovalRequestSecretV2, (t) => {
|
||||
t.boolean("skipMultilineEncoding").defaultTo(false).alter();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,7 @@ export * from "./pki-alerts";
|
||||
export * from "./pki-collection-items";
|
||||
export * from "./pki-collections";
|
||||
export * from "./pki-subscribers";
|
||||
export * from "./pki-syncs";
|
||||
export * from "./project-bots";
|
||||
export * from "./project-environments";
|
||||
export * from "./project-gateways";
|
||||
|
||||
@@ -156,6 +156,7 @@ export enum TableName {
|
||||
ProjectSlackConfigs = "project_slack_configs",
|
||||
AppConnection = "app_connections",
|
||||
SecretSync = "secret_syncs",
|
||||
PkiSync = "pki_syncs",
|
||||
KmipClient = "kmip_clients",
|
||||
KmipOrgConfig = "kmip_org_configs",
|
||||
KmipOrgServerCertificates = "kmip_org_server_certificates",
|
||||
|
||||
40
backend/src/db/schemas/pki-syncs.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
// 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 PkiSyncsSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable().optional(),
|
||||
destination: z.string(),
|
||||
isAutoSyncEnabled: z.boolean().default(true),
|
||||
version: z.number().default(1),
|
||||
destinationConfig: z.unknown(),
|
||||
syncOptions: z.unknown(),
|
||||
projectId: z.string(),
|
||||
subscriberId: z.string().uuid().nullable().optional(),
|
||||
connectionId: z.string().uuid(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
syncStatus: z.string().nullable().optional(),
|
||||
lastSyncJobId: z.string().nullable().optional(),
|
||||
lastSyncMessage: z.string().nullable().optional(),
|
||||
lastSyncedAt: z.date().nullable().optional(),
|
||||
importStatus: z.string().nullable().optional(),
|
||||
lastImportJobId: z.string().nullable().optional(),
|
||||
lastImportMessage: z.string().nullable().optional(),
|
||||
lastImportedAt: z.date().nullable().optional(),
|
||||
removeStatus: z.string().nullable().optional(),
|
||||
lastRemoveJobId: z.string().nullable().optional(),
|
||||
lastRemoveMessage: z.string().nullable().optional(),
|
||||
lastRemovedAt: z.date().nullable().optional()
|
||||
});
|
||||
|
||||
export type TPkiSyncs = z.infer<typeof PkiSyncsSchema>;
|
||||
export type TPkiSyncsInsert = Omit<z.input<typeof PkiSyncsSchema>, TImmutableDBKeys>;
|
||||
export type TPkiSyncsUpdate = Partial<Omit<z.input<typeof PkiSyncsSchema>, TImmutableDBKeys>>;
|
||||
@@ -17,7 +17,7 @@ export const SecretApprovalRequestsSecretsV2Schema = z.object({
|
||||
encryptedComment: zodBuffer.nullable().optional(),
|
||||
reminderNote: z.string().nullable().optional(),
|
||||
reminderRepeatDays: z.number().nullable().optional(),
|
||||
skipMultilineEncoding: z.boolean().default(false).nullable().optional(),
|
||||
skipMultilineEncoding: z.boolean().nullable().optional(),
|
||||
metadata: z.unknown().nullable().optional(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
|
||||
@@ -320,10 +320,20 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
|
||||
.array(),
|
||||
secretPath: z.string(),
|
||||
commits: secretRawSchema
|
||||
.omit({ _id: true, environment: true, workspace: true, type: true, version: true, secretValue: true })
|
||||
.omit({
|
||||
_id: true,
|
||||
environment: true,
|
||||
workspace: true,
|
||||
type: true,
|
||||
version: true,
|
||||
secretValue: true,
|
||||
secretComment: true
|
||||
})
|
||||
.extend({
|
||||
secretValueHidden: z.boolean(),
|
||||
secretValue: z.string().optional(),
|
||||
secretComment: z.string().optional(),
|
||||
skipMultilineEncoding: z.boolean().nullish(),
|
||||
isRotatedSecret: z.boolean().optional(),
|
||||
op: z.string(),
|
||||
tags: SanitizedTagSchema.array().optional(),
|
||||
@@ -348,7 +358,8 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv
|
||||
secretValueHidden: z.boolean(),
|
||||
secretComment: z.string().optional(),
|
||||
tags: SanitizedTagSchema.array().optional(),
|
||||
secretMetadata: ResourceMetadataSchema.nullish()
|
||||
secretMetadata: ResourceMetadataSchema.nullish(),
|
||||
skipMultilineEncoding: z.boolean().nullish()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
@@ -407,6 +407,14 @@ export enum EventType {
|
||||
SECRET_SYNC_SYNC_SECRETS = "secret-sync-sync-secrets",
|
||||
SECRET_SYNC_IMPORT_SECRETS = "secret-sync-import-secrets",
|
||||
SECRET_SYNC_REMOVE_SECRETS = "secret-sync-remove-secrets",
|
||||
GET_PKI_SYNCS = "get-pki-syncs",
|
||||
GET_PKI_SYNC = "get-pki-sync",
|
||||
CREATE_PKI_SYNC = "create-pki-sync",
|
||||
UPDATE_PKI_SYNC = "update-pki-sync",
|
||||
DELETE_PKI_SYNC = "delete-pki-sync",
|
||||
PKI_SYNC_SYNC_CERTIFICATES = "pki-sync-sync-certificates",
|
||||
PKI_SYNC_IMPORT_CERTIFICATES = "pki-sync-import-certificates",
|
||||
PKI_SYNC_REMOVE_CERTIFICATES = "pki-sync-remove-certificates",
|
||||
OIDC_GROUP_MEMBERSHIP_MAPPING_ASSIGN_USER = "oidc-group-membership-mapping-assign-user",
|
||||
OIDC_GROUP_MEMBERSHIP_MAPPING_REMOVE_USER = "oidc-group-membership-mapping-remove-user",
|
||||
CREATE_KMIP_CLIENT = "create-kmip-client",
|
||||
@@ -2959,6 +2967,77 @@ interface SecretSyncRemoveSecretsEvent {
|
||||
};
|
||||
}
|
||||
|
||||
interface GetPkiSyncsEvent {
|
||||
type: EventType.GET_PKI_SYNCS;
|
||||
metadata: {
|
||||
projectId: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface GetPkiSyncEvent {
|
||||
type: EventType.GET_PKI_SYNC;
|
||||
metadata: {
|
||||
destination: string;
|
||||
syncId: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface CreatePkiSyncEvent {
|
||||
type: EventType.CREATE_PKI_SYNC;
|
||||
metadata: {
|
||||
pkiSyncId: string;
|
||||
name: string;
|
||||
destination: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface UpdatePkiSyncEvent {
|
||||
type: EventType.UPDATE_PKI_SYNC;
|
||||
metadata: {
|
||||
pkiSyncId: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface DeletePkiSyncEvent {
|
||||
type: EventType.DELETE_PKI_SYNC;
|
||||
metadata: {
|
||||
pkiSyncId: string;
|
||||
name: string;
|
||||
destination: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface PkiSyncSyncCertificatesEvent {
|
||||
type: EventType.PKI_SYNC_SYNC_CERTIFICATES;
|
||||
metadata: {
|
||||
syncId: string;
|
||||
syncMessage: string | null;
|
||||
jobId: string;
|
||||
jobRanAt: Date;
|
||||
};
|
||||
}
|
||||
|
||||
interface PkiSyncImportCertificatesEvent {
|
||||
type: EventType.PKI_SYNC_IMPORT_CERTIFICATES;
|
||||
metadata: {
|
||||
syncId: string;
|
||||
importMessage: string | null;
|
||||
jobId: string;
|
||||
jobRanAt: Date;
|
||||
};
|
||||
}
|
||||
|
||||
interface PkiSyncRemoveCertificatesEvent {
|
||||
type: EventType.PKI_SYNC_REMOVE_CERTIFICATES;
|
||||
metadata: {
|
||||
syncId: string;
|
||||
removeMessage: string | null;
|
||||
jobId: string;
|
||||
jobRanAt: Date;
|
||||
};
|
||||
}
|
||||
|
||||
interface OidcGroupMembershipMappingAssignUserEvent {
|
||||
type: EventType.OIDC_GROUP_MEMBERSHIP_MAPPING_ASSIGN_USER;
|
||||
metadata: {
|
||||
@@ -3859,6 +3938,14 @@ export type Event =
|
||||
| SecretSyncSyncSecretsEvent
|
||||
| SecretSyncImportSecretsEvent
|
||||
| SecretSyncRemoveSecretsEvent
|
||||
| GetPkiSyncsEvent
|
||||
| GetPkiSyncEvent
|
||||
| CreatePkiSyncEvent
|
||||
| UpdatePkiSyncEvent
|
||||
| DeletePkiSyncEvent
|
||||
| PkiSyncSyncCertificatesEvent
|
||||
| PkiSyncImportCertificatesEvent
|
||||
| PkiSyncRemoveCertificatesEvent
|
||||
| OidcGroupMembershipMappingAssignUserEvent
|
||||
| OidcGroupMembershipMappingRemoveUserEvent
|
||||
| CreateKmipClientEvent
|
||||
|
||||
@@ -31,6 +31,7 @@ export const getDefaultOnPremFeatures = () => {
|
||||
caCrl: false,
|
||||
sshHostGroups: false,
|
||||
enterpriseSecretSyncs: false,
|
||||
enterpriseCertificateSyncs: false,
|
||||
enterpriseAppConnections: true,
|
||||
machineIdentityAuthTemplates: false
|
||||
};
|
||||
|
||||
@@ -62,6 +62,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
|
||||
sshHostGroups: false,
|
||||
secretScanning: false,
|
||||
enterpriseSecretSyncs: false,
|
||||
enterpriseCertificateSyncs: false,
|
||||
enterpriseAppConnections: false,
|
||||
fips: false,
|
||||
eventSubscriptions: false,
|
||||
|
||||
@@ -75,6 +75,7 @@ export type TFeatureSet = {
|
||||
sshHostGroups: false;
|
||||
secretScanning: false;
|
||||
enterpriseSecretSyncs: false;
|
||||
enterpriseCertificateSyncs: false;
|
||||
enterpriseAppConnections: false;
|
||||
machineIdentityAuthTemplates: false;
|
||||
fips: false;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ProjectPermissionKmipActions,
|
||||
ProjectPermissionMemberActions,
|
||||
ProjectPermissionPkiSubscriberActions,
|
||||
ProjectPermissionPkiSyncActions,
|
||||
ProjectPermissionPkiTemplateActions,
|
||||
ProjectPermissionSecretActions,
|
||||
ProjectPermissionSecretEventActions,
|
||||
@@ -209,6 +210,19 @@ const buildAdminPermissionRules = () => {
|
||||
ProjectPermissionSub.SecretSyncs
|
||||
);
|
||||
|
||||
can(
|
||||
[
|
||||
ProjectPermissionPkiSyncActions.Create,
|
||||
ProjectPermissionPkiSyncActions.Edit,
|
||||
ProjectPermissionPkiSyncActions.Delete,
|
||||
ProjectPermissionPkiSyncActions.Read,
|
||||
ProjectPermissionPkiSyncActions.SyncCertificates,
|
||||
ProjectPermissionPkiSyncActions.ImportCertificates,
|
||||
ProjectPermissionPkiSyncActions.RemoveCertificates
|
||||
],
|
||||
ProjectPermissionSub.PkiSyncs
|
||||
);
|
||||
|
||||
can(
|
||||
[
|
||||
ProjectPermissionKmipActions.CreateClients,
|
||||
@@ -462,6 +476,19 @@ const buildMemberPermissionRules = () => {
|
||||
ProjectPermissionSub.SecretSyncs
|
||||
);
|
||||
|
||||
can(
|
||||
[
|
||||
ProjectPermissionPkiSyncActions.Create,
|
||||
ProjectPermissionPkiSyncActions.Edit,
|
||||
ProjectPermissionPkiSyncActions.Delete,
|
||||
ProjectPermissionPkiSyncActions.Read,
|
||||
ProjectPermissionPkiSyncActions.SyncCertificates,
|
||||
ProjectPermissionPkiSyncActions.ImportCertificates,
|
||||
ProjectPermissionPkiSyncActions.RemoveCertificates
|
||||
],
|
||||
ProjectPermissionSub.PkiSyncs
|
||||
);
|
||||
|
||||
can(
|
||||
[
|
||||
ProjectPermissionSecretScanningDataSourceActions.Read,
|
||||
@@ -526,6 +553,7 @@ const buildViewerPermissionRules = () => {
|
||||
can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificates);
|
||||
can(ProjectPermissionActions.Read, ProjectPermissionSub.SshCertificateTemplates);
|
||||
can(ProjectPermissionSecretSyncActions.Read, ProjectPermissionSub.SecretSyncs);
|
||||
can(ProjectPermissionPkiSyncActions.Read, ProjectPermissionSub.PkiSyncs);
|
||||
can(ProjectPermissionCommitsActions.Read, ProjectPermissionSub.Commits);
|
||||
|
||||
can(
|
||||
|
||||
@@ -120,6 +120,16 @@ export enum ProjectPermissionSecretSyncActions {
|
||||
RemoveSecrets = "remove-secrets"
|
||||
}
|
||||
|
||||
export enum ProjectPermissionPkiSyncActions {
|
||||
Read = "read",
|
||||
Create = "create",
|
||||
Edit = "edit",
|
||||
Delete = "delete",
|
||||
SyncCertificates = "sync-certificates",
|
||||
ImportCertificates = "import-certificates",
|
||||
RemoveCertificates = "remove-certificates"
|
||||
}
|
||||
|
||||
export enum ProjectPermissionSecretRotationActions {
|
||||
Read = "read",
|
||||
ReadGeneratedCredentials = "read-generated-credentials",
|
||||
@@ -212,6 +222,7 @@ export enum ProjectPermissionSub {
|
||||
Kms = "kms",
|
||||
Cmek = "cmek",
|
||||
SecretSyncs = "secret-syncs",
|
||||
PkiSyncs = "pki-syncs",
|
||||
Kmip = "kmip",
|
||||
SecretScanningDataSources = "secret-scanning-data-sources",
|
||||
SecretScanningFindings = "secret-scanning-findings",
|
||||
@@ -244,6 +255,10 @@ export type SecretSyncSubjectFields = {
|
||||
secretPath: string;
|
||||
};
|
||||
|
||||
export type PkiSyncSubjectFields = {
|
||||
subscriberName: string;
|
||||
};
|
||||
|
||||
export type DynamicSecretSubjectFields = {
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
@@ -308,6 +323,10 @@ export type ProjectPermissionSet =
|
||||
ProjectPermissionSecretSyncActions,
|
||||
ProjectPermissionSub.SecretSyncs | (ForcedSubject<ProjectPermissionSub.SecretSyncs> & SecretSyncSubjectFields)
|
||||
]
|
||||
| [
|
||||
ProjectPermissionPkiSyncActions,
|
||||
ProjectPermissionSub.PkiSyncs | (ForcedSubject<ProjectPermissionSub.PkiSyncs> & PkiSyncSubjectFields)
|
||||
]
|
||||
| [
|
||||
ProjectPermissionActions,
|
||||
(
|
||||
@@ -480,6 +499,22 @@ const SecretSyncConditionV2Schema = z
|
||||
})
|
||||
.partial();
|
||||
|
||||
const PkiSyncConditionSchema = z
|
||||
.object({
|
||||
subscriberName: z.union([
|
||||
z.string(),
|
||||
z
|
||||
.object({
|
||||
[PermissionConditionOperators.$EQ]: PermissionConditionSchema[PermissionConditionOperators.$EQ],
|
||||
[PermissionConditionOperators.$NEQ]: PermissionConditionSchema[PermissionConditionOperators.$NEQ],
|
||||
[PermissionConditionOperators.$IN]: PermissionConditionSchema[PermissionConditionOperators.$IN],
|
||||
[PermissionConditionOperators.$GLOB]: PermissionConditionSchema[PermissionConditionOperators.$GLOB]
|
||||
})
|
||||
.partial()
|
||||
])
|
||||
})
|
||||
.partial();
|
||||
|
||||
const SecretImportConditionSchema = z
|
||||
.object({
|
||||
environment: z.union([
|
||||
@@ -943,6 +978,16 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [
|
||||
"When specified, only matching conditions will be allowed to access given resource."
|
||||
).optional()
|
||||
}),
|
||||
z.object({
|
||||
subject: z.literal(ProjectPermissionSub.PkiSyncs).describe("The entity this permission pertains to."),
|
||||
inverted: z.boolean().optional().describe("Whether rule allows or forbids."),
|
||||
action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionPkiSyncActions).describe(
|
||||
"Describe what action an entity can take."
|
||||
),
|
||||
conditions: PkiSyncConditionSchema.describe(
|
||||
"When specified, only matching conditions will be allowed to access given resource."
|
||||
).optional()
|
||||
}),
|
||||
z.object({
|
||||
subject: z.literal(ProjectPermissionSub.SecretEvents).describe("The entity this permission pertains to."),
|
||||
inverted: z.boolean().optional().describe("Whether rule allows or forbids."),
|
||||
|
||||
@@ -284,7 +284,8 @@ export const secretApprovalRequestSecretDALFactory = (db: TDbClient) => {
|
||||
db.ref("version").withSchema(TableName.SecretVersionV2).as("secVerVersion"),
|
||||
db.ref("key").withSchema(TableName.SecretVersionV2).as("secVerKey"),
|
||||
db.ref("encryptedValue").withSchema(TableName.SecretVersionV2).as("secVerValue"),
|
||||
db.ref("encryptedComment").withSchema(TableName.SecretVersionV2).as("secVerComment")
|
||||
db.ref("encryptedComment").withSchema(TableName.SecretVersionV2).as("secVerComment"),
|
||||
db.ref("skipMultilineEncoding").withSchema(TableName.SecretVersionV2).as("secVerSkipMultilineEncoding")
|
||||
)
|
||||
.select(
|
||||
db.ref("id").withSchema(TableName.ResourceMetadata).as("metadataId"),
|
||||
@@ -326,14 +327,22 @@ export const secretApprovalRequestSecretDALFactory = (db: TDbClient) => {
|
||||
{
|
||||
key: "secretVersion",
|
||||
label: "secretVersion" as const,
|
||||
mapper: ({ secretVersion, secVerVersion, secVerKey, secVerValue, secVerComment }) =>
|
||||
mapper: ({
|
||||
secretVersion,
|
||||
secVerVersion,
|
||||
secVerKey,
|
||||
secVerValue,
|
||||
secVerComment,
|
||||
secVerSkipMultilineEncoding
|
||||
}) =>
|
||||
secretVersion
|
||||
? {
|
||||
version: secVerVersion,
|
||||
id: secretVersion,
|
||||
key: secVerKey,
|
||||
encryptedValue: secVerValue,
|
||||
encryptedComment: secVerComment
|
||||
encryptedComment: secVerComment,
|
||||
skipMultilineEncoding: secVerSkipMultilineEncoding
|
||||
}
|
||||
: undefined,
|
||||
childrenMapper: [
|
||||
|
||||
@@ -337,12 +337,17 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
? INFISICAL_SECRET_VALUE_HIDDEN_MASK
|
||||
: el.secret && el.secret.isRotatedSecret
|
||||
? undefined
|
||||
: el.encryptedValue
|
||||
: el.encryptedValue !== undefined && el.encryptedValue !== null
|
||||
? secretManagerDecryptor({ cipherTextBlob: el.encryptedValue }).toString()
|
||||
: "",
|
||||
secretComment: el.encryptedComment
|
||||
? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString()
|
||||
: "",
|
||||
: undefined,
|
||||
secretComment:
|
||||
el.encryptedComment !== undefined && el.encryptedComment !== null
|
||||
? secretManagerDecryptor({ cipherTextBlob: el.encryptedComment }).toString()
|
||||
: undefined,
|
||||
skipMultilineEncoding:
|
||||
el.skipMultilineEncoding !== undefined && el.skipMultilineEncoding !== null
|
||||
? el.skipMultilineEncoding
|
||||
: undefined,
|
||||
secret: el.secret
|
||||
? {
|
||||
secretKey: el.secret.key,
|
||||
@@ -394,7 +399,8 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
? secretManagerDecryptor({ cipherTextBlob: el.secretVersion.encryptedComment }).toString()
|
||||
: "",
|
||||
tags: el.secretVersion.tags,
|
||||
secretMetadata: el.oldSecretMetadata as ResourceMetadataDTO
|
||||
secretMetadata: el.oldSecretMetadata as ResourceMetadataDTO,
|
||||
skipMultilineEncoding: el.secretVersion.skipMultilineEncoding
|
||||
}
|
||||
: undefined
|
||||
}));
|
||||
@@ -733,9 +739,9 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
tx,
|
||||
inputSecrets: secretUpdationCommits.map((el) => {
|
||||
const encryptedValue =
|
||||
!el.secret?.isRotatedSecret && typeof el.encryptedValue !== "undefined"
|
||||
!el.secret?.isRotatedSecret && el.encryptedValue !== null && el.encryptedValue !== undefined
|
||||
? {
|
||||
encryptedValue: el.encryptedValue as Buffer,
|
||||
encryptedValue: el.encryptedValue,
|
||||
references: el.encryptedValue
|
||||
? getAllSecretReferencesV2Bridge(
|
||||
secretManagerDecryptor({
|
||||
@@ -749,9 +755,9 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
filter: { id: el.secretId as string, type: SecretType.Shared },
|
||||
data: {
|
||||
reminderRepeatDays: el.reminderRepeatDays,
|
||||
encryptedComment: el.encryptedComment,
|
||||
encryptedComment: el.encryptedComment !== null ? el.encryptedComment : undefined,
|
||||
reminderNote: el.reminderNote,
|
||||
skipMultilineEncoding: el.skipMultilineEncoding,
|
||||
skipMultilineEncoding: el.skipMultilineEncoding !== null ? el.skipMultilineEncoding : undefined,
|
||||
key: el.key,
|
||||
tags: el?.tags.map(({ id }) => id),
|
||||
secretMetadata: el.secretMetadata as ResourceMetadataDTO,
|
||||
@@ -1633,11 +1639,13 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
key: newSecretName || secretKey,
|
||||
encryptedComment: setKnexStringValue(
|
||||
secretComment,
|
||||
(value) => secretManagerEncryptor({ plainText: Buffer.from(value) }).cipherTextBlob
|
||||
(value) => secretManagerEncryptor({ plainText: Buffer.from(value) }).cipherTextBlob,
|
||||
true // scott: we need to encrypt empty string on update to differentiate not updating comment vs clearing comment
|
||||
),
|
||||
encryptedValue: setKnexStringValue(
|
||||
secretValue,
|
||||
(value) => secretManagerEncryptor({ plainText: Buffer.from(value) }).cipherTextBlob
|
||||
(value) => secretManagerEncryptor({ plainText: Buffer.from(value) }).cipherTextBlob,
|
||||
true // scott: we need to encrypt empty string on update to differentiate not updating value vs clearing value
|
||||
),
|
||||
reminderRepeatDays,
|
||||
reminderNote,
|
||||
|
||||
@@ -47,6 +47,7 @@ export const KeyStorePrefixes = {
|
||||
SyncSecretIntegrationLastRunTimestamp: (projectId: string, environmentSlug: string, secretPath: string) =>
|
||||
`sync-integration-last-run-${projectId}-${environmentSlug}-${secretPath}` as const,
|
||||
SecretSyncLock: (syncId: string) => `secret-sync-mutex-${syncId}` as const,
|
||||
PkiSyncLock: (syncId: string) => `pki-sync-mutex-${syncId}` as const,
|
||||
AppConnectionConcurrentJobs: (connectionId: string) => `app-connection-concurrency-${connectionId}` as const,
|
||||
SecretRotationLock: (rotationId: string) => `secret-rotation-v2-mutex-${rotationId}` as const,
|
||||
SecretScanningLock: (dataSourceId: string, resourceExternalId: string) =>
|
||||
|
||||
@@ -50,6 +50,7 @@ export enum ApiDocsTags {
|
||||
IdentitySpecificPrivilegesV2 = "Identity Specific Privileges V2",
|
||||
AppConnections = "App Connections",
|
||||
SecretSyncs = "Secret Syncs",
|
||||
PkiSyncs = "PKI Syncs",
|
||||
Integrations = "Integrations",
|
||||
ServiceTokens = "Service Tokens",
|
||||
AuditLogs = "Audit Logs",
|
||||
|
||||
@@ -323,6 +323,10 @@ const envSchema = z
|
||||
INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_ID: zpStr(z.string().optional()),
|
||||
INF_APP_CONNECTION_AZURE_DEVOPS_CLIENT_SECRET: zpStr(z.string().optional()),
|
||||
|
||||
// Heroku App Connection
|
||||
INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_ID: zpStr(z.string().optional()),
|
||||
INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_SECRET: zpStr(z.string().optional()),
|
||||
|
||||
// datadog
|
||||
SHOULD_USE_DATADOG_TRACER: zodStrBool.default("false"),
|
||||
DATADOG_PROFILING_ENABLED: zodStrBool.default("false"),
|
||||
@@ -433,7 +437,10 @@ const envSchema = z
|
||||
INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID:
|
||||
data.INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_ID || data.INF_APP_CONNECTION_AZURE_CLIENT_ID,
|
||||
INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_SECRET:
|
||||
data.INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_SECRET || data.INF_APP_CONNECTION_AZURE_CLIENT_SECRET
|
||||
data.INF_APP_CONNECTION_AZURE_APP_CONFIGURATION_CLIENT_SECRET || data.INF_APP_CONNECTION_AZURE_CLIENT_SECRET,
|
||||
INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_ID: data.INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_ID || data.CLIENT_ID_HEROKU,
|
||||
INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_SECRET:
|
||||
data.INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_SECRET || data.CLIENT_SECRET_HEROKU
|
||||
}));
|
||||
|
||||
export type TEnvConfig = Readonly<z.infer<typeof envSchema>>;
|
||||
@@ -736,6 +743,19 @@ export const overwriteSchema: {
|
||||
description: "The Client Secret of your GCP OAuth2 application."
|
||||
}
|
||||
]
|
||||
},
|
||||
heroku: {
|
||||
name: "Heroku",
|
||||
fields: [
|
||||
{
|
||||
key: "INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_ID",
|
||||
description: "The Client ID of your Heroku application."
|
||||
},
|
||||
{
|
||||
key: "INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_SECRET",
|
||||
description: "The Client Secret of your Heroku application."
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -16,8 +16,12 @@ export const stripUndefinedInWhere = <T extends object>(val: T): Exclude<T, unde
|
||||
// if its undefined its skipped in knex
|
||||
// if its empty string its set as null
|
||||
// else pass to the required one
|
||||
export const setKnexStringValue = <T>(value: string | null | undefined, cb: (arg: string) => T) => {
|
||||
export const setKnexStringValue = <T>(
|
||||
value: string | null | undefined,
|
||||
cb: (arg: string) => T,
|
||||
allowEmptyString?: boolean
|
||||
) => {
|
||||
if (typeof value === "undefined") return;
|
||||
if (value === "" || value === null) return null;
|
||||
if ((value === "" && !allowEmptyString) || value === null) return null;
|
||||
return cb(value);
|
||||
};
|
||||
|
||||
@@ -24,6 +24,11 @@ import { QueueWorkerProfile } from "@app/lib/types";
|
||||
import { CaType } from "@app/services/certificate-authority/certificate-authority-enums";
|
||||
import { ExternalPlatforms } from "@app/services/external-migration/external-migration-types";
|
||||
import { TCreateUserNotificationDTO } from "@app/services/notification/notification-types";
|
||||
import {
|
||||
TQueuePkiSyncImportCertificatesByIdDTO,
|
||||
TQueuePkiSyncRemoveCertificatesByIdDTO,
|
||||
TQueuePkiSyncSyncCertificatesByIdDTO
|
||||
} from "@app/services/pki-sync/pki-sync-types";
|
||||
import {
|
||||
TFailedIntegrationSyncEmailsPayload,
|
||||
TIntegrationSyncPayload,
|
||||
@@ -46,6 +51,7 @@ export enum QueueName {
|
||||
AuditLogPrune = "audit-log-prune",
|
||||
DailyResourceCleanUp = "daily-resource-cleanup",
|
||||
DailyExpiringPkiItemAlert = "daily-expiring-pki-item-alert",
|
||||
PkiSyncCleanup = "pki-sync-cleanup",
|
||||
PkiSubscriber = "pki-subscriber",
|
||||
TelemetryInstanceStats = "telemtry-self-hosted-stats",
|
||||
IntegrationSync = "sync-integrations",
|
||||
@@ -58,6 +64,7 @@ export enum QueueName {
|
||||
CaLifecycle = "ca-lifecycle", // parent queue to ca-order-certificate-for-subscriber
|
||||
SecretReplication = "secret-replication",
|
||||
SecretSync = "secret-sync", // parent queue to push integration sync, webhook, and secret replication
|
||||
PkiSync = "pki-sync",
|
||||
ProjectV3Migration = "project-v3-migration",
|
||||
AccessTokenStatusUpdate = "access-token-status-update",
|
||||
ImportSecretsFromExternalSource = "import-secrets-from-external-source",
|
||||
@@ -80,6 +87,7 @@ export enum QueueJobs {
|
||||
AuditLogPrune = "audit-log-prune-job",
|
||||
DailyResourceCleanUp = "daily-resource-cleanup-job",
|
||||
DailyExpiringPkiItemAlert = "daily-expiring-pki-item-alert",
|
||||
PkiSyncCleanup = "pki-sync-cleanup-job",
|
||||
SecWebhook = "secret-webhook-trigger",
|
||||
TelemetryInstanceStats = "telemetry-self-hosted-stats",
|
||||
IntegrationSync = "secret-integration-pull",
|
||||
@@ -91,6 +99,7 @@ export enum QueueJobs {
|
||||
CaCrlRotation = "ca-crl-rotation-job",
|
||||
SecretReplication = "secret-replication",
|
||||
SecretSync = "secret-sync", // parent queue to push integration sync, webhook, and secret replication
|
||||
PkiSync = "pki-sync",
|
||||
ProjectV3Migration = "project-v3-migration",
|
||||
IdentityAccessTokenStatusUpdate = "identity-access-token-status-update",
|
||||
ServiceTokenStatusUpdate = "service-token-status-update",
|
||||
@@ -99,6 +108,9 @@ export enum QueueJobs {
|
||||
SecretSyncImportSecrets = "secret-sync-import-secrets",
|
||||
SecretSyncRemoveSecrets = "secret-sync-remove-secrets",
|
||||
SecretSyncSendActionFailedNotifications = "secret-sync-send-action-failed-notifications",
|
||||
PkiSyncSyncCertificates = "pki-sync-sync-certificates",
|
||||
PkiSyncImportCertificates = "pki-sync-import-certificates",
|
||||
PkiSyncRemoveCertificates = "pki-sync-remove-certificates",
|
||||
SecretRotationV2QueueRotations = "secret-rotation-v2-queue-rotations",
|
||||
SecretRotationV2RotateSecrets = "secret-rotation-v2-rotate-secrets",
|
||||
SecretRotationV2SendNotification = "secret-rotation-v2-send-notification",
|
||||
@@ -141,6 +153,10 @@ export type TQueueJobTypes = {
|
||||
name: QueueJobs.DailyExpiringPkiItemAlert;
|
||||
payload: undefined;
|
||||
};
|
||||
[QueueName.PkiSyncCleanup]: {
|
||||
name: QueueJobs.PkiSyncCleanup;
|
||||
payload: undefined;
|
||||
};
|
||||
[QueueName.AuditLogPrune]: {
|
||||
name: QueueJobs.AuditLogPrune;
|
||||
payload: undefined;
|
||||
@@ -218,6 +234,19 @@ export type TQueueJobTypes = {
|
||||
name: QueueJobs.SecretSync;
|
||||
payload: TSyncSecretsDTO;
|
||||
};
|
||||
[QueueName.PkiSync]:
|
||||
| {
|
||||
name: QueueJobs.PkiSyncSyncCertificates;
|
||||
payload: TQueuePkiSyncSyncCertificatesByIdDTO;
|
||||
}
|
||||
| {
|
||||
name: QueueJobs.PkiSyncImportCertificates;
|
||||
payload: TQueuePkiSyncImportCertificatesByIdDTO;
|
||||
}
|
||||
| {
|
||||
name: QueueJobs.PkiSyncRemoveCertificates;
|
||||
payload: TQueuePkiSyncRemoveCertificatesByIdDTO;
|
||||
};
|
||||
[QueueName.ProjectV3Migration]: {
|
||||
name: QueueJobs.ProjectV3Migration;
|
||||
payload: { projectId: string };
|
||||
|
||||
@@ -248,6 +248,10 @@ import { pkiCollectionServiceFactory } from "@app/services/pki-collection/pki-co
|
||||
import { pkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal";
|
||||
import { pkiSubscriberQueueServiceFactory } from "@app/services/pki-subscriber/pki-subscriber-queue";
|
||||
import { pkiSubscriberServiceFactory } from "@app/services/pki-subscriber/pki-subscriber-service";
|
||||
import { pkiSyncCleanupQueueServiceFactory } from "@app/services/pki-sync/pki-sync-cleanup-queue";
|
||||
import { pkiSyncDALFactory } from "@app/services/pki-sync/pki-sync-dal";
|
||||
import { pkiSyncQueueFactory } from "@app/services/pki-sync/pki-sync-queue";
|
||||
import { pkiSyncServiceFactory } from "@app/services/pki-sync/pki-sync-service";
|
||||
import { pkiTemplatesDALFactory } from "@app/services/pki-templates/pki-templates-dal";
|
||||
import { pkiTemplatesServiceFactory } from "@app/services/pki-templates/pki-templates-service";
|
||||
import { projectDALFactory } from "@app/services/project/project-dal";
|
||||
@@ -979,6 +983,7 @@ export const registerRoutes = async (
|
||||
const pkiCollectionDAL = pkiCollectionDALFactory(db);
|
||||
const pkiCollectionItemDAL = pkiCollectionItemDALFactory(db);
|
||||
const pkiSubscriberDAL = pkiSubscriberDALFactory(db);
|
||||
const pkiSyncDAL = pkiSyncDALFactory(db);
|
||||
const pkiTemplatesDAL = pkiTemplatesDALFactory(db);
|
||||
|
||||
const instanceRelayConfigDAL = instanceRelayConfigDalFactory(db);
|
||||
@@ -988,21 +993,6 @@ export const registerRoutes = async (
|
||||
|
||||
const orgGatewayConfigV2DAL = orgGatewayConfigV2DalFactory(db);
|
||||
|
||||
const certificateService = certificateServiceFactory({
|
||||
certificateDAL,
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL,
|
||||
certificateAuthorityDAL,
|
||||
certificateAuthorityCertDAL,
|
||||
certificateAuthorityCrlDAL,
|
||||
certificateAuthoritySecretDAL,
|
||||
projectDAL,
|
||||
kmsService,
|
||||
permissionService,
|
||||
pkiCollectionDAL,
|
||||
pkiCollectionItemDAL
|
||||
});
|
||||
|
||||
const sshCertificateAuthorityService = sshCertificateAuthorityServiceFactory({
|
||||
sshCertificateAuthorityDAL,
|
||||
sshCertificateAuthoritySecretDAL,
|
||||
@@ -1864,52 +1854,6 @@ export const registerRoutes = async (
|
||||
licenseService
|
||||
});
|
||||
|
||||
const certificateAuthorityQueue = certificateAuthorityQueueFactory({
|
||||
certificateAuthorityCrlDAL,
|
||||
certificateAuthorityDAL,
|
||||
certificateAuthoritySecretDAL,
|
||||
certificateDAL,
|
||||
projectDAL,
|
||||
kmsService,
|
||||
queueService,
|
||||
pkiSubscriberDAL,
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL,
|
||||
externalCertificateAuthorityDAL,
|
||||
keyStore,
|
||||
appConnectionDAL,
|
||||
appConnectionService
|
||||
});
|
||||
|
||||
const internalCertificateAuthorityService = internalCertificateAuthorityServiceFactory({
|
||||
certificateAuthorityDAL,
|
||||
certificateAuthorityCertDAL,
|
||||
certificateAuthoritySecretDAL,
|
||||
certificateAuthorityCrlDAL,
|
||||
certificateTemplateDAL,
|
||||
certificateAuthorityQueue,
|
||||
certificateDAL,
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL,
|
||||
pkiCollectionDAL,
|
||||
pkiCollectionItemDAL,
|
||||
projectDAL,
|
||||
internalCertificateAuthorityDAL,
|
||||
kmsService,
|
||||
permissionService
|
||||
});
|
||||
|
||||
const certificateEstService = certificateEstServiceFactory({
|
||||
internalCertificateAuthorityService,
|
||||
certificateTemplateService,
|
||||
certificateTemplateDAL,
|
||||
certificateAuthorityCertDAL,
|
||||
certificateAuthorityDAL,
|
||||
projectDAL,
|
||||
kmsService,
|
||||
licenseService
|
||||
});
|
||||
|
||||
const kmipService = kmipServiceFactory({
|
||||
kmipClientDAL,
|
||||
permissionService,
|
||||
@@ -1952,6 +1896,79 @@ export const registerRoutes = async (
|
||||
gatewayV2Service
|
||||
});
|
||||
|
||||
const pkiSyncQueue = pkiSyncQueueFactory({
|
||||
queueService,
|
||||
kmsService,
|
||||
appConnectionDAL,
|
||||
keyStore,
|
||||
pkiSyncDAL,
|
||||
auditLogService,
|
||||
projectDAL,
|
||||
licenseService,
|
||||
certificateDAL,
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL,
|
||||
certificateAuthorityDAL,
|
||||
certificateAuthorityCertDAL
|
||||
});
|
||||
|
||||
const pkiSyncCleanup = pkiSyncCleanupQueueServiceFactory({
|
||||
queueService,
|
||||
pkiSyncDAL,
|
||||
pkiSyncQueue
|
||||
});
|
||||
|
||||
const internalCaFns = InternalCertificateAuthorityFns({
|
||||
certificateAuthorityDAL,
|
||||
certificateAuthorityCertDAL,
|
||||
certificateAuthoritySecretDAL,
|
||||
certificateAuthorityCrlDAL,
|
||||
certificateDAL,
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL,
|
||||
projectDAL,
|
||||
kmsService,
|
||||
pkiSyncDAL,
|
||||
pkiSyncQueue
|
||||
});
|
||||
|
||||
const certificateAuthorityQueue = certificateAuthorityQueueFactory({
|
||||
certificateAuthorityCrlDAL,
|
||||
certificateAuthorityDAL,
|
||||
certificateAuthoritySecretDAL,
|
||||
certificateDAL,
|
||||
projectDAL,
|
||||
kmsService,
|
||||
queueService,
|
||||
pkiSubscriberDAL,
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL,
|
||||
externalCertificateAuthorityDAL,
|
||||
keyStore,
|
||||
appConnectionDAL,
|
||||
appConnectionService,
|
||||
pkiSyncDAL,
|
||||
pkiSyncQueue
|
||||
});
|
||||
|
||||
const internalCertificateAuthorityService = internalCertificateAuthorityServiceFactory({
|
||||
certificateAuthorityDAL,
|
||||
certificateAuthorityCertDAL,
|
||||
certificateAuthoritySecretDAL,
|
||||
certificateAuthorityCrlDAL,
|
||||
certificateTemplateDAL,
|
||||
certificateAuthorityQueue,
|
||||
certificateDAL,
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL,
|
||||
pkiCollectionDAL,
|
||||
pkiCollectionItemDAL,
|
||||
projectDAL,
|
||||
internalCertificateAuthorityDAL,
|
||||
kmsService,
|
||||
permissionService
|
||||
});
|
||||
|
||||
const certificateAuthorityService = certificateAuthorityServiceFactory({
|
||||
certificateAuthorityDAL,
|
||||
permissionService,
|
||||
@@ -1964,19 +1981,20 @@ export const registerRoutes = async (
|
||||
certificateSecretDAL,
|
||||
kmsService,
|
||||
pkiSubscriberDAL,
|
||||
projectDAL
|
||||
projectDAL,
|
||||
pkiSyncDAL,
|
||||
pkiSyncQueue
|
||||
});
|
||||
|
||||
const internalCaFns = InternalCertificateAuthorityFns({
|
||||
certificateAuthorityDAL,
|
||||
const certificateEstService = certificateEstServiceFactory({
|
||||
internalCertificateAuthorityService,
|
||||
certificateTemplateService,
|
||||
certificateTemplateDAL,
|
||||
certificateAuthorityCertDAL,
|
||||
certificateAuthoritySecretDAL,
|
||||
certificateAuthorityCrlDAL,
|
||||
certificateDAL,
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL,
|
||||
certificateAuthorityDAL,
|
||||
projectDAL,
|
||||
kmsService
|
||||
kmsService,
|
||||
licenseService
|
||||
});
|
||||
|
||||
const pkiSubscriberQueue = pkiSubscriberQueueServiceFactory({
|
||||
@@ -1989,6 +2007,23 @@ export const registerRoutes = async (
|
||||
internalCaFns
|
||||
});
|
||||
|
||||
const certificateService = certificateServiceFactory({
|
||||
certificateDAL,
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL,
|
||||
certificateAuthorityDAL,
|
||||
certificateAuthorityCertDAL,
|
||||
certificateAuthorityCrlDAL,
|
||||
certificateAuthoritySecretDAL,
|
||||
projectDAL,
|
||||
kmsService,
|
||||
permissionService,
|
||||
pkiCollectionDAL,
|
||||
pkiCollectionItemDAL,
|
||||
pkiSyncDAL,
|
||||
pkiSyncQueue
|
||||
});
|
||||
|
||||
const pkiSubscriberService = pkiSubscriberServiceFactory({
|
||||
pkiSubscriberDAL,
|
||||
certificateAuthorityDAL,
|
||||
@@ -2002,7 +2037,18 @@ export const registerRoutes = async (
|
||||
kmsService,
|
||||
permissionService,
|
||||
certificateAuthorityQueue,
|
||||
internalCaFns
|
||||
internalCaFns,
|
||||
pkiSyncDAL,
|
||||
pkiSyncQueue
|
||||
});
|
||||
|
||||
const pkiSyncService = pkiSyncServiceFactory({
|
||||
pkiSyncDAL,
|
||||
pkiSubscriberDAL,
|
||||
appConnectionService,
|
||||
permissionService,
|
||||
licenseService,
|
||||
pkiSyncQueue
|
||||
});
|
||||
|
||||
const pkiTemplateService = pkiTemplatesServiceFactory({
|
||||
@@ -2067,6 +2113,7 @@ export const registerRoutes = async (
|
||||
await telemetryQueue.startTelemetryCheck();
|
||||
await telemetryQueue.startAggregatedEventsJob();
|
||||
await dailyResourceCleanUp.init();
|
||||
await pkiSyncCleanup.init();
|
||||
await dailyReminderQueueService.startDailyRemindersJob();
|
||||
await dailyReminderQueueService.startSecretReminderMigrationJob();
|
||||
await dailyExpiringPkiItemAlert.startSendingAlerts();
|
||||
@@ -2150,6 +2197,7 @@ export const registerRoutes = async (
|
||||
pkiAlert: pkiAlertService,
|
||||
pkiCollection: pkiCollectionService,
|
||||
pkiSubscriber: pkiSubscriberService,
|
||||
pkiSync: pkiSyncService,
|
||||
pkiTemplate: pkiTemplateService,
|
||||
secretScanning: secretScanningService,
|
||||
license: licenseService,
|
||||
|
||||
@@ -1457,6 +1457,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
|
||||
handler: async (req) => {
|
||||
const { secretPath, projectId, environment, secretKey, isOverride } = req.query;
|
||||
|
||||
// TODO (scott): just get the secret instead of searching for it in list
|
||||
const { secrets } = await server.services.secret.getSecretsRaw({
|
||||
actorId: req.permission.id,
|
||||
actor: req.permission.type,
|
||||
@@ -1473,7 +1474,9 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
|
||||
});
|
||||
|
||||
if (isOverride) {
|
||||
const personalSecret = secrets.find((secret) => secret.type === SecretType.Personal);
|
||||
const personalSecret = secrets.find(
|
||||
(secret) => secret.type === SecretType.Personal && secret.secretKey === secretKey
|
||||
);
|
||||
|
||||
if (!personalSecret)
|
||||
throw new BadRequestError({
|
||||
@@ -1486,7 +1489,9 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
|
||||
};
|
||||
}
|
||||
|
||||
const sharedSecret = secrets.find((secret) => secret.type === SecretType.Shared);
|
||||
const sharedSecret = secrets.find(
|
||||
(secret) => secret.type === SecretType.Shared && secret.secretKey === secretKey
|
||||
);
|
||||
|
||||
if (!sharedSecret)
|
||||
throw new BadRequestError({
|
||||
|
||||
@@ -48,6 +48,7 @@ import { registerPasswordRouter } from "./password-router";
|
||||
import { registerPkiAlertRouter } from "./pki-alert-router";
|
||||
import { registerPkiCollectionRouter } from "./pki-collection-router";
|
||||
import { registerPkiSubscriberRouter } from "./pki-subscriber-router";
|
||||
import { PKI_SYNC_REGISTER_ROUTER_MAP, registerPkiSyncRouter } from "./pki-sync-routers";
|
||||
import { registerProjectEnvRouter } from "./project-env-router";
|
||||
import { registerProjectKeyRouter } from "./project-key-router";
|
||||
import { registerProjectMembershipRouter } from "./project-membership-router";
|
||||
@@ -147,6 +148,15 @@ export const registerV1Routes = async (server: FastifyZodProvider) => {
|
||||
await pkiRouter.register(registerPkiAlertRouter, { prefix: "/alerts" });
|
||||
await pkiRouter.register(registerPkiCollectionRouter, { prefix: "/collections" });
|
||||
await pkiRouter.register(registerPkiSubscriberRouter, { prefix: "/subscribers" });
|
||||
await pkiRouter.register(
|
||||
async (pkiSyncRouter) => {
|
||||
await pkiSyncRouter.register(registerPkiSyncRouter);
|
||||
for await (const [destination, router] of Object.entries(PKI_SYNC_REGISTER_ROUTER_MAP)) {
|
||||
await pkiSyncRouter.register(router, { prefix: `/${destination}` });
|
||||
}
|
||||
},
|
||||
{ prefix: "/syncs" }
|
||||
);
|
||||
},
|
||||
{ prefix: "/pki" }
|
||||
);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
AZURE_KEY_VAULT_PKI_SYNC_LIST_OPTION,
|
||||
AzureKeyVaultPkiSyncSchema,
|
||||
CreateAzureKeyVaultPkiSyncSchema,
|
||||
UpdateAzureKeyVaultPkiSyncSchema
|
||||
} from "@app/services/pki-sync/azure-key-vault";
|
||||
import { PkiSync } from "@app/services/pki-sync/pki-sync-enums";
|
||||
|
||||
import { registerSyncPkiEndpoints } from "./pki-sync-endpoints";
|
||||
|
||||
export const registerAzureKeyVaultPkiSyncRouter = async (server: FastifyZodProvider) =>
|
||||
registerSyncPkiEndpoints({
|
||||
destination: PkiSync.AzureKeyVault,
|
||||
server,
|
||||
responseSchema: AzureKeyVaultPkiSyncSchema,
|
||||
createSchema: CreateAzureKeyVaultPkiSyncSchema,
|
||||
updateSchema: UpdateAzureKeyVaultPkiSyncSchema,
|
||||
syncOptions: {
|
||||
canImportCertificates: AZURE_KEY_VAULT_PKI_SYNC_LIST_OPTION.canImportCertificates,
|
||||
canRemoveCertificates: AZURE_KEY_VAULT_PKI_SYNC_LIST_OPTION.canRemoveCertificates
|
||||
}
|
||||
});
|
||||
9
backend/src/server/routes/v1/pki-sync-routers/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { PkiSync } from "@app/services/pki-sync/pki-sync-enums";
|
||||
|
||||
import { registerAzureKeyVaultPkiSyncRouter } from "./azure-key-vault-pki-sync-router";
|
||||
|
||||
export * from "./pki-sync-router";
|
||||
|
||||
export const PKI_SYNC_REGISTER_ROUTER_MAP: Record<PkiSync, (server: FastifyZodProvider) => Promise<void>> = {
|
||||
[PkiSync.AzureKeyVault]: registerAzureKeyVaultPkiSyncRouter
|
||||
};
|
||||
@@ -0,0 +1,341 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import { ApiDocsTags } from "@app/lib/api-docs";
|
||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
import { PkiSync } from "@app/services/pki-sync/pki-sync-enums";
|
||||
import { PKI_SYNC_NAME_MAP } from "@app/services/pki-sync/pki-sync-maps";
|
||||
|
||||
export const registerSyncPkiEndpoints = ({
|
||||
server,
|
||||
destination,
|
||||
createSchema,
|
||||
updateSchema,
|
||||
responseSchema,
|
||||
syncOptions
|
||||
}: {
|
||||
destination: PkiSync;
|
||||
server: FastifyZodProvider;
|
||||
createSchema: z.ZodType<{
|
||||
name: string;
|
||||
projectId: string;
|
||||
connectionId: string;
|
||||
destinationConfig: Record<string, unknown>;
|
||||
syncOptions?: Record<string, unknown>;
|
||||
description?: string;
|
||||
isAutoSyncEnabled?: boolean;
|
||||
subscriberId?: string;
|
||||
}>;
|
||||
updateSchema: z.ZodType<{
|
||||
connectionId?: string;
|
||||
name?: string;
|
||||
destinationConfig?: Record<string, unknown>;
|
||||
syncOptions?: Record<string, unknown>;
|
||||
description?: string;
|
||||
isAutoSyncEnabled?: boolean;
|
||||
subscriberId?: string;
|
||||
}>;
|
||||
responseSchema: z.ZodTypeAny;
|
||||
syncOptions: {
|
||||
canImportCertificates: boolean;
|
||||
canRemoveCertificates: boolean;
|
||||
};
|
||||
}) => {
|
||||
const destinationName = PKI_SYNC_NAME_MAP[destination];
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: `/`,
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
hide: false,
|
||||
tags: [ApiDocsTags.PkiSyncs],
|
||||
description: `List the ${destinationName} PKI Syncs for the specified project.`,
|
||||
querystring: z.object({
|
||||
projectId: z.string().trim().min(1, "Project ID required")
|
||||
}),
|
||||
response: {
|
||||
200: z.object({ pkiSyncs: responseSchema.array() })
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const {
|
||||
query: { projectId }
|
||||
} = req;
|
||||
|
||||
const pkiSyncs = await server.services.pkiSync.listPkiSyncsByProjectId({ projectId }, req.permission);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId,
|
||||
event: {
|
||||
type: EventType.GET_PKI_SYNCS,
|
||||
metadata: {
|
||||
projectId
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { pkiSyncs };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/:pkiSyncId",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
hide: false,
|
||||
tags: [ApiDocsTags.PkiSyncs],
|
||||
description: `Get the specified ${destinationName} PKI Sync by ID.`,
|
||||
params: z.object({
|
||||
pkiSyncId: z.string()
|
||||
}),
|
||||
response: {
|
||||
200: responseSchema
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const { pkiSyncId } = req.params;
|
||||
|
||||
const pkiSync = await server.services.pkiSync.findPkiSyncById({ id: pkiSyncId }, req.permission);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: pkiSync.projectId,
|
||||
event: {
|
||||
type: EventType.GET_PKI_SYNC,
|
||||
metadata: {
|
||||
syncId: pkiSyncId,
|
||||
destination
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return pkiSync;
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
hide: false,
|
||||
tags: [ApiDocsTags.PkiSyncs],
|
||||
description: `Create a ${destinationName} PKI Sync for the specified project.`,
|
||||
body: createSchema,
|
||||
response: {
|
||||
200: responseSchema
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const pkiSync = await server.services.pkiSync.createPkiSync({ ...req.body, destination }, req.permission);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: pkiSync.projectId,
|
||||
event: {
|
||||
type: EventType.CREATE_PKI_SYNC,
|
||||
metadata: {
|
||||
pkiSyncId: pkiSync.id,
|
||||
name: pkiSync.name,
|
||||
destination
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return pkiSync;
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "PATCH",
|
||||
url: "/:pkiSyncId",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
hide: false,
|
||||
tags: [ApiDocsTags.PkiSyncs],
|
||||
description: `Update the specified ${destinationName} PKI Sync.`,
|
||||
params: z.object({
|
||||
pkiSyncId: z.string()
|
||||
}),
|
||||
body: updateSchema,
|
||||
response: {
|
||||
200: responseSchema
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const { pkiSyncId } = req.params;
|
||||
|
||||
const pkiSync = await server.services.pkiSync.updatePkiSync({ ...req.body, id: pkiSyncId }, req.permission);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: pkiSync.projectId,
|
||||
event: {
|
||||
type: EventType.UPDATE_PKI_SYNC,
|
||||
metadata: {
|
||||
pkiSyncId,
|
||||
name: pkiSync.name
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return pkiSync;
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "DELETE",
|
||||
url: `/:pkiSyncId`,
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
hide: false,
|
||||
tags: [ApiDocsTags.PkiSyncs],
|
||||
description: `Delete the specified ${destinationName} PKI Sync.`,
|
||||
params: z.object({
|
||||
pkiSyncId: z.string()
|
||||
}),
|
||||
response: {
|
||||
200: responseSchema
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const { pkiSyncId } = req.params;
|
||||
|
||||
const pkiSync = await server.services.pkiSync.deletePkiSync({ id: pkiSyncId }, req.permission);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: pkiSync.projectId,
|
||||
event: {
|
||||
type: EventType.DELETE_PKI_SYNC,
|
||||
metadata: {
|
||||
pkiSyncId,
|
||||
name: pkiSync.name,
|
||||
destination: pkiSync.destination
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return pkiSync;
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/:pkiSyncId/sync",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
hide: false,
|
||||
tags: [ApiDocsTags.PkiSyncs],
|
||||
description: `Trigger a sync for the specified ${destinationName} PKI Sync.`,
|
||||
params: z.object({
|
||||
pkiSyncId: z.string()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({ message: z.string() })
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const { pkiSyncId } = req.params;
|
||||
|
||||
const result = await server.services.pkiSync.triggerPkiSyncSyncCertificatesById(
|
||||
{
|
||||
id: pkiSyncId
|
||||
},
|
||||
req.permission
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
});
|
||||
|
||||
// Only register import route if the destination supports it
|
||||
if (syncOptions.canImportCertificates) {
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/:pkiSyncId/import",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
hide: false,
|
||||
tags: [ApiDocsTags.PkiSyncs],
|
||||
description: `Import certificates from the specified ${destinationName} PKI Sync destination.`,
|
||||
params: z.object({
|
||||
pkiSyncId: z.string()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({ message: z.string() })
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const { pkiSyncId } = req.params;
|
||||
|
||||
const result = await server.services.pkiSync.triggerPkiSyncImportCertificatesById(
|
||||
{
|
||||
id: pkiSyncId
|
||||
},
|
||||
req.permission
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/:pkiSyncId/remove-certificates",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
hide: false,
|
||||
tags: [ApiDocsTags.PkiSyncs],
|
||||
description: `Remove certificates from the specified ${destinationName} PKI Sync destination.`,
|
||||
params: z.object({
|
||||
pkiSyncId: z.string()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({ message: z.string() })
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const { pkiSyncId } = req.params;
|
||||
|
||||
const result = await server.services.pkiSync.triggerPkiSyncRemoveCertificatesById(
|
||||
{
|
||||
id: pkiSyncId
|
||||
},
|
||||
req.permission
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
});
|
||||
};
|
||||
182
backend/src/server/routes/v1/pki-sync-routers/pki-sync-router.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import { ApiDocsTags } from "@app/lib/api-docs";
|
||||
import { readLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
import { PkiSync } from "@app/services/pki-sync/pki-sync-enums";
|
||||
|
||||
const PkiSyncSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable().optional(),
|
||||
destination: z.nativeEnum(PkiSync),
|
||||
isAutoSyncEnabled: z.boolean(),
|
||||
destinationConfig: z.record(z.unknown()),
|
||||
syncOptions: z.record(z.unknown()),
|
||||
projectId: z.string().uuid(),
|
||||
subscriberId: z.string().uuid().nullable().optional(),
|
||||
connectionId: z.string().uuid(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
// Sync status fields
|
||||
syncStatus: z.string().nullable().optional(),
|
||||
lastSyncJobId: z.string().nullable().optional(),
|
||||
lastSyncMessage: z.string().nullable().optional(),
|
||||
lastSyncedAt: z.date().nullable().optional(),
|
||||
// Import status fields
|
||||
importStatus: z.string().nullable().optional(),
|
||||
lastImportJobId: z.string().nullable().optional(),
|
||||
lastImportMessage: z.string().nullable().optional(),
|
||||
lastImportedAt: z.date().nullable().optional(),
|
||||
// Remove status fields
|
||||
removeStatus: z.string().nullable().optional(),
|
||||
lastRemoveJobId: z.string().nullable().optional(),
|
||||
lastRemoveMessage: z.string().nullable().optional(),
|
||||
lastRemovedAt: z.date().nullable().optional(),
|
||||
// App connection info
|
||||
appConnectionName: z.string(),
|
||||
appConnectionApp: z.string(),
|
||||
connection: z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
app: z.string(),
|
||||
encryptedCredentials: z.unknown().nullable(),
|
||||
orgId: z.string().uuid(),
|
||||
projectId: z.string().uuid().nullable().optional(),
|
||||
method: z.string(),
|
||||
description: z.string().nullable().optional(),
|
||||
version: z.number(),
|
||||
gatewayId: z.string().uuid().nullable().optional(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
isPlatformManagedCredentials: z.boolean().nullable().optional()
|
||||
}),
|
||||
subscriber: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string()
|
||||
})
|
||||
.nullable()
|
||||
.optional()
|
||||
});
|
||||
|
||||
const PkiSyncOptionsSchema = z.object({
|
||||
name: z.string(),
|
||||
connection: z.nativeEnum(AppConnection),
|
||||
destination: z.nativeEnum(PkiSync),
|
||||
canImportCertificates: z.boolean(),
|
||||
canRemoveCertificates: z.boolean(),
|
||||
defaultCertificateNameSchema: z.string().optional(),
|
||||
forbiddenCharacters: z.string().optional(),
|
||||
allowedCharacterPattern: z.string().optional(),
|
||||
maxCertificateNameLength: z.number().optional(),
|
||||
minCertificateNameLength: z.number().optional()
|
||||
});
|
||||
|
||||
export const registerPkiSyncRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/options",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
hide: false,
|
||||
tags: [ApiDocsTags.PkiSyncs],
|
||||
description: "List the available PKI Sync Options.",
|
||||
response: {
|
||||
200: z.object({
|
||||
pkiSyncOptions: PkiSyncOptionsSchema.array()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: () => {
|
||||
const pkiSyncOptions = server.services.pkiSync.getPkiSyncOptions();
|
||||
return { pkiSyncOptions };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
hide: false,
|
||||
tags: [ApiDocsTags.PkiSyncs],
|
||||
description: "List all the PKI Syncs for the specified project.",
|
||||
querystring: z.object({
|
||||
projectId: z.string().trim().min(1)
|
||||
}),
|
||||
response: {
|
||||
200: z.object({ pkiSyncs: PkiSyncSchema.array() })
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const {
|
||||
query: { projectId },
|
||||
permission
|
||||
} = req;
|
||||
|
||||
const pkiSyncs = await server.services.pkiSync.listPkiSyncsByProjectId({ projectId }, permission);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId,
|
||||
event: {
|
||||
type: EventType.GET_PKI_SYNCS,
|
||||
metadata: {
|
||||
projectId
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { pkiSyncs };
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/:pkiSyncId",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
hide: false,
|
||||
tags: [ApiDocsTags.PkiSyncs],
|
||||
description: "Get a PKI Sync by ID.",
|
||||
params: z.object({
|
||||
pkiSyncId: z.string()
|
||||
}),
|
||||
response: {
|
||||
200: PkiSyncSchema
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const { pkiSyncId } = req.params;
|
||||
|
||||
const pkiSync = await server.services.pkiSync.findPkiSyncById({ id: pkiSyncId }, req.permission);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: pkiSync.projectId,
|
||||
event: {
|
||||
type: EventType.GET_PKI_SYNC,
|
||||
metadata: {
|
||||
syncId: pkiSyncId,
|
||||
destination: pkiSync.destination
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return pkiSync;
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -316,7 +316,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
||||
}
|
||||
],
|
||||
params: z.object({
|
||||
slug: slugSchema({ max: 36 }).describe("The slug of the project to get.")
|
||||
slug: slugSchema({ max: 64 }).describe("The slug of the project to get.")
|
||||
}),
|
||||
response: {
|
||||
200: projectWithEnv
|
||||
|
||||
@@ -180,7 +180,7 @@ export const registerDeprecatedProjectRouter = async (server: FastifyZodProvider
|
||||
}
|
||||
],
|
||||
params: z.object({
|
||||
slug: slugSchema({ min: 5, max: 36 }).describe("The slug of the project to delete.")
|
||||
slug: slugSchema({ min: 5, max: 64 }).describe("The slug of the project to delete.")
|
||||
}),
|
||||
response: {
|
||||
200: SanitizedProjectSchema
|
||||
@@ -233,7 +233,7 @@ export const registerDeprecatedProjectRouter = async (server: FastifyZodProvider
|
||||
}
|
||||
],
|
||||
params: z.object({
|
||||
slug: slugSchema({ max: 36 }).describe("The slug of the project to get.")
|
||||
slug: slugSchema({ max: 64 }).describe("The slug of the project to get.")
|
||||
}),
|
||||
response: {
|
||||
200: projectWithEnv
|
||||
@@ -266,7 +266,7 @@ export const registerDeprecatedProjectRouter = async (server: FastifyZodProvider
|
||||
},
|
||||
schema: {
|
||||
params: z.object({
|
||||
slug: slugSchema({ min: 5, max: 36 }).describe("The slug of the project to update.")
|
||||
slug: slugSchema({ min: 5, max: 64 }).describe("The slug of the project to update.")
|
||||
}),
|
||||
body: z.object({
|
||||
name: z.string().trim().optional().describe(PROJECTS.UPDATE.name),
|
||||
@@ -322,7 +322,7 @@ export const registerDeprecatedProjectRouter = async (server: FastifyZodProvider
|
||||
hide: false,
|
||||
tags: [ApiDocsTags.PkiCertificateAuthorities],
|
||||
params: z.object({
|
||||
slug: slugSchema({ min: 5, max: 36 }).describe(PROJECTS.LIST_CAS.slug)
|
||||
slug: slugSchema({ min: 5, max: 64 }).describe(PROJECTS.LIST_CAS.slug)
|
||||
}),
|
||||
querystring: z.object({
|
||||
status: z.enum([CaStatus.ACTIVE, CaStatus.PENDING_CERTIFICATE]).optional().describe(PROJECTS.LIST_CAS.status),
|
||||
@@ -365,7 +365,7 @@ export const registerDeprecatedProjectRouter = async (server: FastifyZodProvider
|
||||
hide: false,
|
||||
tags: [ApiDocsTags.PkiCertificates],
|
||||
params: z.object({
|
||||
slug: slugSchema({ min: 5, max: 36 }).describe(PROJECTS.LIST_CERTIFICATES.slug)
|
||||
slug: slugSchema({ min: 5, max: 64 }).describe(PROJECTS.LIST_CERTIFICATES.slug)
|
||||
}),
|
||||
querystring: z.object({
|
||||
friendlyName: z.string().optional().describe(PROJECTS.LIST_CERTIFICATES.friendlyName),
|
||||
|
||||
@@ -151,7 +151,12 @@ const SECRET_SCANNING_APP_CONNECTION_MAP = Object.fromEntries(
|
||||
);
|
||||
|
||||
// scott: ideally this would be derived from a utilized map like the above
|
||||
const PKI_APP_CONNECTIONS = [AppConnection.AWS, AppConnection.Cloudflare, AppConnection.AzureADCS];
|
||||
const PKI_APP_CONNECTIONS = [
|
||||
AppConnection.AWS,
|
||||
AppConnection.Cloudflare,
|
||||
AppConnection.AzureADCS,
|
||||
AppConnection.AzureKeyVault
|
||||
];
|
||||
|
||||
export const listAppConnectionOptions = (projectType?: ProjectType) => {
|
||||
return [
|
||||
|
||||
@@ -268,9 +268,9 @@ export const appConnectionServiceFactory = ({
|
||||
const { permission } = await permissionService.getOrgPermission(
|
||||
actor.type,
|
||||
actor.id,
|
||||
actor.orgId,
|
||||
appConnection.orgId,
|
||||
actor.authMethod,
|
||||
appConnection.orgId
|
||||
actor.orgId
|
||||
);
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
@@ -318,9 +318,9 @@ export const appConnectionServiceFactory = ({
|
||||
const { permission } = await permissionService.getOrgPermission(
|
||||
actor.type,
|
||||
actor.id,
|
||||
actor.orgId,
|
||||
appConnection.orgId,
|
||||
actor.authMethod,
|
||||
appConnection.orgId
|
||||
actor.orgId
|
||||
);
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
@@ -477,9 +477,9 @@ export const appConnectionServiceFactory = ({
|
||||
const { permission: orgPermission } = await permissionService.getOrgPermission(
|
||||
actor.type,
|
||||
actor.id,
|
||||
actor.orgId,
|
||||
appConnection.orgId,
|
||||
actor.authMethod,
|
||||
appConnection.orgId
|
||||
actor.orgId
|
||||
);
|
||||
|
||||
if (appConnection.projectId) {
|
||||
@@ -635,9 +635,9 @@ export const appConnectionServiceFactory = ({
|
||||
const { permission } = await permissionService.getOrgPermission(
|
||||
actor.type,
|
||||
actor.id,
|
||||
actor.orgId,
|
||||
appConnection.orgId,
|
||||
actor.authMethod,
|
||||
appConnection.orgId
|
||||
actor.orgId
|
||||
);
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
@@ -805,9 +805,9 @@ export const appConnectionServiceFactory = ({
|
||||
const { permission } = await permissionService.getOrgPermission(
|
||||
actor.type,
|
||||
actor.id,
|
||||
actor.orgId,
|
||||
appConnection.orgId,
|
||||
actor.authMethod,
|
||||
appConnection.orgId
|
||||
actor.orgId
|
||||
);
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
|
||||
@@ -22,13 +22,13 @@ interface HerokuOAuthTokenResponse {
|
||||
}
|
||||
|
||||
export const getHerokuConnectionListItem = () => {
|
||||
const { CLIENT_ID_HEROKU } = getConfig();
|
||||
const { INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_ID } = getConfig();
|
||||
|
||||
return {
|
||||
name: "Heroku" as const,
|
||||
app: AppConnection.Heroku as const,
|
||||
methods: Object.values(HerokuConnectionMethod) as [HerokuConnectionMethod.AuthToken, HerokuConnectionMethod.OAuth],
|
||||
oauthClientId: CLIENT_ID_HEROKU
|
||||
oauthClientId: INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_ID
|
||||
};
|
||||
};
|
||||
|
||||
@@ -40,12 +40,12 @@ export const refreshHerokuToken = async (
|
||||
appConnectionDAL: Pick<TAppConnectionDALFactory, "updateById">,
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">
|
||||
): Promise<string> => {
|
||||
const { CLIENT_SECRET_HEROKU } = getConfig();
|
||||
const { INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_SECRET } = getConfig();
|
||||
|
||||
const payload = {
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_secret: CLIENT_SECRET_HEROKU
|
||||
client_secret: INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_SECRET
|
||||
};
|
||||
|
||||
const { data } = await request.post<{ access_token: string; expires_in: number }>(
|
||||
@@ -75,7 +75,7 @@ export const refreshHerokuToken = async (
|
||||
};
|
||||
|
||||
export const exchangeHerokuOAuthCode = async (code: string): Promise<HerokuOAuthTokenResponse> => {
|
||||
const { CLIENT_SECRET_HEROKU } = getConfig();
|
||||
const { INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_SECRET } = getConfig();
|
||||
|
||||
try {
|
||||
const response = await request.post<HerokuOAuthTokenResponse>(
|
||||
@@ -83,7 +83,7 @@ export const exchangeHerokuOAuthCode = async (code: string): Promise<HerokuOAuth
|
||||
{
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
client_secret: CLIENT_SECRET_HEROKU
|
||||
client_secret: INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_SECRET
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
|
||||
@@ -23,6 +23,9 @@ import {
|
||||
} from "@app/services/certificate/certificate-types";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal";
|
||||
import { TPkiSyncDALFactory } from "@app/services/pki-sync/pki-sync-dal";
|
||||
import { triggerAutoSyncForSubscriber } from "@app/services/pki-sync/pki-sync-utils";
|
||||
import { TPkiSyncQueueFactory } from "@app/services/pki-sync/pki-sync-queue";
|
||||
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||
import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns";
|
||||
|
||||
@@ -56,6 +59,8 @@ type TAcmeCertificateAuthorityFnsDeps = {
|
||||
"encryptWithKmsKey" | "generateKmsKey" | "createCipherPairWithDataKey" | "decryptWithKmsKey"
|
||||
>;
|
||||
pkiSubscriberDAL: Pick<TPkiSubscriberDALFactory, "findById">;
|
||||
pkiSyncDAL: Pick<TPkiSyncDALFactory, "find">;
|
||||
pkiSyncQueue: Pick<TPkiSyncQueueFactory, "queuePkiSyncSyncCertificatesById">;
|
||||
projectDAL: Pick<TProjectDALFactory, "findById" | "findOne" | "updateById" | "transaction">;
|
||||
};
|
||||
|
||||
@@ -109,7 +114,9 @@ export const AcmeCertificateAuthorityFns = ({
|
||||
certificateSecretDAL,
|
||||
kmsService,
|
||||
projectDAL,
|
||||
pkiSubscriberDAL
|
||||
pkiSubscriberDAL,
|
||||
pkiSyncDAL,
|
||||
pkiSyncQueue
|
||||
}: TAcmeCertificateAuthorityFnsDeps) => {
|
||||
const createCertificateAuthority = async ({
|
||||
name,
|
||||
@@ -524,6 +531,8 @@ export const AcmeCertificateAuthorityFns = ({
|
||||
tx
|
||||
);
|
||||
});
|
||||
|
||||
await triggerAutoSyncForSubscriber(subscriber.id, { pkiSyncDAL, pkiSyncQueue });
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -26,6 +26,9 @@ import {
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal";
|
||||
import { TPkiSubscriberProperties } from "@app/services/pki-subscriber/pki-subscriber-types";
|
||||
import { TPkiSyncDALFactory } from "@app/services/pki-sync/pki-sync-dal";
|
||||
import { triggerAutoSyncForSubscriber } from "@app/services/pki-sync/pki-sync-utils";
|
||||
import { TPkiSyncQueueFactory } from "@app/services/pki-sync/pki-sync-queue";
|
||||
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||
import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns";
|
||||
|
||||
@@ -55,6 +58,8 @@ type TAzureAdCsCertificateAuthorityFnsDeps = {
|
||||
"encryptWithKmsKey" | "generateKmsKey" | "createCipherPairWithDataKey" | "decryptWithKmsKey"
|
||||
>;
|
||||
pkiSubscriberDAL: Pick<TPkiSubscriberDALFactory, "findById">;
|
||||
pkiSyncDAL: Pick<TPkiSyncDALFactory, "find">;
|
||||
pkiSyncQueue: Pick<TPkiSyncQueueFactory, "queuePkiSyncSyncCertificatesById">;
|
||||
projectDAL: Pick<TProjectDALFactory, "findById" | "findOne" | "updateById" | "transaction">;
|
||||
};
|
||||
|
||||
@@ -584,7 +589,9 @@ export const AzureAdCsCertificateAuthorityFns = ({
|
||||
certificateSecretDAL,
|
||||
kmsService,
|
||||
projectDAL,
|
||||
pkiSubscriberDAL
|
||||
pkiSubscriberDAL,
|
||||
pkiSyncDAL,
|
||||
pkiSyncQueue
|
||||
}: TAzureAdCsCertificateAuthorityFnsDeps) => {
|
||||
const createCertificateAuthority = async ({
|
||||
name,
|
||||
@@ -1024,6 +1031,8 @@ export const AzureAdCsCertificateAuthorityFns = ({
|
||||
);
|
||||
});
|
||||
|
||||
await triggerAutoSyncForSubscriber(subscriber.id, { pkiSyncDAL, pkiSyncQueue });
|
||||
|
||||
return {
|
||||
certificate: certificatePem,
|
||||
certificateChain: certificateChainPem,
|
||||
|
||||
@@ -20,6 +20,8 @@ import { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal"
|
||||
import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal";
|
||||
import { TPkiSubscriberDALFactory } from "../pki-subscriber/pki-subscriber-dal";
|
||||
import { SubscriberOperationStatus } from "../pki-subscriber/pki-subscriber-types";
|
||||
import { TPkiSyncDALFactory } from "../pki-sync/pki-sync-dal";
|
||||
import { TPkiSyncQueueFactory } from "../pki-sync/pki-sync-queue";
|
||||
import { AcmeCertificateAuthorityFns } from "./acme/acme-certificate-authority-fns";
|
||||
import { AzureAdCsCertificateAuthorityFns } from "./azure-ad-cs/azure-ad-cs-certificate-authority-fns";
|
||||
import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal";
|
||||
@@ -50,6 +52,8 @@ type TCertificateAuthorityQueueFactoryDep = {
|
||||
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "create">;
|
||||
queueService: TQueueServiceFactory;
|
||||
pkiSubscriberDAL: Pick<TPkiSubscriberDALFactory, "findById" | "updateById">;
|
||||
pkiSyncDAL: Pick<TPkiSyncDALFactory, "find">;
|
||||
pkiSyncQueue: Pick<TPkiSyncQueueFactory, "queuePkiSyncSyncCertificatesById">;
|
||||
};
|
||||
|
||||
export type TCertificateAuthorityQueueFactory = ReturnType<typeof certificateAuthorityQueueFactory>;
|
||||
@@ -68,7 +72,9 @@ export const certificateAuthorityQueueFactory = ({
|
||||
externalCertificateAuthorityDAL,
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL,
|
||||
pkiSubscriberDAL
|
||||
pkiSubscriberDAL,
|
||||
pkiSyncDAL,
|
||||
pkiSyncQueue
|
||||
}: TCertificateAuthorityQueueFactoryDep) => {
|
||||
const acmeFns = AcmeCertificateAuthorityFns({
|
||||
appConnectionDAL,
|
||||
@@ -80,7 +86,9 @@ export const certificateAuthorityQueueFactory = ({
|
||||
certificateSecretDAL,
|
||||
kmsService,
|
||||
pkiSubscriberDAL,
|
||||
projectDAL
|
||||
projectDAL,
|
||||
pkiSyncDAL,
|
||||
pkiSyncQueue
|
||||
});
|
||||
|
||||
const azureAdCsFns = AzureAdCsCertificateAuthorityFns({
|
||||
@@ -93,7 +101,9 @@ export const certificateAuthorityQueueFactory = ({
|
||||
certificateSecretDAL,
|
||||
kmsService,
|
||||
pkiSubscriberDAL,
|
||||
projectDAL
|
||||
projectDAL,
|
||||
pkiSyncDAL,
|
||||
pkiSyncQueue
|
||||
});
|
||||
|
||||
// TODO 1: auto-periodic rotation
|
||||
|
||||
@@ -13,6 +13,8 @@ import { TCertificateDALFactory } from "../certificate/certificate-dal";
|
||||
import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal";
|
||||
import { TKmsServiceFactory } from "../kms/kms-service";
|
||||
import { TPkiSubscriberDALFactory } from "../pki-subscriber/pki-subscriber-dal";
|
||||
import { TPkiSyncDALFactory } from "../pki-sync/pki-sync-dal";
|
||||
import { TPkiSyncQueueFactory } from "../pki-sync/pki-sync-queue";
|
||||
import { TProjectDALFactory } from "../project/project-dal";
|
||||
import {
|
||||
AcmeCertificateAuthorityFns,
|
||||
@@ -68,6 +70,8 @@ type TCertificateAuthorityServiceFactoryDep = {
|
||||
"encryptWithKmsKey" | "generateKmsKey" | "createCipherPairWithDataKey" | "decryptWithKmsKey"
|
||||
>;
|
||||
pkiSubscriberDAL: Pick<TPkiSubscriberDALFactory, "findById">;
|
||||
pkiSyncDAL: Pick<TPkiSyncDALFactory, "find">;
|
||||
pkiSyncQueue: Pick<TPkiSyncQueueFactory, "queuePkiSyncSyncCertificatesById">;
|
||||
};
|
||||
|
||||
export type TCertificateAuthorityServiceFactory = ReturnType<typeof certificateAuthorityServiceFactory>;
|
||||
@@ -84,7 +88,9 @@ export const certificateAuthorityServiceFactory = ({
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL,
|
||||
kmsService,
|
||||
pkiSubscriberDAL
|
||||
pkiSubscriberDAL,
|
||||
pkiSyncDAL,
|
||||
pkiSyncQueue
|
||||
}: TCertificateAuthorityServiceFactoryDep) => {
|
||||
const acmeFns = AcmeCertificateAuthorityFns({
|
||||
appConnectionDAL,
|
||||
@@ -96,7 +102,9 @@ export const certificateAuthorityServiceFactory = ({
|
||||
certificateSecretDAL,
|
||||
kmsService,
|
||||
pkiSubscriberDAL,
|
||||
projectDAL
|
||||
projectDAL,
|
||||
pkiSyncDAL,
|
||||
pkiSyncQueue
|
||||
});
|
||||
|
||||
const azureAdCsFns = AzureAdCsCertificateAuthorityFns({
|
||||
@@ -109,7 +117,9 @@ export const certificateAuthorityServiceFactory = ({
|
||||
certificateSecretDAL,
|
||||
kmsService,
|
||||
pkiSubscriberDAL,
|
||||
projectDAL
|
||||
projectDAL,
|
||||
pkiSyncDAL,
|
||||
pkiSyncQueue
|
||||
});
|
||||
|
||||
const createCertificateAuthority = async (
|
||||
|
||||
@@ -19,6 +19,9 @@ import {
|
||||
TAltNameMapping
|
||||
} from "@app/services/certificate/certificate-types";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { TPkiSyncDALFactory } from "@app/services/pki-sync/pki-sync-dal";
|
||||
import { triggerAutoSyncForSubscriber } from "@app/services/pki-sync/pki-sync-utils";
|
||||
import { TPkiSyncQueueFactory } from "@app/services/pki-sync/pki-sync-queue";
|
||||
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||
import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns";
|
||||
|
||||
@@ -51,6 +54,8 @@ type TInternalCertificateAuthorityFnsDeps = {
|
||||
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction">;
|
||||
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "create">;
|
||||
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "create">;
|
||||
pkiSyncDAL: Pick<TPkiSyncDALFactory, "find">;
|
||||
pkiSyncQueue: Pick<TPkiSyncQueueFactory, "queuePkiSyncSyncCertificatesById">;
|
||||
};
|
||||
|
||||
export const InternalCertificateAuthorityFns = ({
|
||||
@@ -62,7 +67,9 @@ export const InternalCertificateAuthorityFns = ({
|
||||
certificateAuthorityCrlDAL,
|
||||
certificateDAL,
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL
|
||||
certificateSecretDAL,
|
||||
pkiSyncDAL,
|
||||
pkiSyncQueue
|
||||
}: TInternalCertificateAuthorityFnsDeps) => {
|
||||
const issueCertificate = async (
|
||||
subscriber: TPkiSubscribers,
|
||||
@@ -251,6 +258,8 @@ export const InternalCertificateAuthorityFns = ({
|
||||
);
|
||||
});
|
||||
|
||||
await triggerAutoSyncForSubscriber(subscriber.id, { pkiSyncDAL, pkiSyncQueue });
|
||||
|
||||
return {
|
||||
certificate: leafCert.toString("pem"),
|
||||
certificateChain: certificateChainPem,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { TableName, TCertificates } from "@app/db/schemas";
|
||||
import { DatabaseError } from "@app/lib/errors";
|
||||
import { ormify } from "@app/lib/knex";
|
||||
|
||||
@@ -25,6 +25,20 @@ export const certificateDALFactory = (db: TDbClient) => {
|
||||
}
|
||||
};
|
||||
|
||||
const findAllActiveCertsForSubscriber = async ({ subscriberId }: { subscriberId: string }) => {
|
||||
try {
|
||||
const certs = await db
|
||||
.replicaNode()(TableName.Certificate)
|
||||
.where({ pkiSubscriberId: subscriberId, status: CertStatus.ACTIVE })
|
||||
.where("notAfter", ">", new Date())
|
||||
.orderBy("notBefore", "desc");
|
||||
|
||||
return certs;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Find all active certificates for subscriber" });
|
||||
}
|
||||
};
|
||||
|
||||
const countCertificatesInProject = async ({
|
||||
projectId,
|
||||
friendlyName,
|
||||
@@ -79,10 +93,33 @@ export const certificateDALFactory = (db: TDbClient) => {
|
||||
}
|
||||
};
|
||||
|
||||
const findExpiredSyncedCertificates = async (): Promise<TCertificates[]> => {
|
||||
try {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
yesterday.setHours(0, 0, 0, 0);
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const certs = await db
|
||||
.replicaNode()(TableName.Certificate)
|
||||
.where("notAfter", ">=", yesterday)
|
||||
.where("notAfter", "<", today)
|
||||
.whereNotNull("pkiSubscriberId");
|
||||
|
||||
return certs;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Find expired synced certificates" });
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
...certificateOrm,
|
||||
countCertificatesInProject,
|
||||
countCertificatesForPkiSubscriber,
|
||||
findLatestActiveCertForSubscriber
|
||||
findLatestActiveCertForSubscriber,
|
||||
findAllActiveCertsForSubscriber,
|
||||
findExpiredSyncedCertificates
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
import { ForbiddenError } from "@casl/ability";
|
||||
import * as x509 from "@peculiar/x509";
|
||||
|
||||
@@ -20,6 +21,9 @@ import { TCertificateAuthoritySecretDALFactory } from "@app/services/certificate
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { TPkiCollectionDALFactory } from "@app/services/pki-collection/pki-collection-dal";
|
||||
import { TPkiCollectionItemDALFactory } from "@app/services/pki-collection/pki-collection-item-dal";
|
||||
import { TPkiSyncDALFactory } from "@app/services/pki-sync/pki-sync-dal";
|
||||
import { TPkiSyncQueueFactory } from "@app/services/pki-sync/pki-sync-queue";
|
||||
import { triggerAutoSyncForSubscriber } from "@app/services/pki-sync/pki-sync-utils";
|
||||
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||
import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns";
|
||||
|
||||
@@ -53,6 +57,8 @@ type TCertificateServiceFactoryDep = {
|
||||
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug" | "findOne" | "updateById" | "findById" | "transaction">;
|
||||
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey">;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||
pkiSyncDAL: Pick<TPkiSyncDALFactory, "find">;
|
||||
pkiSyncQueue: Pick<TPkiSyncQueueFactory, "queuePkiSyncSyncCertificatesById">;
|
||||
};
|
||||
|
||||
export type TCertificateServiceFactory = ReturnType<typeof certificateServiceFactory>;
|
||||
@@ -69,7 +75,9 @@ export const certificateServiceFactory = ({
|
||||
pkiCollectionItemDAL,
|
||||
projectDAL,
|
||||
kmsService,
|
||||
permissionService
|
||||
permissionService,
|
||||
pkiSyncDAL,
|
||||
pkiSyncQueue
|
||||
}: TCertificateServiceFactoryDep) => {
|
||||
/**
|
||||
* Return details for certificate with serial number [serialNumber]
|
||||
@@ -158,6 +166,11 @@ export const certificateServiceFactory = ({
|
||||
|
||||
const deletedCert = await certificateDAL.deleteById(cert.id);
|
||||
|
||||
// Trigger auto sync for PKI syncs connected to this certificate's subscriber
|
||||
if (cert.pkiSubscriberId) {
|
||||
await triggerAutoSyncForSubscriber(cert.pkiSubscriberId, { pkiSyncDAL, pkiSyncQueue });
|
||||
}
|
||||
|
||||
return {
|
||||
deletedCert
|
||||
};
|
||||
@@ -222,6 +235,11 @@ export const certificateServiceFactory = ({
|
||||
}
|
||||
);
|
||||
|
||||
// Trigger auto sync for PKI syncs connected to this certificate's subscriber
|
||||
if (cert.pkiSubscriberId) {
|
||||
await triggerAutoSyncForSubscriber(cert.pkiSubscriberId, { pkiSyncDAL, pkiSyncQueue });
|
||||
}
|
||||
|
||||
// Note: External CA revocation handling would go here for supported CA types
|
||||
// Currently, only internal CAs and ACME CAs support revocation
|
||||
|
||||
|
||||
129
backend/src/services/connection-queue/connection-queue-fns.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
import { logger } from "@app/lib/logger";
|
||||
|
||||
export type RateLimitConfig = {
|
||||
MAX_CONCURRENT_REQUESTS: number;
|
||||
BASE_DELAY: number;
|
||||
MAX_DELAY: number;
|
||||
MAX_RETRIES: number;
|
||||
RATE_LIMIT_STATUS_CODES: number[];
|
||||
};
|
||||
|
||||
export type RateLimitContext = {
|
||||
operation: string;
|
||||
identifier?: string;
|
||||
syncId: string;
|
||||
};
|
||||
|
||||
export type ConcurrencyContext = {
|
||||
operation: string;
|
||||
syncId: string;
|
||||
};
|
||||
|
||||
export const sleep = (ms: number): Promise<void> =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
|
||||
export const createRateLimitErrorChecker =
|
||||
(config: RateLimitConfig) =>
|
||||
(error: unknown): boolean => {
|
||||
if (error instanceof AxiosError) {
|
||||
return (
|
||||
config.RATE_LIMIT_STATUS_CODES.includes(error.response?.status || 0) ||
|
||||
error.message.toLowerCase().includes("rate limit") ||
|
||||
error.message.toLowerCase().includes("throttl")
|
||||
);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const createRateLimitRetry =
|
||||
(config: RateLimitConfig, isRateLimitError: (error: unknown) => boolean) =>
|
||||
async <T>(fn: () => Promise<T>, context: RateLimitContext, retryCount = 0): Promise<T> => {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
if (isRateLimitError(error) && retryCount < config.MAX_RETRIES) {
|
||||
const delay = Math.min(config.BASE_DELAY * 2 ** retryCount, config.MAX_DELAY);
|
||||
|
||||
logger.warn(
|
||||
{
|
||||
syncId: context.syncId,
|
||||
operation: context.operation,
|
||||
identifier: context.identifier,
|
||||
retryCount: retryCount + 1,
|
||||
delayMs: delay,
|
||||
error: error instanceof AxiosError ? error.message : String(error)
|
||||
},
|
||||
"Rate limit hit, retrying with exponential backoff"
|
||||
);
|
||||
|
||||
await sleep(delay);
|
||||
return createRateLimitRetry(config, isRateLimitError)(fn, context, retryCount + 1);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const createConcurrencyLimitExecutor =
|
||||
(
|
||||
config: RateLimitConfig,
|
||||
withRateLimitRetry: <T>(fn: () => Promise<T>, context: RateLimitContext, retryCount?: number) => Promise<T>
|
||||
) =>
|
||||
async <T, R>(
|
||||
items: T[],
|
||||
executor: (item: T) => Promise<R>,
|
||||
context: ConcurrencyContext,
|
||||
concurrencyLimit = config.MAX_CONCURRENT_REQUESTS
|
||||
): Promise<PromiseSettledResult<R>[]> => {
|
||||
const results: PromiseSettledResult<R>[] = [];
|
||||
|
||||
for (let i = 0; i < items.length; i += concurrencyLimit) {
|
||||
const batch = items.slice(i, i + concurrencyLimit);
|
||||
|
||||
logger.debug(
|
||||
{
|
||||
syncId: context.syncId,
|
||||
operation: context.operation,
|
||||
batchStart: i + 1,
|
||||
batchEnd: Math.min(i + concurrencyLimit, items.length),
|
||||
totalItems: items.length
|
||||
},
|
||||
"Processing batch with rate limit protection"
|
||||
);
|
||||
|
||||
const batchPromises = batch.map((item, batchIndex) =>
|
||||
withRateLimitRetry(() => executor(item), {
|
||||
operation: context.operation,
|
||||
identifier: `batch-${i + batchIndex + 1}`,
|
||||
syncId: context.syncId
|
||||
})
|
||||
);
|
||||
|
||||
const batchResults = await Promise.allSettled(batchPromises);
|
||||
results.push(...batchResults);
|
||||
|
||||
if (i + concurrencyLimit < items.length) {
|
||||
await sleep(100);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
};
|
||||
|
||||
export const createConnectionQueue = (config: RateLimitConfig) => {
|
||||
const isRateLimitError = createRateLimitErrorChecker(config);
|
||||
const withRateLimitRetry = createRateLimitRetry(config, isRateLimitError);
|
||||
const executeWithConcurrencyLimit = createConcurrencyLimitExecutor(config, withRateLimitRetry);
|
||||
|
||||
return {
|
||||
sleep,
|
||||
isRateLimitError,
|
||||
withRateLimitRetry,
|
||||
executeWithConcurrencyLimit
|
||||
};
|
||||
};
|
||||
1
backend/src/services/connection-queue/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./connection-queue-fns";
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
/* eslint-disable no-bitwise */
|
||||
import { ForbiddenError, subject } from "@casl/ability";
|
||||
import * as x509 from "@peculiar/x509";
|
||||
@@ -36,6 +37,9 @@ import {
|
||||
import { TCertificateAuthoritySecretDALFactory } from "@app/services/certificate-authority/certificate-authority-secret-dal";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal";
|
||||
import { TPkiSyncDALFactory } from "@app/services/pki-sync/pki-sync-dal";
|
||||
import { triggerAutoSyncForSubscriber } from "@app/services/pki-sync/pki-sync-utils";
|
||||
import { TPkiSyncQueueFactory } from "@app/services/pki-sync/pki-sync-queue";
|
||||
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||
import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns";
|
||||
|
||||
@@ -79,6 +83,8 @@ type TPkiSubscriberServiceFactoryDep = {
|
||||
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "decryptWithKmsKey" | "encryptWithKmsKey">;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||
internalCaFns: ReturnType<typeof InternalCertificateAuthorityFns>;
|
||||
pkiSyncDAL: Pick<TPkiSyncDALFactory, "find">;
|
||||
pkiSyncQueue: Pick<TPkiSyncQueueFactory, "queuePkiSyncSyncCertificatesById">;
|
||||
};
|
||||
|
||||
export type TPkiSubscriberServiceFactory = ReturnType<typeof pkiSubscriberServiceFactory>;
|
||||
@@ -96,7 +102,9 @@ export const pkiSubscriberServiceFactory = ({
|
||||
kmsService,
|
||||
permissionService,
|
||||
certificateAuthorityQueue,
|
||||
internalCaFns
|
||||
internalCaFns,
|
||||
pkiSyncDAL,
|
||||
pkiSyncQueue
|
||||
}: TPkiSubscriberServiceFactoryDep) => {
|
||||
const createSubscriber = async ({
|
||||
name,
|
||||
@@ -413,7 +421,12 @@ export const pkiSubscriberServiceFactory = ({
|
||||
|
||||
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(subscriber.caId);
|
||||
if (ca.internalCa?.id) {
|
||||
return internalCaFns.issueCertificate(subscriber, ca);
|
||||
const result = await internalCaFns.issueCertificate(subscriber, ca);
|
||||
|
||||
// Trigger auto sync for PKI syncs connected to this subscriber after certificate issuance
|
||||
await triggerAutoSyncForSubscriber(subscriber.id, { pkiSyncDAL, pkiSyncQueue });
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
throw new BadRequestError({ message: "CA does not support immediate issuance of certificates" });
|
||||
@@ -671,6 +684,9 @@ export const pkiSubscriberServiceFactory = ({
|
||||
return cert;
|
||||
});
|
||||
|
||||
// Trigger auto sync for PKI syncs connected to this subscriber after certificate signing
|
||||
await triggerAutoSyncForSubscriber(subscriber.id, { pkiSyncDAL, pkiSyncQueue });
|
||||
|
||||
return {
|
||||
certificate: leafCert.toString("pem"),
|
||||
certificateChain: `${issuingCaCertificate}\n${caCertChain}`.trim(),
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import RE2 from "re2";
|
||||
|
||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||
import { PkiSync } from "@app/services/pki-sync/pki-sync-enums";
|
||||
|
||||
/**
|
||||
* Azure Key Vault naming constraints for certificates
|
||||
*/
|
||||
export const AZURE_KEY_VAULT_CERTIFICATE_NAMING = {
|
||||
/**
|
||||
* Regular expression pattern for valid Azure Key Vault certificate names
|
||||
* Must contain only alphanumeric characters and hyphens (a-z, A-Z, 0-9, -)
|
||||
* Must be 1-127 characters long
|
||||
*/
|
||||
NAME_PATTERN: new RE2("^[a-zA-Z0-9-]{1,127}$"),
|
||||
|
||||
/**
|
||||
* String of characters that are forbidden in Azure Key Vault certificate names
|
||||
*/
|
||||
FORBIDDEN_CHARACTERS: "!@#$%^&*()+=[]{}|\\:;\"'<>,.?/~` _",
|
||||
|
||||
/**
|
||||
* Maximum length for certificate names in Azure Key Vault
|
||||
*/
|
||||
MAX_NAME_LENGTH: 127,
|
||||
|
||||
/**
|
||||
* Minimum length for certificate names in Azure Key Vault
|
||||
*/
|
||||
MIN_NAME_LENGTH: 1,
|
||||
|
||||
/**
|
||||
* String representation of the allowed character pattern (for UI display)
|
||||
*/
|
||||
ALLOWED_CHARACTER_PATTERN: "^[a-zA-Z0-9-]{1,127}$"
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Azure Key Vault PKI Sync list option configuration
|
||||
*/
|
||||
export const AZURE_KEY_VAULT_PKI_SYNC_LIST_OPTION = {
|
||||
name: "Azure Key Vault" as const,
|
||||
connection: AppConnection.AzureKeyVault,
|
||||
destination: PkiSync.AzureKeyVault,
|
||||
canImportCertificates: false,
|
||||
canRemoveCertificates: true,
|
||||
defaultCertificateNameSchema: "Infisical-PKI-Sync-{{certificateId}}",
|
||||
forbiddenCharacters: AZURE_KEY_VAULT_CERTIFICATE_NAMING.FORBIDDEN_CHARACTERS,
|
||||
allowedCharacterPattern: AZURE_KEY_VAULT_CERTIFICATE_NAMING.ALLOWED_CHARACTER_PATTERN,
|
||||
maxCertificateNameLength: AZURE_KEY_VAULT_CERTIFICATE_NAMING.MAX_NAME_LENGTH,
|
||||
minCertificateNameLength: AZURE_KEY_VAULT_CERTIFICATE_NAMING.MIN_NAME_LENGTH
|
||||
} as const;
|
||||
@@ -0,0 +1,680 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
import { AxiosError } from "axios";
|
||||
import * as crypto from "crypto";
|
||||
|
||||
import { request } from "@app/lib/config/request";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal";
|
||||
import { getAzureConnectionAccessToken } from "@app/services/app-connection/azure-key-vault";
|
||||
import { createConnectionQueue, RateLimitConfig } from "@app/services/connection-queue";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { matchesCertificateNameSchema } from "@app/services/pki-sync/pki-sync-fns";
|
||||
import { TCertificateMap } from "@app/services/pki-sync/pki-sync-types";
|
||||
|
||||
import { PkiSyncError } from "../pki-sync-errors";
|
||||
import { TPkiSyncWithCredentials } from "../pki-sync-types";
|
||||
import { GetAzureKeyVaultCertificate, TAzureKeyVaultPkiSyncConfig } from "./azure-key-vault-pki-sync-types";
|
||||
|
||||
const AZURE_RATE_LIMIT_CONFIG: RateLimitConfig = {
|
||||
MAX_CONCURRENT_REQUESTS: 10,
|
||||
BASE_DELAY: 1000,
|
||||
MAX_DELAY: 30000,
|
||||
MAX_RETRIES: 3,
|
||||
RATE_LIMIT_STATUS_CODES: [429, 503]
|
||||
};
|
||||
|
||||
const azureConnectionQueue = createConnectionQueue(AZURE_RATE_LIMIT_CONFIG);
|
||||
|
||||
const { withRateLimitRetry, executeWithConcurrencyLimit } = azureConnectionQueue;
|
||||
|
||||
const extractCertificateNameFromId = (certificateId: string): string => {
|
||||
return certificateId.substring(certificateId.lastIndexOf("/") + 1);
|
||||
};
|
||||
|
||||
const isInfisicalManagedCertificate = (certificateName: string, pkiSync: TPkiSyncWithCredentials): boolean => {
|
||||
const syncOptions = pkiSync.syncOptions as { certificateNameSchema?: string } | undefined;
|
||||
const certificateNameSchema = syncOptions?.certificateNameSchema;
|
||||
|
||||
if (certificateNameSchema) {
|
||||
const environment = "global";
|
||||
return matchesCertificateNameSchema(certificateName, environment, certificateNameSchema);
|
||||
}
|
||||
|
||||
return certificateName.startsWith("Infisical-PKI-Sync-");
|
||||
};
|
||||
|
||||
type TAzureKeyVaultPkiSyncFactoryDeps = {
|
||||
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "updateById">;
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
||||
};
|
||||
|
||||
const parseCertificateX509Props = (certPem: string) => {
|
||||
try {
|
||||
const cert = new crypto.X509Certificate(certPem);
|
||||
|
||||
const { subject } = cert;
|
||||
|
||||
const sans = {
|
||||
dns_names: [] as string[],
|
||||
emails: [] as string[],
|
||||
upns: [] as string[]
|
||||
};
|
||||
|
||||
if (cert.subjectAltName) {
|
||||
const sanEntries = cert.subjectAltName.split(", ");
|
||||
for (const entry of sanEntries) {
|
||||
if (entry.startsWith("DNS:")) {
|
||||
sans.dns_names.push(entry.substring(4));
|
||||
} else if (entry.startsWith("email:")) {
|
||||
sans.emails.push(entry.substring(6));
|
||||
} else if (entry.startsWith("othername:UPN:")) {
|
||||
sans.upns.push(entry.substring(14));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
subject,
|
||||
sans
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
"Failed to parse certificate X.509 properties, using empty values"
|
||||
);
|
||||
return {
|
||||
subject: "",
|
||||
sans: {
|
||||
dns_names: [],
|
||||
emails: [],
|
||||
upns: []
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const parseCertificateKeyProps = (certPem: string) => {
|
||||
try {
|
||||
const publicKeyObject = crypto.createPublicKey(certPem);
|
||||
const keyDetails = publicKeyObject.asymmetricKeyDetails;
|
||||
|
||||
if (!keyDetails) {
|
||||
if (publicKeyObject.asymmetricKeyType === "rsa") {
|
||||
const pubKeyStr = publicKeyObject.export({ type: "spki", format: "der" }).toString("hex");
|
||||
const estimatedBits = pubKeyStr.length * 4;
|
||||
|
||||
let keySize = 2048;
|
||||
if (estimatedBits >= 4000) {
|
||||
keySize = 4096;
|
||||
} else if (estimatedBits >= 3000) {
|
||||
keySize = 3072;
|
||||
} else if (estimatedBits >= 2000) {
|
||||
keySize = 2048;
|
||||
} else if (estimatedBits >= 1000) {
|
||||
keySize = 1024;
|
||||
}
|
||||
|
||||
return {
|
||||
kty: "RSA",
|
||||
key_size: keySize
|
||||
};
|
||||
}
|
||||
|
||||
if (publicKeyObject.asymmetricKeyType === "ec") {
|
||||
return {
|
||||
kty: "EC",
|
||||
curve: "P-256"
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kty: "RSA",
|
||||
key_size: 2048
|
||||
};
|
||||
}
|
||||
|
||||
if (publicKeyObject.asymmetricKeyType === "rsa") {
|
||||
const modulusLength = keyDetails.modulusLength || 2048;
|
||||
return {
|
||||
kty: "RSA",
|
||||
key_size: modulusLength
|
||||
};
|
||||
}
|
||||
|
||||
if (publicKeyObject.asymmetricKeyType === "ec") {
|
||||
const { namedCurve } = keyDetails;
|
||||
let curveName = "P-256";
|
||||
|
||||
switch (namedCurve) {
|
||||
case "prime256v1":
|
||||
case "secp256r1":
|
||||
curveName = "P-256";
|
||||
break;
|
||||
case "secp384r1":
|
||||
curveName = "P-384";
|
||||
break;
|
||||
case "secp521r1":
|
||||
curveName = "P-521";
|
||||
break;
|
||||
default:
|
||||
curveName = "P-256";
|
||||
}
|
||||
|
||||
return {
|
||||
kty: "EC",
|
||||
curve: curveName
|
||||
};
|
||||
}
|
||||
|
||||
const keyType = publicKeyObject.asymmetricKeyType;
|
||||
if (keyType && !["rsa", "ec"].includes(keyType)) {
|
||||
throw new Error(`Unsupported certificate key type: ${keyType}. Azure Key Vault only supports RSA and EC keys.`);
|
||||
}
|
||||
|
||||
logger.warn({ keyType }, "Unable to determine certificate key type, defaulting to RSA 2048");
|
||||
return {
|
||||
kty: "RSA",
|
||||
key_size: 2048
|
||||
};
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{ error: error instanceof Error ? error.message : String(error) },
|
||||
"Failed to parse certificate key properties, defaulting to RSA 2048"
|
||||
);
|
||||
return {
|
||||
kty: "RSA",
|
||||
key_size: 2048
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const azureKeyVaultPkiSyncFactory = ({ kmsService, appConnectionDAL }: TAzureKeyVaultPkiSyncFactoryDeps) => {
|
||||
const $getAzureKeyVaultCertificates = async (accessToken: string, vaultBaseUrl: string, syncId = "unknown") => {
|
||||
const paginateAzureKeyVaultCertificates = async () => {
|
||||
let result: GetAzureKeyVaultCertificate[] = [];
|
||||
|
||||
let currentUrl = `${vaultBaseUrl}/certificates?api-version=7.4`;
|
||||
|
||||
while (currentUrl) {
|
||||
const urlToFetch = currentUrl; // Capture current URL to avoid loop function issue
|
||||
const res = await withRateLimitRetry(
|
||||
() =>
|
||||
request.get<{ value: GetAzureKeyVaultCertificate[]; nextLink: string }>(urlToFetch, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
}
|
||||
}),
|
||||
{ operation: "list-certificates", syncId }
|
||||
);
|
||||
|
||||
result = result.concat(res.data.value);
|
||||
currentUrl = res.data.nextLink;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const getAzureKeyVaultCertificates = await paginateAzureKeyVaultCertificates();
|
||||
|
||||
const enabledAzureKeyVaultCertificates = getAzureKeyVaultCertificates.filter((cert) => cert.attributes.enabled);
|
||||
|
||||
// disabled certificates to skip sending updates to
|
||||
const disabledAzureKeyVaultCertificateKeys = getAzureKeyVaultCertificates
|
||||
.filter(({ attributes }) => !attributes.enabled)
|
||||
.map((certificate) => extractCertificateNameFromId(certificate.id));
|
||||
|
||||
// Use rate-limited concurrent execution for fetching certificate details
|
||||
const certificateResults = await executeWithConcurrencyLimit(
|
||||
enabledAzureKeyVaultCertificates,
|
||||
async (getAzureKeyVaultCertificate) => {
|
||||
const azureKeyVaultCertificate = await request.get<GetAzureKeyVaultCertificate>(
|
||||
`${getAzureKeyVaultCertificate.id}?api-version=7.4`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
let certPem = "";
|
||||
if (azureKeyVaultCertificate.data.cer) {
|
||||
try {
|
||||
// Azure Key Vault stores certificate in base64 DER format
|
||||
// We need to convert it to PEM format with proper headers
|
||||
const base64Cert = azureKeyVaultCertificate.data.cer;
|
||||
const base64Lines = base64Cert.match(/.{1,64}/g);
|
||||
if (!base64Lines) {
|
||||
throw new Error("Failed to format base64 certificate data");
|
||||
}
|
||||
certPem = `-----BEGIN CERTIFICATE-----\n${base64Lines.join("\n")}\n-----END CERTIFICATE-----`;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
certificateId: getAzureKeyVaultCertificate.id
|
||||
},
|
||||
"Failed to convert Azure Key Vault certificate to PEM format, skipping certificate"
|
||||
);
|
||||
certPem = ""; // Skip this certificate if we can't convert it properly
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...azureKeyVaultCertificate.data,
|
||||
key: extractCertificateNameFromId(getAzureKeyVaultCertificate.id),
|
||||
cert: certPem,
|
||||
privateKey: "" // Private keys cannot be extracted from Azure Key Vault for security reasons
|
||||
};
|
||||
},
|
||||
{ operation: "fetch-certificate-details", syncId }
|
||||
);
|
||||
|
||||
const successfulCertificates = certificateResults
|
||||
.filter(
|
||||
(
|
||||
result
|
||||
): result is PromiseFulfilledResult<
|
||||
GetAzureKeyVaultCertificate & {
|
||||
key: string;
|
||||
cert: string;
|
||||
privateKey: string;
|
||||
}
|
||||
> => result.status === "fulfilled"
|
||||
)
|
||||
.map((result) => result.value);
|
||||
|
||||
// Log any failures
|
||||
const failedFetches = certificateResults.filter((result) => result.status === "rejected");
|
||||
if (failedFetches.length > 0) {
|
||||
logger.warn(
|
||||
{
|
||||
syncId,
|
||||
failedCount: failedFetches.length,
|
||||
totalCount: enabledAzureKeyVaultCertificates.length
|
||||
},
|
||||
"Some certificate details could not be fetched from Azure Key Vault"
|
||||
);
|
||||
}
|
||||
|
||||
const res: Record<string, { cert: string; privateKey: string }> = successfulCertificates.reduce(
|
||||
(obj, certificate) => ({
|
||||
...obj,
|
||||
[certificate.key]: {
|
||||
cert: certificate.cert,
|
||||
privateKey: certificate.privateKey
|
||||
}
|
||||
}),
|
||||
{} as Record<string, { cert: string; privateKey: string }>
|
||||
);
|
||||
|
||||
return {
|
||||
vaultCertificates: res,
|
||||
disabledAzureKeyVaultCertificateKeys
|
||||
};
|
||||
};
|
||||
|
||||
const syncCertificates = async (pkiSync: TPkiSyncWithCredentials, certificateMap: TCertificateMap) => {
|
||||
const { accessToken } = await getAzureConnectionAccessToken(pkiSync.connection.id, appConnectionDAL, kmsService);
|
||||
|
||||
// Cast destination config to Azure Key Vault config
|
||||
const destinationConfig = pkiSync.destinationConfig as TAzureKeyVaultPkiSyncConfig;
|
||||
|
||||
const { vaultCertificates, disabledAzureKeyVaultCertificateKeys } = await $getAzureKeyVaultCertificates(
|
||||
accessToken,
|
||||
destinationConfig.vaultBaseUrl,
|
||||
pkiSync.id
|
||||
);
|
||||
|
||||
const setCertificates: {
|
||||
key: string;
|
||||
cert: string;
|
||||
privateKey: string;
|
||||
certificateChain?: string;
|
||||
}[] = [];
|
||||
|
||||
// Track which certificates should exist in Azure Key Vault
|
||||
const activeCertificateNames = Object.keys(certificateMap);
|
||||
|
||||
// Iterate through certificates to sync to Azure Key Vault
|
||||
Object.entries(certificateMap).forEach(([certName, { cert, privateKey, certificateChain }]) => {
|
||||
if (disabledAzureKeyVaultCertificateKeys.includes(certName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingCert = vaultCertificates[certName];
|
||||
const shouldUpdateCert = !existingCert || existingCert.cert !== cert;
|
||||
|
||||
if (shouldUpdateCert) {
|
||||
setCertificates.push({
|
||||
key: certName,
|
||||
cert,
|
||||
privateKey,
|
||||
certificateChain
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Identify expired/removed certificates that need to be cleaned up from Azure Key Vault
|
||||
// Only remove certificates that were managed by Infisical (match naming schema)
|
||||
const certificatesToRemove = Object.keys(vaultCertificates).filter(
|
||||
(vaultCertName) =>
|
||||
isInfisicalManagedCertificate(vaultCertName, pkiSync) &&
|
||||
!activeCertificateNames.includes(vaultCertName) &&
|
||||
!disabledAzureKeyVaultCertificateKeys.includes(vaultCertName)
|
||||
);
|
||||
|
||||
// Upload certificates to Azure Key Vault with rate limiting
|
||||
const uploadResults = await executeWithConcurrencyLimit(
|
||||
setCertificates,
|
||||
async ({ key, cert, privateKey, certificateChain }) => {
|
||||
try {
|
||||
// Combine private key, certificate, and certificate chain in PEM format for Azure Key Vault
|
||||
let combinedPem = "";
|
||||
|
||||
if (privateKey) {
|
||||
combinedPem = privateKey.trim();
|
||||
}
|
||||
|
||||
if (combinedPem) {
|
||||
combinedPem = `${combinedPem}\n${cert.trim()}`;
|
||||
} else {
|
||||
combinedPem = cert.trim();
|
||||
}
|
||||
|
||||
if (certificateChain) {
|
||||
const trimmedChain = certificateChain.trim();
|
||||
if (trimmedChain) {
|
||||
combinedPem = `${combinedPem}\n${trimmedChain}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to base64 for Azure Key Vault import
|
||||
const base64Cert = Buffer.from(combinedPem).toString("base64");
|
||||
|
||||
// Parse certificate to extract X.509 properties and key properties
|
||||
const x509Props = parseCertificateX509Props(cert);
|
||||
const keyProps = parseCertificateKeyProps(cert);
|
||||
|
||||
// Build key_props based on key type
|
||||
const keyPropsConfig = {
|
||||
exportable: true,
|
||||
reuse_key: false,
|
||||
...keyProps
|
||||
};
|
||||
|
||||
const importData = {
|
||||
value: base64Cert,
|
||||
policy: {
|
||||
key_props: keyPropsConfig,
|
||||
secret_props: {
|
||||
contentType: "application/x-pem-file"
|
||||
},
|
||||
x509_props: x509Props
|
||||
},
|
||||
attributes: {
|
||||
enabled: true,
|
||||
exportable: true
|
||||
}
|
||||
};
|
||||
|
||||
const response = await request.post(
|
||||
`${destinationConfig.vaultBaseUrl}/certificates/${encodeURIComponent(key)}/import?api-version=7.4`,
|
||||
importData,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return { key, success: true, response: response.data as unknown };
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
const errorMessage =
|
||||
error.response?.data && typeof error.response.data === "object" && "error" in error.response.data
|
||||
? (error.response.data as { error?: { message?: string } }).error?.message || error.message
|
||||
: error.message;
|
||||
|
||||
// Check if the error is due to certificate in deleted but recoverable state
|
||||
const isDeletedButRecoverable =
|
||||
errorMessage.includes("deleted but recoverable state") || errorMessage.includes("name cannot be reused");
|
||||
|
||||
if (isDeletedButRecoverable) {
|
||||
logger.warn(
|
||||
{ certificateKey: key, syncId: pkiSync.id },
|
||||
"Certificate exists in deleted but recoverable state in Azure Key Vault - skipping upload"
|
||||
);
|
||||
return { key, success: false, skipped: true, reason: "Certificate in deleted but recoverable state" };
|
||||
}
|
||||
|
||||
throw new PkiSyncError({
|
||||
message: `Failed to upload certificate ${key} to Azure Key Vault: ${errorMessage}`,
|
||||
cause: error,
|
||||
context: {
|
||||
certificateKey: key,
|
||||
statusCode: error.response?.status,
|
||||
responseData: error.response?.data
|
||||
}
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{ operation: "upload-certificates", syncId: pkiSync.id }
|
||||
);
|
||||
|
||||
const results = uploadResults;
|
||||
const failedUploads = results.filter((result) => result.status === "rejected");
|
||||
const fulfilledResults = results.filter((result) => result.status === "fulfilled");
|
||||
|
||||
// Separate successful uploads from skipped certificates
|
||||
const successfulUploads = fulfilledResults.filter(
|
||||
(result) => result.status === "fulfilled" && result.value.success
|
||||
);
|
||||
const skippedUploads = fulfilledResults.filter((result) => result.status === "fulfilled" && result.value.skipped);
|
||||
|
||||
// Remove expired/removed certificates from Azure Key Vault
|
||||
let removedCertificates = 0;
|
||||
let failedRemovals = 0;
|
||||
|
||||
if (certificatesToRemove.length > 0) {
|
||||
const removeResults = await executeWithConcurrencyLimit(
|
||||
certificatesToRemove,
|
||||
async (certName) => {
|
||||
try {
|
||||
await request.delete(
|
||||
`${destinationConfig.vaultBaseUrl}/certificates/${encodeURIComponent(certName)}?api-version=7.4`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return { key: certName, success: true };
|
||||
} catch (error) {
|
||||
// If certificate doesn't exist (404), consider it as successfully removed
|
||||
if (error instanceof AxiosError && error.response?.status === 404) {
|
||||
return { key: certName, success: true, alreadyRemoved: true };
|
||||
}
|
||||
|
||||
logger.error(
|
||||
{ error, syncId: pkiSync.id, certificateName: certName },
|
||||
"Failed to remove expired/removed certificate from Azure Key Vault"
|
||||
);
|
||||
|
||||
// Don't throw here - we want to continue with other operations
|
||||
return { key: certName, success: false, error: error as Error };
|
||||
}
|
||||
},
|
||||
{ operation: "remove-certificates", syncId: pkiSync.id }
|
||||
);
|
||||
const successfulRemovals = removeResults.filter(
|
||||
(result) => result.status === "fulfilled" && result.value.success
|
||||
);
|
||||
removedCertificates = successfulRemovals.length;
|
||||
failedRemovals = removeResults.length - removedCertificates;
|
||||
|
||||
if (failedRemovals > 0) {
|
||||
logger.warn(
|
||||
{
|
||||
syncId: pkiSync.id,
|
||||
failedRemovals,
|
||||
successfulRemovals: removedCertificates
|
||||
},
|
||||
"Some expired/removed certificates could not be removed from Azure Key Vault"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Collect detailed information for UI feedback
|
||||
const details: {
|
||||
failedUploads?: Array<{ name: string; error: string }>;
|
||||
failedRemovals?: Array<{ name: string; error: string }>;
|
||||
skippedCertificates?: Array<{ name: string; reason: string }>;
|
||||
} = {};
|
||||
|
||||
// Collect skipped certificate details
|
||||
if (skippedUploads.length > 0) {
|
||||
details.skippedCertificates = skippedUploads.map((result) => {
|
||||
const certificateName = result.status === "fulfilled" ? result.value.key : "unknown";
|
||||
return {
|
||||
name: certificateName,
|
||||
reason: "Azure Key Vault constraints or certificate already up to date"
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Collect failed upload details
|
||||
if (failedUploads.length > 0) {
|
||||
details.failedUploads = failedUploads.map((failure, index) => {
|
||||
const certificateName = setCertificates[index]?.key || "unknown";
|
||||
let errorMessage = "Unknown error";
|
||||
|
||||
if (failure.status === "rejected") {
|
||||
errorMessage = (failure.reason as Error)?.message || "Unknown error";
|
||||
}
|
||||
|
||||
return {
|
||||
name: certificateName,
|
||||
error: errorMessage
|
||||
};
|
||||
});
|
||||
|
||||
logger.error(
|
||||
{
|
||||
syncId: pkiSync.id,
|
||||
failedUploads: details.failedUploads,
|
||||
failedCount: failedUploads.length
|
||||
},
|
||||
"Some certificates failed to upload to Azure Key Vault"
|
||||
);
|
||||
}
|
||||
|
||||
// Collect failed removal details
|
||||
if (failedRemovals > 0) {
|
||||
const failedRemovalNames = certificatesToRemove.slice(-failedRemovals);
|
||||
details.failedRemovals = failedRemovalNames.map((certName) => ({
|
||||
name: certName,
|
||||
error: "Failed to remove from Azure Key Vault"
|
||||
}));
|
||||
|
||||
logger.warn(
|
||||
{
|
||||
syncId: pkiSync.id,
|
||||
failedRemovals: details.failedRemovals,
|
||||
successfulRemovals: removedCertificates
|
||||
},
|
||||
"Some expired/removed certificates could not be removed from Azure Key Vault"
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
uploaded: successfulUploads.length,
|
||||
removed: removedCertificates,
|
||||
failedRemovals,
|
||||
skipped: Object.keys(certificateMap).length - setCertificates.length,
|
||||
details: Object.keys(details).length > 0 ? details : undefined
|
||||
};
|
||||
};
|
||||
|
||||
const removeCertificates = async (pkiSync: TPkiSyncWithCredentials, certificateNames: string[]) => {
|
||||
const { accessToken } = await getAzureConnectionAccessToken(pkiSync.connection.id, appConnectionDAL, kmsService);
|
||||
|
||||
// Cast destination config to Azure Key Vault config
|
||||
const destinationConfig = pkiSync.destinationConfig as TAzureKeyVaultPkiSyncConfig;
|
||||
|
||||
// Only remove certificates that are managed by Infisical (match naming schema)
|
||||
const infisicalManagedCertNames = certificateNames.filter((certName) =>
|
||||
isInfisicalManagedCertificate(certName, pkiSync)
|
||||
);
|
||||
|
||||
const results = await executeWithConcurrencyLimit(
|
||||
infisicalManagedCertNames,
|
||||
async (certName) => {
|
||||
try {
|
||||
const response = await request.delete(
|
||||
`${destinationConfig.vaultBaseUrl}/certificates/${encodeURIComponent(certName)}?api-version=7.4`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return { key: certName, success: true, response: response.data as unknown };
|
||||
} catch (error) {
|
||||
if (error instanceof AxiosError) {
|
||||
// If certificate doesn't exist (404), consider it as successfully removed
|
||||
if (error.response?.status === 404) {
|
||||
return { key: certName, success: true, alreadyRemoved: true };
|
||||
}
|
||||
|
||||
throw new PkiSyncError({
|
||||
message: `Failed to remove certificate ${certName} from Azure Key Vault`,
|
||||
cause: error,
|
||||
context: {
|
||||
certificateKey: certName,
|
||||
statusCode: error.response?.status,
|
||||
responseData: error.response?.data
|
||||
}
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{ operation: "remove-specific-certificates", syncId: pkiSync.id }
|
||||
);
|
||||
const failedRemovals = results.filter((result) => result.status === "rejected");
|
||||
|
||||
if (failedRemovals.length > 0) {
|
||||
const failedReasons = failedRemovals.map((failure) => {
|
||||
if (failure.status === "rejected") {
|
||||
return (failure.reason as Error)?.message || "Unknown error";
|
||||
}
|
||||
return "Unknown error";
|
||||
});
|
||||
|
||||
throw new PkiSyncError({
|
||||
message: `Failed to remove ${failedRemovals.length} certificate(s) from Azure Key Vault`,
|
||||
context: {
|
||||
failedReasons,
|
||||
totalCertificates: infisicalManagedCertNames.length,
|
||||
failedCount: failedRemovals.length
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
removed: infisicalManagedCertNames.length - failedRemovals.length,
|
||||
failed: failedRemovals.length,
|
||||
skipped: certificateNames.length - infisicalManagedCertNames.length
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
syncCertificates,
|
||||
removeCertificates
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import RE2 from "re2";
|
||||
import { z } from "zod";
|
||||
|
||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||
import { PkiSync } from "@app/services/pki-sync/pki-sync-enums";
|
||||
import { PkiSyncSchema } from "@app/services/pki-sync/pki-sync-schemas";
|
||||
|
||||
import { AZURE_KEY_VAULT_CERTIFICATE_NAMING } from "./azure-key-vault-pki-sync-constants";
|
||||
|
||||
export const AzureKeyVaultPkiSyncConfigSchema = z.object({
|
||||
vaultBaseUrl: z.string().url()
|
||||
});
|
||||
|
||||
const AzureKeyVaultPkiSyncOptionsSchema = z.object({
|
||||
canImportCertificates: z.boolean().default(false),
|
||||
canRemoveCertificates: z.boolean().default(true),
|
||||
certificateNameSchema: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine(
|
||||
(schema) => {
|
||||
if (!schema) return true;
|
||||
|
||||
const testName = schema
|
||||
.replace(new RE2("\\{\\{certificateId\\}\\}", "g"), "")
|
||||
.replace(new RE2("\\{\\{environment\\}\\}", "g"), "");
|
||||
|
||||
const hasForbiddenChars = AZURE_KEY_VAULT_CERTIFICATE_NAMING.FORBIDDEN_CHARACTERS.split("").some((char) =>
|
||||
testName.includes(char)
|
||||
);
|
||||
|
||||
return AZURE_KEY_VAULT_CERTIFICATE_NAMING.NAME_PATTERN.test(testName) && !hasForbiddenChars;
|
||||
},
|
||||
{
|
||||
message:
|
||||
"Certificate name schema must result in names that contain only alphanumeric characters and hyphens (a-z, A-Z, 0-9, -) and be 1-127 characters long when compiled for Azure Key Vault"
|
||||
}
|
||||
)
|
||||
});
|
||||
|
||||
export const AzureKeyVaultPkiSyncSchema = PkiSyncSchema.extend({
|
||||
destination: z.literal(PkiSync.AzureKeyVault),
|
||||
destinationConfig: AzureKeyVaultPkiSyncConfigSchema,
|
||||
syncOptions: AzureKeyVaultPkiSyncOptionsSchema
|
||||
});
|
||||
|
||||
export const CreateAzureKeyVaultPkiSyncSchema = z.object({
|
||||
name: z.string().trim().min(1).max(64),
|
||||
description: z.string().optional(),
|
||||
isAutoSyncEnabled: z.boolean().default(true),
|
||||
destinationConfig: AzureKeyVaultPkiSyncConfigSchema,
|
||||
syncOptions: AzureKeyVaultPkiSyncOptionsSchema.optional().default({}),
|
||||
subscriberId: z.string().optional(),
|
||||
connectionId: z.string(),
|
||||
projectId: z.string().trim().min(1)
|
||||
});
|
||||
|
||||
export const UpdateAzureKeyVaultPkiSyncSchema = z.object({
|
||||
name: z.string().trim().min(1).max(64).optional(),
|
||||
description: z.string().optional(),
|
||||
isAutoSyncEnabled: z.boolean().optional(),
|
||||
destinationConfig: AzureKeyVaultPkiSyncConfigSchema.optional(),
|
||||
syncOptions: AzureKeyVaultPkiSyncOptionsSchema.optional(),
|
||||
subscriberId: z.string().optional(),
|
||||
connectionId: z.string().optional()
|
||||
});
|
||||
|
||||
export const AzureKeyVaultPkiSyncListItemSchema = z.object({
|
||||
name: z.literal("Azure Key Vault"),
|
||||
connection: z.literal(AppConnection.AzureKeyVault),
|
||||
destination: z.literal(PkiSync.AzureKeyVault),
|
||||
canImportCertificates: z.literal(false),
|
||||
canRemoveCertificates: z.literal(false)
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { TAzureKeyVaultConnection } from "@app/services/app-connection/azure-key-vault";
|
||||
|
||||
import {
|
||||
AzureKeyVaultPkiSyncConfigSchema,
|
||||
AzureKeyVaultPkiSyncSchema,
|
||||
CreateAzureKeyVaultPkiSyncSchema,
|
||||
UpdateAzureKeyVaultPkiSyncSchema
|
||||
} from "./azure-key-vault-pki-sync-schemas";
|
||||
|
||||
export type GetAzureKeyVaultCertificate = {
|
||||
id: string;
|
||||
value: string;
|
||||
attributes: {
|
||||
enabled: boolean;
|
||||
created: number;
|
||||
updated: number;
|
||||
recoveryLevel: string;
|
||||
tags?: Record<string, string>;
|
||||
};
|
||||
x5t?: string;
|
||||
contentType?: string;
|
||||
key?: string;
|
||||
cer?: string;
|
||||
};
|
||||
|
||||
export type TAzureKeyVaultPkiSyncConfig = z.infer<typeof AzureKeyVaultPkiSyncConfigSchema>;
|
||||
|
||||
export type TAzureKeyVaultPkiSync = z.infer<typeof AzureKeyVaultPkiSyncSchema>;
|
||||
|
||||
export type TAzureKeyVaultPkiSyncInput = z.infer<typeof CreateAzureKeyVaultPkiSyncSchema>;
|
||||
|
||||
export type TAzureKeyVaultPkiSyncUpdate = z.infer<typeof UpdateAzureKeyVaultPkiSyncSchema>;
|
||||
|
||||
export type TAzureKeyVaultPkiSyncWithCredentials = TAzureKeyVaultPkiSync & {
|
||||
connection: TAzureKeyVaultConnection;
|
||||
};
|
||||
4
backend/src/services/pki-sync/azure-key-vault/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./azure-key-vault-pki-sync-constants";
|
||||
export * from "./azure-key-vault-pki-sync-fns";
|
||||
export * from "./azure-key-vault-pki-sync-schemas";
|
||||
export * from "./azure-key-vault-pki-sync-types";
|
||||
94
backend/src/services/pki-sync/pki-sync-cleanup-queue.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
|
||||
|
||||
import { TPkiSyncDALFactory } from "./pki-sync-dal";
|
||||
import { TPkiSyncQueueFactory } from "./pki-sync-queue";
|
||||
|
||||
type TPkiSyncCleanupQueueServiceFactoryDep = {
|
||||
queueService: TQueueServiceFactory;
|
||||
pkiSyncDAL: Pick<TPkiSyncDALFactory, "findPkiSyncsWithExpiredCertificates">;
|
||||
pkiSyncQueue: Pick<TPkiSyncQueueFactory, "queuePkiSyncSyncCertificatesById">;
|
||||
};
|
||||
|
||||
export type TPkiSyncCleanupQueueServiceFactory = ReturnType<typeof pkiSyncCleanupQueueServiceFactory>;
|
||||
|
||||
export const pkiSyncCleanupQueueServiceFactory = ({
|
||||
queueService,
|
||||
pkiSyncDAL,
|
||||
pkiSyncQueue
|
||||
}: TPkiSyncCleanupQueueServiceFactoryDep) => {
|
||||
const appCfg = getConfig();
|
||||
|
||||
const syncExpiredCertificatesForPkiSyncs = async () => {
|
||||
try {
|
||||
const pkiSyncsWithExpiredCerts = await pkiSyncDAL.findPkiSyncsWithExpiredCertificates();
|
||||
|
||||
if (pkiSyncsWithExpiredCerts.length === 0) {
|
||||
logger.info("No PKI syncs found with certificates that expired the previous day");
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Found ${pkiSyncsWithExpiredCerts.length} PKI sync(s) with certificates that expired the previous day`
|
||||
);
|
||||
|
||||
// Trigger sync for each PKI sync that has expired certificates
|
||||
for (const { id: syncId, subscriberId } of pkiSyncsWithExpiredCerts) {
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await pkiSyncQueue.queuePkiSyncSyncCertificatesById({
|
||||
syncId
|
||||
});
|
||||
logger.info(
|
||||
`Successfully queued PKI sync ${syncId} for subscriber ${subscriberId} due to expired certificates`
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(error, `Failed to queue PKI sync ${syncId} for subscriber ${subscriberId}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error, "Failed to sync expired certificates for PKI syncs");
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const init = async () => {
|
||||
if (appCfg.isSecondaryInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
await queueService.stopRepeatableJob(
|
||||
QueueName.PkiSyncCleanup,
|
||||
QueueJobs.PkiSyncCleanup,
|
||||
{ pattern: "0 0 * * *", utc: true },
|
||||
QueueName.PkiSyncCleanup // just a job id
|
||||
);
|
||||
|
||||
await queueService.startPg<QueueName.PkiSyncCleanup>(
|
||||
QueueJobs.PkiSyncCleanup,
|
||||
async () => {
|
||||
try {
|
||||
logger.info(`${QueueName.PkiSyncCleanup}: queue task started`);
|
||||
await syncExpiredCertificatesForPkiSyncs();
|
||||
logger.info(`${QueueName.PkiSyncCleanup}: queue task completed`);
|
||||
} catch (error) {
|
||||
logger.error(error, `${QueueName.PkiSyncCleanup}: PKI sync cleanup failed`);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
batchSize: 1,
|
||||
workerCount: 1,
|
||||
pollingIntervalSeconds: 120
|
||||
}
|
||||
);
|
||||
|
||||
await queueService.schedulePg(QueueJobs.PkiSyncCleanup, "0 0 * * *", undefined, { tz: "UTC" });
|
||||
};
|
||||
|
||||
return {
|
||||
init,
|
||||
syncExpiredCertificatesForPkiSyncs
|
||||
};
|
||||
};
|
||||
314
backend/src/services/pki-sync/pki-sync-dal.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName, TPkiSyncs } from "@app/db/schemas";
|
||||
import { DatabaseError } from "@app/lib/errors";
|
||||
import { buildFindFilter, ormify, prependTableNameToFindFilter, selectAllTableCols } from "@app/lib/knex";
|
||||
|
||||
import { PkiSync } from "./pki-sync-enums";
|
||||
|
||||
export type TPkiSyncDALFactory = ReturnType<typeof pkiSyncDALFactory>;
|
||||
|
||||
type PkiSyncFindFilter = Parameters<typeof buildFindFilter<TPkiSyncs>>[0];
|
||||
|
||||
const basePkiSyncQuery = ({ filter, db, tx }: { db: TDbClient; filter?: PkiSyncFindFilter; tx?: Knex }) => {
|
||||
const query = (tx || db.replicaNode())(TableName.PkiSync)
|
||||
.leftJoin(TableName.AppConnection, `${TableName.PkiSync}.connectionId`, `${TableName.AppConnection}.id`)
|
||||
.select(selectAllTableCols(TableName.PkiSync))
|
||||
.select(
|
||||
// app connection fields
|
||||
db.ref("name").withSchema(TableName.AppConnection).as("appConnectionName"),
|
||||
db.ref("app").withSchema(TableName.AppConnection).as("appConnectionApp"),
|
||||
db.ref("encryptedCredentials").withSchema(TableName.AppConnection).as("appConnectionEncryptedCredentials"),
|
||||
db.ref("orgId").withSchema(TableName.AppConnection).as("appConnectionOrgId"),
|
||||
db.ref("projectId").withSchema(TableName.AppConnection).as("appConnectionProjectId"),
|
||||
db.ref("method").withSchema(TableName.AppConnection).as("appConnectionMethod"),
|
||||
db.ref("description").withSchema(TableName.AppConnection).as("appConnectionDescription"),
|
||||
db.ref("version").withSchema(TableName.AppConnection).as("appConnectionVersion"),
|
||||
db.ref("gatewayId").withSchema(TableName.AppConnection).as("appConnectionGatewayId"),
|
||||
db.ref("createdAt").withSchema(TableName.AppConnection).as("appConnectionCreatedAt"),
|
||||
db.ref("updatedAt").withSchema(TableName.AppConnection).as("appConnectionUpdatedAt"),
|
||||
db
|
||||
.ref("isPlatformManagedCredentials")
|
||||
.withSchema(TableName.AppConnection)
|
||||
.as("appConnectionIsPlatformManagedCredentials")
|
||||
);
|
||||
|
||||
if (filter) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
void query.where(buildFindFilter(prependTableNameToFindFilter(TableName.PkiSync, filter)));
|
||||
}
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
const basePkiSyncWithSubscriberQuery = ({
|
||||
filter,
|
||||
db,
|
||||
tx
|
||||
}: {
|
||||
db: TDbClient;
|
||||
filter?: PkiSyncFindFilter;
|
||||
tx?: Knex;
|
||||
}) => {
|
||||
const query = (tx || db.replicaNode())(TableName.PkiSync)
|
||||
.leftJoin(TableName.AppConnection, `${TableName.PkiSync}.connectionId`, `${TableName.AppConnection}.id`)
|
||||
.leftJoin(TableName.PkiSubscriber, `${TableName.PkiSync}.subscriberId`, `${TableName.PkiSubscriber}.id`)
|
||||
.select(selectAllTableCols(TableName.PkiSync))
|
||||
.select(
|
||||
// app connection fields
|
||||
db.ref("name").withSchema(TableName.AppConnection).as("appConnectionName"),
|
||||
db.ref("app").withSchema(TableName.AppConnection).as("appConnectionApp"),
|
||||
db.ref("encryptedCredentials").withSchema(TableName.AppConnection).as("appConnectionEncryptedCredentials"),
|
||||
db.ref("orgId").withSchema(TableName.AppConnection).as("appConnectionOrgId"),
|
||||
db.ref("projectId").withSchema(TableName.AppConnection).as("appConnectionProjectId"),
|
||||
db.ref("method").withSchema(TableName.AppConnection).as("appConnectionMethod"),
|
||||
db.ref("description").withSchema(TableName.AppConnection).as("appConnectionDescription"),
|
||||
db.ref("version").withSchema(TableName.AppConnection).as("appConnectionVersion"),
|
||||
db.ref("gatewayId").withSchema(TableName.AppConnection).as("appConnectionGatewayId"),
|
||||
db.ref("createdAt").withSchema(TableName.AppConnection).as("appConnectionCreatedAt"),
|
||||
db.ref("updatedAt").withSchema(TableName.AppConnection).as("appConnectionUpdatedAt"),
|
||||
db
|
||||
.ref("isPlatformManagedCredentials")
|
||||
.withSchema(TableName.AppConnection)
|
||||
.as("appConnectionIsPlatformManagedCredentials"),
|
||||
// pki subscriber fields
|
||||
db.ref("id").withSchema(TableName.PkiSubscriber).as("pkiSubscriberId"),
|
||||
db.ref("name").withSchema(TableName.PkiSubscriber).as("subscriberName")
|
||||
);
|
||||
|
||||
if (filter) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
void query.where(buildFindFilter(prependTableNameToFindFilter(TableName.PkiSync, filter)));
|
||||
}
|
||||
|
||||
return query;
|
||||
};
|
||||
|
||||
const expandPkiSync = (pkiSync: Awaited<ReturnType<typeof basePkiSyncQuery>>[number]) => {
|
||||
const {
|
||||
appConnectionName,
|
||||
appConnectionApp,
|
||||
appConnectionEncryptedCredentials,
|
||||
appConnectionOrgId,
|
||||
appConnectionProjectId,
|
||||
appConnectionMethod,
|
||||
appConnectionDescription,
|
||||
appConnectionVersion,
|
||||
appConnectionGatewayId,
|
||||
appConnectionCreatedAt,
|
||||
appConnectionUpdatedAt,
|
||||
appConnectionIsPlatformManagedCredentials,
|
||||
...el
|
||||
} = pkiSync;
|
||||
|
||||
return {
|
||||
...el,
|
||||
destination: el.destination as PkiSync,
|
||||
destinationConfig: el.destinationConfig as Record<string, unknown>,
|
||||
syncOptions: el.syncOptions as Record<string, unknown>,
|
||||
appConnectionName,
|
||||
appConnectionApp,
|
||||
connection: {
|
||||
id: el.connectionId,
|
||||
name: appConnectionName,
|
||||
app: appConnectionApp,
|
||||
encryptedCredentials: appConnectionEncryptedCredentials,
|
||||
orgId: appConnectionOrgId,
|
||||
projectId: appConnectionProjectId,
|
||||
method: appConnectionMethod,
|
||||
description: appConnectionDescription,
|
||||
version: appConnectionVersion,
|
||||
gatewayId: appConnectionGatewayId,
|
||||
createdAt: appConnectionCreatedAt,
|
||||
updatedAt: appConnectionUpdatedAt,
|
||||
isPlatformManagedCredentials: appConnectionIsPlatformManagedCredentials
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const expandPkiSyncWithSubscriber = (pkiSync: Awaited<ReturnType<typeof basePkiSyncWithSubscriberQuery>>[number]) => {
|
||||
const {
|
||||
appConnectionName,
|
||||
appConnectionApp,
|
||||
appConnectionEncryptedCredentials,
|
||||
appConnectionOrgId,
|
||||
appConnectionProjectId,
|
||||
appConnectionMethod,
|
||||
appConnectionDescription,
|
||||
appConnectionVersion,
|
||||
appConnectionGatewayId,
|
||||
appConnectionCreatedAt,
|
||||
appConnectionUpdatedAt,
|
||||
appConnectionIsPlatformManagedCredentials,
|
||||
pkiSubscriberId,
|
||||
subscriberName,
|
||||
...el
|
||||
} = pkiSync;
|
||||
|
||||
return {
|
||||
...el,
|
||||
destination: el.destination as PkiSync,
|
||||
destinationConfig: el.destinationConfig as Record<string, unknown>,
|
||||
syncOptions: el.syncOptions as Record<string, unknown>,
|
||||
appConnectionName,
|
||||
appConnectionApp,
|
||||
connection: {
|
||||
id: el.connectionId,
|
||||
name: appConnectionName,
|
||||
app: appConnectionApp,
|
||||
encryptedCredentials: appConnectionEncryptedCredentials,
|
||||
orgId: appConnectionOrgId,
|
||||
projectId: appConnectionProjectId,
|
||||
method: appConnectionMethod,
|
||||
description: appConnectionDescription,
|
||||
version: appConnectionVersion,
|
||||
gatewayId: appConnectionGatewayId,
|
||||
createdAt: appConnectionCreatedAt,
|
||||
updatedAt: appConnectionUpdatedAt,
|
||||
isPlatformManagedCredentials: appConnectionIsPlatformManagedCredentials
|
||||
},
|
||||
subscriber: pkiSubscriberId && subscriberName ? { id: pkiSubscriberId, name: subscriberName } : null
|
||||
};
|
||||
};
|
||||
|
||||
export const pkiSyncDALFactory = (db: TDbClient) => {
|
||||
const pkiSyncOrm = ormify(db, TableName.PkiSync);
|
||||
|
||||
const findByProjectId = async (projectId: string, tx?: Knex) => {
|
||||
try {
|
||||
const pkiSyncs = await basePkiSyncQuery({ filter: { projectId }, db, tx });
|
||||
return pkiSyncs.map(expandPkiSync);
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Find By Project ID - PKI Sync" });
|
||||
}
|
||||
};
|
||||
|
||||
const findByProjectIdWithSubscribers = async (projectId: string, tx?: Knex) => {
|
||||
try {
|
||||
const pkiSyncs = await basePkiSyncWithSubscriberQuery({ filter: { projectId }, db, tx });
|
||||
return pkiSyncs.map(expandPkiSyncWithSubscriber);
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Find By Project ID With Subscribers - PKI Sync" });
|
||||
}
|
||||
};
|
||||
|
||||
const findBySubscriberId = async (subscriberId: string, tx?: Knex) => {
|
||||
try {
|
||||
const pkiSyncs = await basePkiSyncQuery({ filter: { subscriberId }, db, tx });
|
||||
return pkiSyncs.map(expandPkiSync);
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Find By Subscriber ID - PKI Sync" });
|
||||
}
|
||||
};
|
||||
|
||||
const findByIdAndProjectId = async (id: string, projectId: string, tx?: Knex) => {
|
||||
try {
|
||||
const pkiSync = await basePkiSyncQuery({ filter: { id, projectId }, db, tx }).first();
|
||||
return pkiSync ? expandPkiSync(pkiSync) : undefined;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Find By ID and Project ID - PKI Sync" });
|
||||
}
|
||||
};
|
||||
|
||||
const findByNameAndProjectId = async (name: string, projectId: string, tx?: Knex) => {
|
||||
try {
|
||||
const pkiSync = await basePkiSyncQuery({ filter: { name, projectId }, db, tx }).first();
|
||||
return pkiSync ? expandPkiSync(pkiSync) : undefined;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Find By Name and Project ID - PKI Sync" });
|
||||
}
|
||||
};
|
||||
|
||||
const findById = async (id: string, tx?: Knex) => {
|
||||
try {
|
||||
const pkiSync = await basePkiSyncQuery({ filter: { id }, db, tx }).first();
|
||||
return pkiSync ? expandPkiSync(pkiSync) : undefined;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Find By ID - PKI Sync" });
|
||||
}
|
||||
};
|
||||
|
||||
const findOne = async (filter: Parameters<(typeof pkiSyncOrm)["findOne"]>[0], tx?: Knex) => {
|
||||
try {
|
||||
const pkiSync = await basePkiSyncQuery({ filter, db, tx }).first();
|
||||
return pkiSync ? expandPkiSync(pkiSync) : undefined;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Find One - PKI Sync" });
|
||||
}
|
||||
};
|
||||
|
||||
const find = async (filter: Parameters<(typeof pkiSyncOrm)["find"]>[0], tx?: Knex) => {
|
||||
try {
|
||||
const pkiSyncs = await basePkiSyncQuery({ filter, db, tx });
|
||||
return pkiSyncs.map(expandPkiSync);
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Find - PKI Sync" });
|
||||
}
|
||||
};
|
||||
|
||||
const create = async (data: Parameters<(typeof pkiSyncOrm)["create"]>[0]) => {
|
||||
const pkiSync = (await pkiSyncOrm.transaction(async (tx) => {
|
||||
const sync = await pkiSyncOrm.create(data, tx);
|
||||
return basePkiSyncQuery({ filter: { id: sync.id }, db, tx }).first();
|
||||
}))!;
|
||||
|
||||
return expandPkiSync(pkiSync);
|
||||
};
|
||||
|
||||
const updateById = async (syncId: string, data: Parameters<(typeof pkiSyncOrm)["updateById"]>[1]) => {
|
||||
const pkiSync = (await pkiSyncOrm.transaction(async (tx) => {
|
||||
const sync = await pkiSyncOrm.updateById(syncId, data, tx);
|
||||
return basePkiSyncQuery({ filter: { id: sync.id }, db, tx }).first();
|
||||
}))!;
|
||||
|
||||
return expandPkiSync(pkiSync);
|
||||
};
|
||||
|
||||
const findPkiSyncsWithExpiredCertificates = async (): Promise<Array<{ id: string; subscriberId: string }>> => {
|
||||
try {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
yesterday.setHours(0, 0, 0, 0);
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const pkiSyncs = (await db
|
||||
.replicaNode()(TableName.PkiSync)
|
||||
.select(`${TableName.PkiSync}.id`, `${TableName.PkiSync}.subscriberId`)
|
||||
.innerJoin(
|
||||
TableName.Certificate,
|
||||
`${TableName.PkiSync}.subscriberId`,
|
||||
`${TableName.Certificate}.pkiSubscriberId`
|
||||
)
|
||||
.where(`${TableName.Certificate}.notAfter`, ">=", yesterday)
|
||||
.where(`${TableName.Certificate}.notAfter`, "<", today)
|
||||
.whereNotNull(`${TableName.Certificate}.pkiSubscriberId`)
|
||||
.whereNotNull(`${TableName.PkiSync}.subscriberId`)
|
||||
.groupBy(`${TableName.PkiSync}.id`, `${TableName.PkiSync}.subscriberId`)) as Array<{
|
||||
id: string;
|
||||
subscriberId: string;
|
||||
}>;
|
||||
|
||||
return pkiSyncs;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Find PKI syncs with expired certificates" });
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
...pkiSyncOrm,
|
||||
findByProjectId,
|
||||
findByProjectIdWithSubscribers,
|
||||
findBySubscriberId,
|
||||
findByIdAndProjectId,
|
||||
findByNameAndProjectId,
|
||||
findById,
|
||||
findOne,
|
||||
find,
|
||||
create,
|
||||
updateById,
|
||||
findPkiSyncsWithExpiredCertificates
|
||||
};
|
||||
};
|
||||
16
backend/src/services/pki-sync/pki-sync-enums.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export enum PkiSync {
|
||||
AzureKeyVault = "azure-key-vault"
|
||||
}
|
||||
|
||||
export enum PkiSyncStatus {
|
||||
Pending = "pending",
|
||||
Running = "running",
|
||||
Succeeded = "succeeded",
|
||||
Failed = "failed"
|
||||
}
|
||||
|
||||
export enum PkiSyncAction {
|
||||
SyncCertificates = "sync-certificates",
|
||||
ImportCertificates = "import-certificates",
|
||||
RemoveCertificates = "remove-certificates"
|
||||
}
|
||||
25
backend/src/services/pki-sync/pki-sync-errors.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export class PkiSyncError extends Error {
|
||||
public context?: Record<string, unknown>;
|
||||
|
||||
public cause?: Error;
|
||||
|
||||
public shouldRetry: boolean;
|
||||
|
||||
constructor({
|
||||
message,
|
||||
cause,
|
||||
context,
|
||||
shouldRetry = true
|
||||
}: {
|
||||
message: string;
|
||||
cause?: Error;
|
||||
context?: Record<string, unknown>;
|
||||
shouldRetry?: boolean;
|
||||
}) {
|
||||
super(message);
|
||||
this.name = "PkiSyncError";
|
||||
this.cause = cause;
|
||||
this.context = context;
|
||||
this.shouldRetry = shouldRetry;
|
||||
}
|
||||
}
|
||||
223
backend/src/services/pki-sync/pki-sync-fns.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import * as handlebars from "handlebars";
|
||||
import { z, ZodSchema } from "zod";
|
||||
|
||||
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
|
||||
import { AZURE_KEY_VAULT_PKI_SYNC_LIST_OPTION } from "./azure-key-vault/azure-key-vault-pki-sync-constants";
|
||||
import { azureKeyVaultPkiSyncFactory } from "./azure-key-vault/azure-key-vault-pki-sync-fns";
|
||||
import { PkiSync } from "./pki-sync-enums";
|
||||
import { TCertificateMap, TPkiSyncWithCredentials } from "./pki-sync-types";
|
||||
|
||||
const ENTERPRISE_PKI_SYNCS: PkiSync[] = [];
|
||||
|
||||
const PKI_SYNC_LIST_OPTIONS = {
|
||||
[PkiSync.AzureKeyVault]: AZURE_KEY_VAULT_PKI_SYNC_LIST_OPTION
|
||||
};
|
||||
|
||||
export const enterprisePkiSyncCheck = async (
|
||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">,
|
||||
orgId: string,
|
||||
pkiSyncDestination: PkiSync,
|
||||
errorMessage?: string
|
||||
) => {
|
||||
const plan = await licenseService.getPlan(orgId);
|
||||
|
||||
if (!plan.enterpriseCertificateSyncs && ENTERPRISE_PKI_SYNCS.includes(pkiSyncDestination)) {
|
||||
throw new BadRequestError({
|
||||
message: errorMessage || "Failed to create PKI sync due to plan restriction. Upgrade plan to create PKI sync."
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const listPkiSyncOptions = () => {
|
||||
return Object.values(PKI_SYNC_LIST_OPTIONS).sort((a, b) => a.name.localeCompare(b.name));
|
||||
};
|
||||
|
||||
export const getPkiSyncProviderCapabilities = (destination: PkiSync) => {
|
||||
const providerOption = PKI_SYNC_LIST_OPTIONS[destination];
|
||||
if (!providerOption) {
|
||||
throw new BadRequestError({ message: `Unsupported PKI sync destination: ${destination}` });
|
||||
}
|
||||
|
||||
return {
|
||||
canImportCertificates: providerOption.canImportCertificates,
|
||||
canRemoveCertificates: providerOption.canRemoveCertificates
|
||||
};
|
||||
};
|
||||
|
||||
export const matchesSchema = <T extends ZodSchema>(schema: T, data: unknown): data is z.infer<T> => {
|
||||
return schema.safeParse(data).success;
|
||||
};
|
||||
|
||||
export const parsePkiSyncErrorMessage = (error: unknown): string => {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
|
||||
return "An unknown error occurred during PKI sync operation";
|
||||
};
|
||||
|
||||
export const applyCertificateNameSchema = (
|
||||
certificateMap: TCertificateMap,
|
||||
environment: string,
|
||||
schema?: string
|
||||
): TCertificateMap => {
|
||||
if (!schema) return certificateMap;
|
||||
|
||||
const processedCertificateMap: TCertificateMap = {};
|
||||
|
||||
for (const [certificateId, value] of Object.entries(certificateMap)) {
|
||||
const newName = handlebars.compile(schema)({
|
||||
certificateId,
|
||||
environment
|
||||
});
|
||||
|
||||
processedCertificateMap[newName] = value;
|
||||
}
|
||||
|
||||
return processedCertificateMap;
|
||||
};
|
||||
|
||||
export const stripCertificateNameSchema = (
|
||||
certificateMap: TCertificateMap,
|
||||
environment: string,
|
||||
schema?: string
|
||||
): TCertificateMap => {
|
||||
if (!schema) return certificateMap;
|
||||
|
||||
const compiledSchemaPattern = handlebars.compile(schema)({
|
||||
certificateId: "{{certificateId}}",
|
||||
environment
|
||||
});
|
||||
|
||||
const parts = compiledSchemaPattern.split("{{certificateId}}");
|
||||
const prefix = parts[0];
|
||||
const suffix = parts[parts.length - 1];
|
||||
|
||||
const strippedMap: TCertificateMap = {};
|
||||
|
||||
for (const [name, value] of Object.entries(certificateMap)) {
|
||||
if (!name.startsWith(prefix) || !name.endsWith(suffix)) {
|
||||
// eslint-disable-next-line no-continue
|
||||
continue;
|
||||
}
|
||||
|
||||
const strippedName = name.slice(prefix.length, name.length - suffix.length);
|
||||
strippedMap[strippedName] = value;
|
||||
}
|
||||
|
||||
return strippedMap;
|
||||
};
|
||||
|
||||
export const matchesCertificateNameSchema = (name: string, environment: string, schema?: string): boolean => {
|
||||
if (!schema) return true;
|
||||
|
||||
const compiledSchemaPattern = handlebars.compile(schema)({
|
||||
certificateId: "{{certificateId}}",
|
||||
environment
|
||||
});
|
||||
|
||||
if (!compiledSchemaPattern.includes("{{certificateId}}")) {
|
||||
return name === compiledSchemaPattern;
|
||||
}
|
||||
|
||||
const parts = compiledSchemaPattern.split("{{certificateId}}");
|
||||
const prefix = parts[0];
|
||||
const suffix = parts[parts.length - 1];
|
||||
|
||||
if (prefix === "" && suffix === "") return true;
|
||||
|
||||
// If prefix is empty, name must end with suffix
|
||||
if (prefix === "") return name.endsWith(suffix);
|
||||
|
||||
// If suffix is empty, name must start with prefix
|
||||
if (suffix === "") return name.startsWith(prefix);
|
||||
|
||||
// Name must start with prefix and end with suffix
|
||||
return name.startsWith(prefix) && name.endsWith(suffix);
|
||||
};
|
||||
|
||||
const isAzureKeyVaultPkiSync = (pkiSync: TPkiSyncWithCredentials): boolean => {
|
||||
return pkiSync.destination === PkiSync.AzureKeyVault;
|
||||
};
|
||||
|
||||
export const PkiSyncFns = {
|
||||
getCertificates: async (
|
||||
pkiSync: TPkiSyncWithCredentials,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
dependencies: {
|
||||
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "updateById">;
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
||||
}
|
||||
): Promise<TCertificateMap> => {
|
||||
switch (pkiSync.destination) {
|
||||
case PkiSync.AzureKeyVault: {
|
||||
throw new Error(
|
||||
"Azure Key Vault does not support importing certificates into Infisical (private keys cannot be extracted)"
|
||||
);
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unsupported PKI sync destination: ${String(pkiSync.destination)}`);
|
||||
}
|
||||
},
|
||||
|
||||
syncCertificates: async (
|
||||
pkiSync: TPkiSyncWithCredentials,
|
||||
certificateMap: TCertificateMap,
|
||||
dependencies: {
|
||||
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "updateById">;
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
||||
}
|
||||
): Promise<{
|
||||
uploaded: number;
|
||||
removed?: number;
|
||||
failedRemovals?: number;
|
||||
skipped: number;
|
||||
details?: {
|
||||
failedUploads?: Array<{ name: string; error: string }>;
|
||||
failedRemovals?: Array<{ name: string; error: string }>;
|
||||
skippedCertificates?: Array<{ name: string; reason: string }>;
|
||||
};
|
||||
}> => {
|
||||
switch (pkiSync.destination) {
|
||||
case PkiSync.AzureKeyVault: {
|
||||
if (!isAzureKeyVaultPkiSync(pkiSync)) {
|
||||
throw new Error("Invalid Azure Key Vault PKI sync configuration");
|
||||
}
|
||||
const azureKeyVaultPkiSync = azureKeyVaultPkiSyncFactory(dependencies);
|
||||
return azureKeyVaultPkiSync.syncCertificates(pkiSync, certificateMap);
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unsupported PKI sync destination: ${String(pkiSync.destination)}`);
|
||||
}
|
||||
},
|
||||
|
||||
removeCertificates: async (
|
||||
pkiSync: TPkiSyncWithCredentials,
|
||||
certificateNames: string[],
|
||||
dependencies: {
|
||||
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "updateById">;
|
||||
kmsService: Pick<TKmsServiceFactory, "createCipherPairWithDataKey">;
|
||||
}
|
||||
): Promise<void> => {
|
||||
switch (pkiSync.destination) {
|
||||
case PkiSync.AzureKeyVault: {
|
||||
if (!isAzureKeyVaultPkiSync(pkiSync)) {
|
||||
throw new Error("Invalid Azure Key Vault PKI sync configuration");
|
||||
}
|
||||
const azureKeyVaultPkiSync = azureKeyVaultPkiSyncFactory(dependencies);
|
||||
await azureKeyVaultPkiSync.removeCertificates(pkiSync, certificateNames);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unsupported PKI sync destination: ${String(pkiSync.destination)}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
11
backend/src/services/pki-sync/pki-sync-maps.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||
|
||||
import { PkiSync } from "./pki-sync-enums";
|
||||
|
||||
export const PKI_SYNC_NAME_MAP: Record<PkiSync, string> = {
|
||||
[PkiSync.AzureKeyVault]: "Azure Key Vault"
|
||||
};
|
||||
|
||||
export const PKI_SYNC_CONNECTION_MAP: Record<PkiSync, AppConnection> = {
|
||||
[PkiSync.AzureKeyVault]: AppConnection.AzureKeyVault
|
||||
};
|
||||
753
backend/src/services/pki-sync/pki-sync-queue.ts
Normal file
@@ -0,0 +1,753 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
import opentelemetry from "@opentelemetry/api";
|
||||
import * as x509 from "@peculiar/x509";
|
||||
import { AxiosError } from "axios";
|
||||
import { Job } from "bullmq";
|
||||
import handlebars from "handlebars";
|
||||
|
||||
import { EventType, TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
||||
import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
|
||||
import { decryptAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns";
|
||||
import { ActorType } from "@app/services/auth/auth-type";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||
import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns";
|
||||
|
||||
import { TAppConnectionDALFactory } from "../app-connection/app-connection-dal";
|
||||
import { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal";
|
||||
import { TCertificateDALFactory } from "../certificate/certificate-dal";
|
||||
import { getCertificateCredentials } from "../certificate/certificate-fns";
|
||||
import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal";
|
||||
import { TCertificateAuthorityCertDALFactory } from "../certificate-authority/certificate-authority-cert-dal";
|
||||
import { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal";
|
||||
import { getCaCertChain } from "../certificate-authority/certificate-authority-fns";
|
||||
import { TPkiSyncDALFactory } from "./pki-sync-dal";
|
||||
import { PkiSyncStatus } from "./pki-sync-enums";
|
||||
import { PkiSyncError } from "./pki-sync-errors";
|
||||
import { enterprisePkiSyncCheck, parsePkiSyncErrorMessage, PkiSyncFns } from "./pki-sync-fns";
|
||||
import {
|
||||
TCertificateMap,
|
||||
TPkiSyncImportCertificatesDTO,
|
||||
TPkiSyncRaw,
|
||||
TPkiSyncRemoveCertificatesDTO,
|
||||
TPkiSyncSyncCertificatesDTO,
|
||||
TPkiSyncWithCredentials,
|
||||
TQueuePkiSyncImportCertificatesByIdDTO,
|
||||
TQueuePkiSyncRemoveCertificatesByIdDTO,
|
||||
TQueuePkiSyncSyncCertificatesByIdDTO
|
||||
} from "./pki-sync-types";
|
||||
|
||||
export type TPkiSyncQueueFactory = ReturnType<typeof pkiSyncQueueFactory>;
|
||||
|
||||
type TPkiSyncQueueFactoryDep = {
|
||||
queueService: Pick<TQueueServiceFactory, "queue" | "start">;
|
||||
kmsService: Pick<
|
||||
TKmsServiceFactory,
|
||||
"createCipherPairWithDataKey" | "decryptWithKmsKey" | "generateKmsKey" | "encryptWithKmsKey"
|
||||
>;
|
||||
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "update" | "updateById">;
|
||||
keyStore: Pick<TKeyStoreFactory, "acquireLock" | "setItemWithExpiry" | "getItem">;
|
||||
pkiSyncDAL: Pick<TPkiSyncDALFactory, "findById" | "find" | "updateById" | "deleteById" | "update">;
|
||||
auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">;
|
||||
projectDAL: TProjectDALFactory;
|
||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||
certificateDAL: Pick<
|
||||
TCertificateDALFactory,
|
||||
"findLatestActiveCertForSubscriber" | "findAllActiveCertsForSubscriber" | "create"
|
||||
>;
|
||||
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "findOne" | "create">;
|
||||
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "findOne" | "create">;
|
||||
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findById">;
|
||||
certificateAuthorityCertDAL: Pick<TCertificateAuthorityCertDALFactory, "findById">;
|
||||
};
|
||||
|
||||
type PkiSyncActionJob = Job<
|
||||
TQueuePkiSyncSyncCertificatesByIdDTO | TQueuePkiSyncImportCertificatesByIdDTO | TQueuePkiSyncRemoveCertificatesByIdDTO
|
||||
>;
|
||||
|
||||
const JITTER_MS = 10 * 1000;
|
||||
const REQUEUE_MS = 30 * 1000;
|
||||
const REQUEUE_LIMIT = 30;
|
||||
const CONNECTION_CONCURRENCY_LIMIT = 3;
|
||||
|
||||
const getRequeueDelay = (failureCount?: number) => {
|
||||
const jitter = Math.random() * JITTER_MS;
|
||||
if (!failureCount) return jitter;
|
||||
return REQUEUE_MS + jitter;
|
||||
};
|
||||
|
||||
export const pkiSyncQueueFactory = ({
|
||||
queueService,
|
||||
kmsService,
|
||||
appConnectionDAL,
|
||||
keyStore,
|
||||
pkiSyncDAL,
|
||||
auditLogService,
|
||||
projectDAL,
|
||||
licenseService,
|
||||
certificateDAL,
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL,
|
||||
certificateAuthorityDAL,
|
||||
certificateAuthorityCertDAL
|
||||
}: TPkiSyncQueueFactoryDep) => {
|
||||
const appCfg = getConfig();
|
||||
|
||||
const integrationMeter = opentelemetry.metrics.getMeter("PkiSyncs");
|
||||
const syncCertificatesErrorHistogram = integrationMeter.createHistogram("pki_sync_sync_certificates_errors", {
|
||||
description: "PKI Sync - sync certificates errors",
|
||||
unit: "1"
|
||||
});
|
||||
const importCertificatesErrorHistogram = integrationMeter.createHistogram("pki_sync_import_certificates_errors", {
|
||||
description: "PKI Sync - import certificates errors",
|
||||
unit: "1"
|
||||
});
|
||||
const removeCertificatesErrorHistogram = integrationMeter.createHistogram("pki_sync_remove_certificates_errors", {
|
||||
description: "PKI Sync - remove certificates errors",
|
||||
unit: "1"
|
||||
});
|
||||
|
||||
const $isConnectionConcurrencyLimitReached = async (connectionId: string) => {
|
||||
const concurrencyCount = await keyStore.getItem(KeyStorePrefixes.AppConnectionConcurrentJobs(connectionId));
|
||||
|
||||
if (!concurrencyCount) return false;
|
||||
|
||||
const count = Number.parseInt(concurrencyCount, 10);
|
||||
|
||||
if (Number.isNaN(count)) return false;
|
||||
|
||||
return count >= CONNECTION_CONCURRENCY_LIMIT;
|
||||
};
|
||||
|
||||
const $incrementConnectionConcurrencyCount = async (connectionId: string) => {
|
||||
const concurrencyCount = await keyStore.getItem(KeyStorePrefixes.AppConnectionConcurrentJobs(connectionId));
|
||||
|
||||
const currentCount = Number.parseInt(concurrencyCount || "0", 10);
|
||||
|
||||
const incrementedCount = Number.isNaN(currentCount) ? 1 : currentCount + 1;
|
||||
|
||||
await keyStore.setItemWithExpiry(
|
||||
KeyStorePrefixes.AppConnectionConcurrentJobs(connectionId),
|
||||
(REQUEUE_MS * REQUEUE_LIMIT) / 1000, // in seconds
|
||||
incrementedCount
|
||||
);
|
||||
};
|
||||
|
||||
const $decrementConnectionConcurrencyCount = async (connectionId: string) => {
|
||||
const concurrencyCount = await keyStore.getItem(KeyStorePrefixes.AppConnectionConcurrentJobs(connectionId));
|
||||
|
||||
const currentCount = Number.parseInt(concurrencyCount || "0", 10);
|
||||
|
||||
const decrementedCount = Math.max(0, Number.isNaN(currentCount) ? 0 : currentCount - 1);
|
||||
|
||||
await keyStore.setItemWithExpiry(
|
||||
KeyStorePrefixes.AppConnectionConcurrentJobs(connectionId),
|
||||
(REQUEUE_MS * REQUEUE_LIMIT) / 1000, // in seconds
|
||||
decrementedCount
|
||||
);
|
||||
};
|
||||
|
||||
const $getInfisicalCertificates = async (
|
||||
pkiSync: TPkiSyncRaw | TPkiSyncWithCredentials
|
||||
): Promise<TCertificateMap> => {
|
||||
const { projectId, subscriberId } = 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 = {};
|
||||
|
||||
try {
|
||||
// Get all active certificates for the subscriber (not just the latest)
|
||||
const certificates = await certificateDAL.findAllActiveCertsForSubscriber({
|
||||
subscriberId
|
||||
});
|
||||
|
||||
for (const certificate of certificates) {
|
||||
try {
|
||||
// Get the certificate body and decrypt the certificate data
|
||||
const certBody = await certificateBodyDAL.findOne({ certId: certificate.id });
|
||||
|
||||
if (certBody) {
|
||||
const certificateManagerKeyId = await getProjectKmsCertificateKeyId({
|
||||
projectId: certificate.projectId,
|
||||
projectDAL,
|
||||
kmsService
|
||||
});
|
||||
|
||||
const kmsDecryptor = await kmsService.decryptWithKmsKey({
|
||||
kmsId: certificateManagerKeyId
|
||||
});
|
||||
|
||||
const decryptedCert = await kmsDecryptor({
|
||||
cipherTextBlob: certBody.encryptedCertificate
|
||||
});
|
||||
|
||||
const certObj = new x509.X509Certificate(decryptedCert);
|
||||
const certificatePem = certObj.toString("pem");
|
||||
|
||||
// Get private key using getCertificateCredentials - handle cases where private key doesn't exist
|
||||
let certPrivateKey: string | undefined;
|
||||
try {
|
||||
const credentials = await getCertificateCredentials({
|
||||
certId: certificate.id,
|
||||
projectId: certificate.projectId,
|
||||
certificateSecretDAL,
|
||||
projectDAL,
|
||||
kmsService
|
||||
});
|
||||
certPrivateKey = credentials.certPrivateKey;
|
||||
} catch (credError) {
|
||||
logger.warn(
|
||||
{ certificateId: certificate.id, subscriberId, error: credError },
|
||||
"Certificate private key not found - certificate may be imported or key was not stored"
|
||||
);
|
||||
// Continue without private key - some providers may only need the certificate
|
||||
certPrivateKey = undefined;
|
||||
}
|
||||
|
||||
let certificateChain: string | undefined;
|
||||
try {
|
||||
if (certBody.encryptedCertificateChain) {
|
||||
const decryptedCertChain = await kmsDecryptor({
|
||||
cipherTextBlob: certBody.encryptedCertificateChain
|
||||
});
|
||||
certificateChain = decryptedCertChain.toString();
|
||||
} else if (certificate.caCertId) {
|
||||
const { caCert, caCertChain } = await getCaCertChain({
|
||||
caCertId: certificate.caCertId,
|
||||
certificateAuthorityDAL,
|
||||
certificateAuthorityCertDAL,
|
||||
projectDAL,
|
||||
kmsService
|
||||
});
|
||||
certificateChain = `${caCert}\n${caCertChain}`.trim();
|
||||
}
|
||||
} catch (chainError) {
|
||||
logger.warn(
|
||||
{ certificateId: certificate.id, subscriberId, error: chainError },
|
||||
"Certificate chain not found or could not be decrypted - certificate may be imported or chain was not stored"
|
||||
);
|
||||
// Continue without certificate chain
|
||||
certificateChain = undefined;
|
||||
}
|
||||
|
||||
let certificateName: string;
|
||||
const syncOptions = pkiSync.syncOptions as { certificateNameSchema?: string } | undefined;
|
||||
const certificateNameSchema = syncOptions?.certificateNameSchema;
|
||||
|
||||
if (certificateNameSchema) {
|
||||
const environment = "global";
|
||||
certificateName = handlebars.compile(certificateNameSchema)({
|
||||
certificateId: certificate.id.replace(/-/g, ""),
|
||||
environment
|
||||
});
|
||||
} else {
|
||||
certificateName = `Infisical-${certificate.id.replace(/-/g, "")}`;
|
||||
}
|
||||
|
||||
certificateMap[certificateName] = {
|
||||
cert: certificatePem,
|
||||
privateKey: certPrivateKey || "",
|
||||
certificateChain
|
||||
};
|
||||
} else {
|
||||
logger.warn({ certificateId: certificate.id, subscriberId }, "Certificate body not found for certificate");
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ error, subscriberId, certificateId: certificate.id },
|
||||
"Failed to decrypt certificate for PKI sync"
|
||||
);
|
||||
// Continue with other certificates
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
error,
|
||||
`Failed to fetch certificate for subscriber [subscriberId=${subscriberId}] [projectId=${projectId}]`
|
||||
);
|
||||
throw new PkiSyncError({
|
||||
message: `Failed to fetch certificate for PKI subscriber: ${error instanceof Error ? error.message : String(error)}`,
|
||||
shouldRetry: true
|
||||
});
|
||||
}
|
||||
|
||||
return certificateMap;
|
||||
};
|
||||
|
||||
const queuePkiSyncSyncCertificatesById = async (payload: TQueuePkiSyncSyncCertificatesByIdDTO) =>
|
||||
queueService.queue(QueueName.PkiSync, QueueJobs.PkiSyncSyncCertificates, payload, {
|
||||
delay: getRequeueDelay(payload.failedToAcquireLockCount), // this is for delaying re-queued jobs if sync is locked
|
||||
attempts: 5,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 3000
|
||||
},
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true
|
||||
});
|
||||
|
||||
const queuePkiSyncImportCertificatesById = async (payload: TQueuePkiSyncImportCertificatesByIdDTO) =>
|
||||
queueService.queue(QueueName.PkiSync, QueueJobs.PkiSyncImportCertificates, payload, {
|
||||
attempts: 5,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 3000
|
||||
},
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true
|
||||
});
|
||||
|
||||
const queuePkiSyncRemoveCertificatesById = async (payload: TQueuePkiSyncRemoveCertificatesByIdDTO) =>
|
||||
queueService.queue(QueueName.PkiSync, QueueJobs.PkiSyncRemoveCertificates, payload, {
|
||||
attempts: 5,
|
||||
backoff: {
|
||||
type: "exponential",
|
||||
delay: 3000
|
||||
},
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true
|
||||
});
|
||||
|
||||
const $importCertificates = async (): Promise<TCertificateMap> => {
|
||||
throw new Error("Certificate import functionality is not implemented");
|
||||
};
|
||||
|
||||
const $handleSyncCertificatesJob = async (job: TPkiSyncSyncCertificatesDTO, pkiSync: TPkiSyncRaw) => {
|
||||
const {
|
||||
data: { syncId, auditLogInfo }
|
||||
} = job;
|
||||
|
||||
await enterprisePkiSyncCheck(
|
||||
licenseService,
|
||||
pkiSync.connection.orgId,
|
||||
pkiSync.destination,
|
||||
"Failed to sync certificates due to plan restriction. Upgrade plan to access enterprise PKI syncs."
|
||||
);
|
||||
|
||||
await pkiSyncDAL.updateById(syncId, {
|
||||
syncStatus: PkiSyncStatus.Running
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`PkiSync Sync [syncId=${pkiSync.id}] [destination=${pkiSync.destination}] [projectId=${pkiSync.projectId}] [subscriberId=${pkiSync.subscriberId}] [connectionId=${pkiSync.connectionId}]`
|
||||
);
|
||||
|
||||
let isSynced = false;
|
||||
let syncMessage: string | null = null;
|
||||
let isFinalAttempt = job.attemptsStarted === job.opts.attempts;
|
||||
|
||||
try {
|
||||
const {
|
||||
connection: { orgId, encryptedCredentials, projectId: appConnectionProjectId }
|
||||
} = pkiSync;
|
||||
|
||||
const credentials = await decryptAppConnectionCredentials({
|
||||
orgId,
|
||||
encryptedCredentials,
|
||||
kmsService,
|
||||
projectId: appConnectionProjectId
|
||||
});
|
||||
|
||||
const pkiSyncWithCredentials = {
|
||||
...pkiSync,
|
||||
connection: {
|
||||
...pkiSync.connection,
|
||||
credentials
|
||||
}
|
||||
} as TPkiSyncWithCredentials;
|
||||
|
||||
const certificateMap = await $getInfisicalCertificates(pkiSync);
|
||||
|
||||
const syncResult = await PkiSyncFns.syncCertificates(pkiSyncWithCredentials, certificateMap, {
|
||||
appConnectionDAL,
|
||||
kmsService
|
||||
});
|
||||
|
||||
logger.info(
|
||||
{
|
||||
syncId: pkiSync.id,
|
||||
uploaded: syncResult.uploaded || 0,
|
||||
removed: syncResult.removed || 0,
|
||||
failedRemovals: syncResult.failedRemovals || 0,
|
||||
skipped: syncResult.skipped || 0
|
||||
},
|
||||
"PKI sync operation completed with certificate cleanup"
|
||||
);
|
||||
|
||||
isSynced = true;
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
err,
|
||||
`PkiSync Sync Error [syncId=${pkiSync.id}] [destination=${pkiSync.destination}] [projectId=${pkiSync.projectId}] [subscriberId=${pkiSync.subscriberId}] [connectionId=${pkiSync.connectionId}]`
|
||||
);
|
||||
|
||||
if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
|
||||
syncCertificatesErrorHistogram.record(1, {
|
||||
version: 1,
|
||||
destination: pkiSync.destination,
|
||||
syncId: pkiSync.id,
|
||||
projectId: pkiSync.projectId,
|
||||
type: err instanceof AxiosError ? "AxiosError" : err?.constructor?.name || "UnknownError",
|
||||
status: err instanceof AxiosError ? err.response?.status : undefined,
|
||||
name: err instanceof Error ? err.name : undefined
|
||||
});
|
||||
}
|
||||
|
||||
syncMessage = parsePkiSyncErrorMessage(err);
|
||||
|
||||
if (err instanceof PkiSyncError && !err.shouldRetry) {
|
||||
isFinalAttempt = true;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
const ranAt = new Date();
|
||||
const syncStatus = isSynced ? PkiSyncStatus.Succeeded : PkiSyncStatus.Failed;
|
||||
|
||||
await auditLogService.createAuditLog({
|
||||
projectId: pkiSync.projectId,
|
||||
...(auditLogInfo ?? {
|
||||
actor: {
|
||||
type: ActorType.PLATFORM,
|
||||
metadata: {}
|
||||
}
|
||||
}),
|
||||
event: {
|
||||
type: EventType.PKI_SYNC_SYNC_CERTIFICATES,
|
||||
metadata: {
|
||||
syncId: pkiSync.id,
|
||||
syncMessage,
|
||||
jobId: job.id!,
|
||||
jobRanAt: ranAt
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (isSynced || isFinalAttempt) {
|
||||
await pkiSyncDAL.updateById(pkiSync.id, {
|
||||
syncStatus,
|
||||
lastSyncJobId: job.id,
|
||||
lastSyncMessage: syncMessage,
|
||||
lastSyncedAt: isSynced ? ranAt : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const $handleImportCertificatesJob = async (job: TPkiSyncImportCertificatesDTO, pkiSync: TPkiSyncRaw) => {
|
||||
const {
|
||||
data: { syncId, auditLogInfo }
|
||||
} = job;
|
||||
|
||||
await pkiSyncDAL.updateById(syncId, {
|
||||
importStatus: PkiSyncStatus.Running
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`PkiSync Import [syncId=${pkiSync.id}] [destination=${pkiSync.destination}] [projectId=${pkiSync.projectId}] [subscriberId=${pkiSync.subscriberId}] [connectionId=${pkiSync.connectionId}]`
|
||||
);
|
||||
|
||||
let isSuccess = false;
|
||||
let importMessage: string | null = null;
|
||||
let isFinalAttempt = job.attemptsStarted === job.opts.attempts;
|
||||
|
||||
try {
|
||||
await $importCertificates();
|
||||
|
||||
isSuccess = true;
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
err,
|
||||
`PkiSync Import Error [syncId=${pkiSync.id}] [destination=${pkiSync.destination}] [projectId=${pkiSync.projectId}] [subscriberId=${pkiSync.subscriberId}] [connectionId=${pkiSync.connectionId}]`
|
||||
);
|
||||
|
||||
if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
|
||||
importCertificatesErrorHistogram.record(1, {
|
||||
version: 1,
|
||||
destination: pkiSync.destination,
|
||||
syncId: pkiSync.id,
|
||||
projectId: pkiSync.projectId,
|
||||
type: err instanceof AxiosError ? "AxiosError" : err?.constructor?.name || "UnknownError",
|
||||
status: err instanceof AxiosError ? err.response?.status : undefined,
|
||||
name: err instanceof Error ? err.name : undefined
|
||||
});
|
||||
}
|
||||
|
||||
importMessage = parsePkiSyncErrorMessage(err);
|
||||
|
||||
if (err instanceof PkiSyncError && !err.shouldRetry) {
|
||||
isFinalAttempt = true;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
const ranAt = new Date();
|
||||
const importStatus = isSuccess ? PkiSyncStatus.Succeeded : PkiSyncStatus.Failed;
|
||||
|
||||
await auditLogService.createAuditLog({
|
||||
projectId: pkiSync.projectId,
|
||||
...(auditLogInfo ?? {
|
||||
actor: {
|
||||
type: ActorType.PLATFORM,
|
||||
metadata: {}
|
||||
}
|
||||
}),
|
||||
event: {
|
||||
type: EventType.PKI_SYNC_IMPORT_CERTIFICATES,
|
||||
metadata: {
|
||||
syncId: pkiSync.id,
|
||||
importMessage,
|
||||
jobId: job.id!,
|
||||
jobRanAt: ranAt
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (isSuccess || isFinalAttempt) {
|
||||
await pkiSyncDAL.updateById(pkiSync.id, {
|
||||
importStatus,
|
||||
lastImportJobId: job.id,
|
||||
lastImportMessage: importMessage,
|
||||
lastImportedAt: isSuccess ? ranAt : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const $handleRemoveCertificatesJob = async (job: TPkiSyncRemoveCertificatesDTO, pkiSync: TPkiSyncRaw) => {
|
||||
const {
|
||||
data: { syncId, auditLogInfo, deleteSyncOnComplete }
|
||||
} = job;
|
||||
|
||||
await enterprisePkiSyncCheck(
|
||||
licenseService,
|
||||
pkiSync.connection.orgId,
|
||||
pkiSync.destination,
|
||||
"Failed to remove certificates due to plan restriction. Upgrade plan to access enterprise PKI syncs."
|
||||
);
|
||||
|
||||
await pkiSyncDAL.updateById(syncId, {
|
||||
removeStatus: PkiSyncStatus.Running
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`PkiSync Remove [syncId=${pkiSync.id}] [destination=${pkiSync.destination}] [projectId=${pkiSync.projectId}] [subscriberId=${pkiSync.subscriberId}] [connectionId=${pkiSync.connectionId}]`
|
||||
);
|
||||
|
||||
let isSuccess = false;
|
||||
let removeMessage: string | null = null;
|
||||
let isFinalAttempt = job.attemptsStarted === job.opts.attempts;
|
||||
|
||||
try {
|
||||
const {
|
||||
connection: { orgId, encryptedCredentials, projectId: appConnectionProjectId }
|
||||
} = pkiSync;
|
||||
|
||||
const credentials = await decryptAppConnectionCredentials({
|
||||
orgId,
|
||||
encryptedCredentials,
|
||||
kmsService,
|
||||
projectId: appConnectionProjectId
|
||||
});
|
||||
|
||||
const certificateMap = await $getInfisicalCertificates(pkiSync);
|
||||
|
||||
await PkiSyncFns.removeCertificates(
|
||||
{
|
||||
...pkiSync,
|
||||
connection: {
|
||||
...pkiSync.connection,
|
||||
credentials
|
||||
}
|
||||
} as TPkiSyncWithCredentials,
|
||||
Object.keys(certificateMap),
|
||||
{
|
||||
appConnectionDAL,
|
||||
kmsService
|
||||
}
|
||||
);
|
||||
|
||||
isSuccess = true;
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
err,
|
||||
`PkiSync Remove Error [syncId=${pkiSync.id}] [destination=${pkiSync.destination}] [projectId=${pkiSync.projectId}] [subscriberId=${pkiSync.subscriberId}] [connectionId=${pkiSync.connectionId}]`
|
||||
);
|
||||
|
||||
if (appCfg.OTEL_TELEMETRY_COLLECTION_ENABLED) {
|
||||
removeCertificatesErrorHistogram.record(1, {
|
||||
version: 1,
|
||||
destination: pkiSync.destination,
|
||||
syncId: pkiSync.id,
|
||||
projectId: pkiSync.projectId,
|
||||
type: err instanceof AxiosError ? "AxiosError" : err?.constructor?.name || "UnknownError",
|
||||
status: err instanceof AxiosError ? err.response?.status : undefined,
|
||||
name: err instanceof Error ? err.name : undefined
|
||||
});
|
||||
}
|
||||
|
||||
removeMessage = parsePkiSyncErrorMessage(err);
|
||||
|
||||
if (err instanceof PkiSyncError && !err.shouldRetry) {
|
||||
isFinalAttempt = true;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
const ranAt = new Date();
|
||||
const removeStatus = isSuccess ? PkiSyncStatus.Succeeded : PkiSyncStatus.Failed;
|
||||
|
||||
await auditLogService.createAuditLog({
|
||||
projectId: pkiSync.projectId,
|
||||
...(auditLogInfo ?? {
|
||||
actor: {
|
||||
type: ActorType.PLATFORM,
|
||||
metadata: {}
|
||||
}
|
||||
}),
|
||||
event: {
|
||||
type: EventType.PKI_SYNC_REMOVE_CERTIFICATES,
|
||||
metadata: {
|
||||
syncId: pkiSync.id,
|
||||
removeMessage,
|
||||
jobId: job.id!,
|
||||
jobRanAt: ranAt
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (isSuccess || isFinalAttempt) {
|
||||
if (isSuccess && deleteSyncOnComplete) {
|
||||
await pkiSyncDAL.deleteById(pkiSync.id);
|
||||
} else {
|
||||
await pkiSyncDAL.updateById(pkiSync.id, {
|
||||
removeStatus,
|
||||
lastRemoveJobId: job.id,
|
||||
lastRemoveMessage: removeMessage,
|
||||
lastRemovedAt: isSuccess ? ranAt : undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const $handleAcquireLockFailure = async (job: PkiSyncActionJob) => {
|
||||
const { syncId } = job.data;
|
||||
|
||||
switch (job.name) {
|
||||
case QueueJobs.PkiSyncSyncCertificates: {
|
||||
const { failedToAcquireLockCount = 0, ...rest } = job.data as TQueuePkiSyncSyncCertificatesByIdDTO;
|
||||
|
||||
if (failedToAcquireLockCount < REQUEUE_LIMIT) {
|
||||
await queuePkiSyncSyncCertificatesById({ ...rest, failedToAcquireLockCount: failedToAcquireLockCount + 1 });
|
||||
return;
|
||||
}
|
||||
|
||||
await pkiSyncDAL.updateById(syncId, {
|
||||
syncStatus: PkiSyncStatus.Failed,
|
||||
lastSyncMessage:
|
||||
"Failed to run job. This typically happens when a sync is already in progress. Please try again.",
|
||||
lastSyncJobId: job.id
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case QueueJobs.PkiSyncImportCertificates: {
|
||||
await pkiSyncDAL.updateById(syncId, {
|
||||
importStatus: PkiSyncStatus.Failed,
|
||||
lastImportMessage:
|
||||
"Failed to run job. This typically happens when a sync is already in progress. Please try again.",
|
||||
lastImportJobId: job.id
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case QueueJobs.PkiSyncRemoveCertificates: {
|
||||
await pkiSyncDAL.updateById(syncId, {
|
||||
removeStatus: PkiSyncStatus.Failed,
|
||||
lastRemoveMessage:
|
||||
"Failed to run job. This typically happens when a sync is already in progress. Please try again.",
|
||||
lastRemoveJobId: job.id
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unhandled PKI Sync Job ${String(job.name)}`);
|
||||
}
|
||||
};
|
||||
|
||||
queueService.start(QueueName.PkiSync, async (job) => {
|
||||
const { syncId } = job.data;
|
||||
|
||||
const pkiSync = await pkiSyncDAL.findById(syncId);
|
||||
|
||||
if (!pkiSync) throw new Error(`Cannot find PKI sync with ID ${syncId}`);
|
||||
|
||||
const { connectionId } = pkiSync;
|
||||
|
||||
if (job.name === QueueJobs.PkiSyncSyncCertificates) {
|
||||
const isConcurrentLimitReached = await $isConnectionConcurrencyLimitReached(connectionId);
|
||||
|
||||
if (isConcurrentLimitReached) {
|
||||
await $handleAcquireLockFailure(job as PkiSyncActionJob);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let lock: Awaited<ReturnType<typeof keyStore.acquireLock>>;
|
||||
|
||||
try {
|
||||
lock = await keyStore.acquireLock(
|
||||
[KeyStorePrefixes.PkiSyncLock(syncId)],
|
||||
// PKI syncs can take excessive amounts of time so we need to keep it locked
|
||||
5 * 60 * 1000
|
||||
);
|
||||
} catch (e) {
|
||||
await $handleAcquireLockFailure(job as PkiSyncActionJob);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
switch (job.name) {
|
||||
case QueueJobs.PkiSyncSyncCertificates: {
|
||||
await $incrementConnectionConcurrencyCount(connectionId);
|
||||
await $handleSyncCertificatesJob(job as TPkiSyncSyncCertificatesDTO, pkiSync);
|
||||
break;
|
||||
}
|
||||
case QueueJobs.PkiSyncImportCertificates:
|
||||
await $handleImportCertificatesJob(job as TPkiSyncImportCertificatesDTO, pkiSync);
|
||||
break;
|
||||
case QueueJobs.PkiSyncRemoveCertificates:
|
||||
await $handleRemoveCertificatesJob(job as TPkiSyncRemoveCertificatesDTO, pkiSync);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unhandled PKI Sync Job ${String(job.name)}`);
|
||||
}
|
||||
} finally {
|
||||
if (job.name === QueueJobs.PkiSyncSyncCertificates) {
|
||||
await $decrementConnectionConcurrencyCount(connectionId);
|
||||
}
|
||||
|
||||
await lock.release();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
queuePkiSyncSyncCertificatesById,
|
||||
queuePkiSyncImportCertificatesById,
|
||||
queuePkiSyncRemoveCertificatesById
|
||||
};
|
||||
};
|
||||
61
backend/src/services/pki-sync/pki-sync-schemas.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import RE2 from "re2";
|
||||
import { z } from "zod";
|
||||
|
||||
import { PkiSync } from "./pki-sync-enums";
|
||||
|
||||
// Schema for PKI sync options configuration
|
||||
export const PkiSyncOptionsSchema = z.object({
|
||||
canImportCertificates: z.boolean(),
|
||||
canRemoveCertificates: z.boolean().optional(),
|
||||
certificateNameSchema: z
|
||||
.string()
|
||||
.optional()
|
||||
.refine(
|
||||
(val) => {
|
||||
if (!val) return true;
|
||||
|
||||
const allowedPlaceholdersRegexPart = ["{{certificateId}}"]
|
||||
.map((p) => p.replace(new RE2(/[-/\\^$*+?.()|[\]{}]/g), "\\$&")) // Escape regex special characters
|
||||
.join("|");
|
||||
|
||||
const allowedContentRegex = new RE2(`^([a-zA-Z0-9_\\-/]|${allowedPlaceholdersRegexPart})*$`);
|
||||
const contentIsValid = allowedContentRegex.test(val);
|
||||
|
||||
if (val.trim()) {
|
||||
const certificateIdRegex = new RE2(/\{\{certificateId\}\}/);
|
||||
const certificateIdIsPresent = certificateIdRegex.test(val);
|
||||
return contentIsValid && certificateIdIsPresent;
|
||||
}
|
||||
|
||||
return contentIsValid;
|
||||
},
|
||||
{
|
||||
message:
|
||||
"Certificate name schema must include exactly one {{certificateId}} placeholder. Only alphanumeric characters (a-z, A-Z, 0-9), dashes (-), underscores (_), and slashes (/) are allowed besides the placeholders."
|
||||
}
|
||||
)
|
||||
});
|
||||
|
||||
// Schema for destination-specific configurations
|
||||
export const PkiSyncDestinationConfigSchema = z.object({
|
||||
destination: z.nativeEnum(PkiSync),
|
||||
config: z.record(z.unknown())
|
||||
});
|
||||
|
||||
// Base PKI sync schema for API responses
|
||||
export const PkiSyncSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
name: z.string().max(255),
|
||||
description: z.string().nullable().optional(),
|
||||
destination: z.nativeEnum(PkiSync),
|
||||
isAutoSyncEnabled: z.boolean(),
|
||||
destinationConfig: z.record(z.unknown()),
|
||||
syncOptions: z.record(z.unknown()),
|
||||
projectId: z.string().uuid(),
|
||||
subscriberId: z.string().uuid().nullable().optional(),
|
||||
connectionId: z.string().uuid(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
syncStatus: z.string().nullable().optional(),
|
||||
lastSyncedAt: z.date().nullable().optional()
|
||||
});
|
||||
447
backend/src/services/pki-sync/pki-sync-service.ts
Normal file
@@ -0,0 +1,447 @@
|
||||
import { ForbiddenError, subject } from "@casl/ability";
|
||||
|
||||
import { ActionProjectType } from "@app/db/schemas";
|
||||
import { TLicenseServiceFactory } from "@app/ee/services/license/license-service";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
|
||||
import { ProjectPermissionPkiSyncActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||
import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors";
|
||||
import { OrgServiceActor } from "@app/lib/types";
|
||||
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
|
||||
import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service";
|
||||
import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal";
|
||||
|
||||
import { TPkiSyncDALFactory } from "./pki-sync-dal";
|
||||
import { PkiSync, PkiSyncStatus } from "./pki-sync-enums";
|
||||
import { enterprisePkiSyncCheck, getPkiSyncProviderCapabilities, listPkiSyncOptions } from "./pki-sync-fns";
|
||||
import { PKI_SYNC_CONNECTION_MAP, PKI_SYNC_NAME_MAP } from "./pki-sync-maps";
|
||||
import { TPkiSyncQueueFactory } from "./pki-sync-queue";
|
||||
import {
|
||||
TCreatePkiSyncDTO,
|
||||
TDeletePkiSyncDTO,
|
||||
TFindPkiSyncByIdDTO,
|
||||
TListPkiSyncsByProjectId,
|
||||
TPkiSync,
|
||||
TTriggerPkiSyncImportCertificatesByIdDTO,
|
||||
TTriggerPkiSyncRemoveCertificatesByIdDTO,
|
||||
TTriggerPkiSyncSyncCertificatesByIdDTO,
|
||||
TUpdatePkiSyncDTO
|
||||
} from "./pki-sync-types";
|
||||
|
||||
const getDestinationAppType = (destination: PkiSync): AppConnection => {
|
||||
const appConnection = PKI_SYNC_CONNECTION_MAP[destination];
|
||||
if (!appConnection) {
|
||||
throw new BadRequestError({
|
||||
message: `Unsupported PKI sync destination: ${destination}`
|
||||
});
|
||||
}
|
||||
return appConnection;
|
||||
};
|
||||
|
||||
type TPkiSyncServiceFactoryDep = {
|
||||
pkiSyncDAL: Pick<
|
||||
TPkiSyncDALFactory,
|
||||
"findById" | "findByProjectIdWithSubscribers" | "findByNameAndProjectId" | "create" | "updateById" | "deleteById"
|
||||
>;
|
||||
pkiSubscriberDAL: Pick<TPkiSubscriberDALFactory, "findById">;
|
||||
appConnectionService: Pick<TAppConnectionServiceFactory, "connectAppConnectionById">;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||
pkiSyncQueue: Pick<
|
||||
TPkiSyncQueueFactory,
|
||||
"queuePkiSyncSyncCertificatesById" | "queuePkiSyncImportCertificatesById" | "queuePkiSyncRemoveCertificatesById"
|
||||
>;
|
||||
};
|
||||
|
||||
export type TPkiSyncServiceFactory = ReturnType<typeof pkiSyncServiceFactory>;
|
||||
|
||||
export const pkiSyncServiceFactory = ({
|
||||
pkiSyncDAL,
|
||||
pkiSubscriberDAL,
|
||||
appConnectionService,
|
||||
permissionService,
|
||||
licenseService,
|
||||
pkiSyncQueue
|
||||
}: TPkiSyncServiceFactoryDep) => {
|
||||
const createPkiSync = async (
|
||||
{
|
||||
name,
|
||||
description,
|
||||
destination,
|
||||
isAutoSyncEnabled = true,
|
||||
destinationConfig,
|
||||
syncOptions = {},
|
||||
subscriberId,
|
||||
connectionId,
|
||||
projectId
|
||||
}: Omit<TCreatePkiSyncDTO, "auditLogInfo">,
|
||||
actor: OrgServiceActor
|
||||
): Promise<TPkiSync> => {
|
||||
await enterprisePkiSyncCheck(licenseService, actor.orgId, destination);
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorId: actor.id,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorOrgId: actor.orgId,
|
||||
actionProjectType: ActionProjectType.CertificateManager,
|
||||
projectId
|
||||
});
|
||||
|
||||
let subscriber;
|
||||
if (subscriberId) {
|
||||
subscriber = await pkiSubscriberDAL.findById(subscriberId);
|
||||
if (!subscriber || subscriber.projectId !== projectId) {
|
||||
throw new NotFoundError({ message: "PKI subscriber not found" });
|
||||
}
|
||||
}
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionPkiSyncActions.Create,
|
||||
subscriber
|
||||
? subject(ProjectPermissionSub.PkiSyncs, { subscriberName: subscriber.name })
|
||||
: ProjectPermissionSub.PkiSyncs
|
||||
);
|
||||
|
||||
// Get the destination app type based on PKI sync destination
|
||||
const destinationApp = getDestinationAppType(destination);
|
||||
|
||||
// Validates permission to connect and app is valid for sync destination
|
||||
await appConnectionService.connectAppConnectionById(destinationApp, connectionId, actor);
|
||||
|
||||
const providerCapabilities = getPkiSyncProviderCapabilities(destination);
|
||||
const resolvedSyncOptions = {
|
||||
...providerCapabilities,
|
||||
...syncOptions
|
||||
};
|
||||
|
||||
try {
|
||||
const pkiSync = await pkiSyncDAL.create({
|
||||
name,
|
||||
description,
|
||||
destination,
|
||||
isAutoSyncEnabled,
|
||||
destinationConfig,
|
||||
syncOptions: resolvedSyncOptions,
|
||||
subscriberId,
|
||||
connectionId,
|
||||
projectId,
|
||||
...(isAutoSyncEnabled && { syncStatus: PkiSyncStatus.Pending })
|
||||
});
|
||||
|
||||
if (pkiSync.isAutoSyncEnabled) {
|
||||
await pkiSyncQueue.queuePkiSyncSyncCertificatesById({ syncId: pkiSync.id });
|
||||
}
|
||||
|
||||
return pkiSync as TPkiSync;
|
||||
} catch (err) {
|
||||
if (err instanceof DatabaseError && (err.error as { code: string })?.code === "23505") {
|
||||
throw new BadRequestError({
|
||||
message: `A PKI Sync with the name "${name}" already exists for the project with ID "${projectId}"`
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const updatePkiSync = async (
|
||||
{
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
isAutoSyncEnabled,
|
||||
destinationConfig,
|
||||
syncOptions,
|
||||
subscriberId,
|
||||
connectionId
|
||||
}: Omit<TUpdatePkiSyncDTO, "auditLogInfo" | "projectId">,
|
||||
actor: OrgServiceActor
|
||||
): Promise<TPkiSync> => {
|
||||
const pkiSync = await pkiSyncDAL.findById(id);
|
||||
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
|
||||
});
|
||||
|
||||
let currentSubscriber;
|
||||
if (pkiSync.subscriberId) {
|
||||
currentSubscriber = await pkiSubscriberDAL.findById(pkiSync.subscriberId);
|
||||
}
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionPkiSyncActions.Edit,
|
||||
currentSubscriber
|
||||
? subject(ProjectPermissionSub.PkiSyncs, { subscriberName: currentSubscriber.name })
|
||||
: ProjectPermissionSub.PkiSyncs
|
||||
);
|
||||
|
||||
if (name && name !== pkiSync.name) {
|
||||
const existingPkiSync = await pkiSyncDAL.findByNameAndProjectId(name, pkiSync.projectId);
|
||||
if (existingPkiSync) {
|
||||
throw new BadRequestError({ message: "PKI sync with this name already exists" });
|
||||
}
|
||||
}
|
||||
|
||||
if (subscriberId) {
|
||||
const subscriber = await pkiSubscriberDAL.findById(subscriberId);
|
||||
if (!subscriber || subscriber.projectId !== pkiSync.projectId) {
|
||||
throw new NotFoundError({ message: "PKI subscriber not found" });
|
||||
}
|
||||
}
|
||||
|
||||
if (connectionId && connectionId !== pkiSync.connectionId) {
|
||||
const destinationApp = getDestinationAppType(pkiSync.destination);
|
||||
await appConnectionService.connectAppConnectionById(destinationApp, connectionId, actor);
|
||||
}
|
||||
|
||||
let resolvedSyncOptions = syncOptions;
|
||||
if (syncOptions) {
|
||||
const providerCapabilities = getPkiSyncProviderCapabilities(pkiSync.destination);
|
||||
|
||||
if (syncOptions.canImportCertificates && !providerCapabilities.canImportCertificates) {
|
||||
throw new BadRequestError({
|
||||
message: `Certificate import is not supported for ${PKI_SYNC_NAME_MAP[pkiSync.destination]} PKI sync destination`
|
||||
});
|
||||
}
|
||||
|
||||
if (syncOptions.canRemoveCertificates && !providerCapabilities.canRemoveCertificates) {
|
||||
throw new BadRequestError({
|
||||
message: `Certificate removal cannot be enabled for ${PKI_SYNC_NAME_MAP[pkiSync.destination]} PKI sync destination`
|
||||
});
|
||||
}
|
||||
|
||||
resolvedSyncOptions = {
|
||||
...providerCapabilities,
|
||||
...syncOptions
|
||||
};
|
||||
}
|
||||
|
||||
const updatedPkiSync = await pkiSyncDAL.updateById(id, {
|
||||
name,
|
||||
description,
|
||||
isAutoSyncEnabled,
|
||||
destinationConfig,
|
||||
syncOptions: resolvedSyncOptions,
|
||||
subscriberId,
|
||||
connectionId
|
||||
});
|
||||
|
||||
return updatedPkiSync as TPkiSync;
|
||||
};
|
||||
|
||||
const deletePkiSync = async (
|
||||
{ id }: Omit<TDeletePkiSyncDTO, "auditLogInfo" | "projectId">,
|
||||
actor: OrgServiceActor
|
||||
) => {
|
||||
const pkiSync = await pkiSyncDAL.findById(id);
|
||||
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
|
||||
});
|
||||
|
||||
let pkiSyncSubscriber;
|
||||
if (pkiSync.subscriberId) {
|
||||
pkiSyncSubscriber = await pkiSubscriberDAL.findById(pkiSync.subscriberId);
|
||||
}
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionPkiSyncActions.Delete,
|
||||
pkiSyncSubscriber
|
||||
? subject(ProjectPermissionSub.PkiSyncs, { subscriberName: pkiSyncSubscriber.name })
|
||||
: ProjectPermissionSub.PkiSyncs
|
||||
);
|
||||
|
||||
return pkiSyncDAL.deleteById(id);
|
||||
};
|
||||
|
||||
const listPkiSyncsByProjectId = async (
|
||||
{ projectId }: TListPkiSyncsByProjectId,
|
||||
actor: OrgServiceActor
|
||||
): Promise<TPkiSync[]> => {
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorId: actor.id,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorOrgId: actor.orgId,
|
||||
actionProjectType: ActionProjectType.CertificateManager,
|
||||
projectId
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionPkiSyncActions.Read, ProjectPermissionSub.PkiSyncs);
|
||||
|
||||
const pkiSyncsWithSubscribers = await pkiSyncDAL.findByProjectIdWithSubscribers(projectId);
|
||||
|
||||
return pkiSyncsWithSubscribers as TPkiSync[];
|
||||
};
|
||||
|
||||
const findPkiSyncById = async ({ id, projectId }: TFindPkiSyncByIdDTO, actor: OrgServiceActor) => {
|
||||
const pkiSync = await pkiSyncDAL.findById(id);
|
||||
if (!pkiSync || (projectId && pkiSync.projectId !== projectId)) {
|
||||
throw new NotFoundError({
|
||||
message: `Could not find PKI Sync with ID "${id}"`
|
||||
});
|
||||
}
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorId: actor.id,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorOrgId: actor.orgId,
|
||||
actionProjectType: ActionProjectType.CertificateManager,
|
||||
projectId: pkiSync.projectId
|
||||
});
|
||||
|
||||
let findSubscriber;
|
||||
if (pkiSync.subscriberId) {
|
||||
findSubscriber = await pkiSubscriberDAL.findById(pkiSync.subscriberId);
|
||||
}
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionPkiSyncActions.Read,
|
||||
findSubscriber
|
||||
? subject(ProjectPermissionSub.PkiSyncs, { subscriberName: findSubscriber.name })
|
||||
: ProjectPermissionSub.PkiSyncs
|
||||
);
|
||||
|
||||
const result = {
|
||||
...pkiSync,
|
||||
subscriber: findSubscriber ? { id: findSubscriber.id, name: findSubscriber.name } : null
|
||||
} as TPkiSync;
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const triggerPkiSyncSyncCertificatesById = async (
|
||||
{ id }: Omit<TTriggerPkiSyncSyncCertificatesByIdDTO, "auditLogInfo" | "projectId">,
|
||||
actor: OrgServiceActor
|
||||
) => {
|
||||
const pkiSync = await pkiSyncDAL.findById(id);
|
||||
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
|
||||
});
|
||||
|
||||
let syncSubscriber;
|
||||
if (pkiSync.subscriberId) {
|
||||
syncSubscriber = await pkiSubscriberDAL.findById(pkiSync.subscriberId);
|
||||
}
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionPkiSyncActions.SyncCertificates,
|
||||
syncSubscriber
|
||||
? subject(ProjectPermissionSub.PkiSyncs, { subscriberName: syncSubscriber.name })
|
||||
: ProjectPermissionSub.PkiSyncs
|
||||
);
|
||||
|
||||
await pkiSyncQueue.queuePkiSyncSyncCertificatesById({ syncId: id });
|
||||
|
||||
return { message: "PKI sync job added to queue successfully" };
|
||||
};
|
||||
|
||||
const triggerPkiSyncImportCertificatesById = async (
|
||||
{ id }: Omit<TTriggerPkiSyncImportCertificatesByIdDTO, "auditLogInfo" | "projectId">,
|
||||
actor: OrgServiceActor
|
||||
) => {
|
||||
const pkiSync = await pkiSyncDAL.findById(id);
|
||||
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
|
||||
});
|
||||
|
||||
// Check if the PKI sync destination supports importing certificates
|
||||
const syncOptions = listPkiSyncOptions().find((option) => option.destination === pkiSync.destination);
|
||||
if (!syncOptions?.canImportCertificates) {
|
||||
throw new BadRequestError({
|
||||
message: `Certificate import is not supported for ${pkiSync.destination} PKI sync destination`
|
||||
});
|
||||
}
|
||||
|
||||
let importSubscriber;
|
||||
if (pkiSync.subscriberId) {
|
||||
importSubscriber = await pkiSubscriberDAL.findById(pkiSync.subscriberId);
|
||||
}
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionPkiSyncActions.ImportCertificates,
|
||||
importSubscriber
|
||||
? subject(ProjectPermissionSub.PkiSyncs, { subscriberName: importSubscriber.name })
|
||||
: ProjectPermissionSub.PkiSyncs
|
||||
);
|
||||
|
||||
await pkiSyncQueue.queuePkiSyncImportCertificatesById({ syncId: id });
|
||||
|
||||
return { message: "PKI sync import job added to queue successfully" };
|
||||
};
|
||||
|
||||
const triggerPkiSyncRemoveCertificatesById = async (
|
||||
{ id }: Omit<TTriggerPkiSyncRemoveCertificatesByIdDTO, "auditLogInfo" | "projectId">,
|
||||
actor: OrgServiceActor
|
||||
) => {
|
||||
const pkiSync = await pkiSyncDAL.findById(id);
|
||||
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
|
||||
});
|
||||
|
||||
let removeSubscriber;
|
||||
if (pkiSync.subscriberId) {
|
||||
removeSubscriber = await pkiSubscriberDAL.findById(pkiSync.subscriberId);
|
||||
}
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionPkiSyncActions.RemoveCertificates,
|
||||
removeSubscriber
|
||||
? subject(ProjectPermissionSub.PkiSyncs, { subscriberName: removeSubscriber.name })
|
||||
: ProjectPermissionSub.PkiSyncs
|
||||
);
|
||||
|
||||
await pkiSyncQueue.queuePkiSyncRemoveCertificatesById({ syncId: id });
|
||||
|
||||
return { message: "PKI sync remove job added to queue successfully" };
|
||||
};
|
||||
|
||||
const getPkiSyncOptions = () => {
|
||||
return listPkiSyncOptions();
|
||||
};
|
||||
|
||||
return {
|
||||
createPkiSync,
|
||||
updatePkiSync,
|
||||
deletePkiSync,
|
||||
listPkiSyncsByProjectId,
|
||||
findPkiSyncById,
|
||||
triggerPkiSyncSyncCertificatesById,
|
||||
triggerPkiSyncImportCertificatesById,
|
||||
triggerPkiSyncRemoveCertificatesById,
|
||||
getPkiSyncOptions
|
||||
};
|
||||
};
|
||||
169
backend/src/services/pki-sync/pki-sync-types.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { Job } from "bullmq";
|
||||
|
||||
import { AuditLogInfo } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import { QueueJobs } from "@app/queue";
|
||||
import { ResourceMetadataDTO } from "@app/services/resource-metadata/resource-metadata-schema";
|
||||
|
||||
import { TPkiSyncDALFactory } from "./pki-sync-dal";
|
||||
import { PkiSync } from "./pki-sync-enums";
|
||||
|
||||
export type TPkiSync = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
destination: PkiSync;
|
||||
isAutoSyncEnabled: boolean;
|
||||
destinationConfig: Record<string, unknown>;
|
||||
syncOptions: Record<string, unknown>;
|
||||
projectId: string;
|
||||
subscriberId?: string;
|
||||
connectionId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
syncStatus?: string;
|
||||
lastSyncJobId?: string;
|
||||
lastSyncMessage?: string;
|
||||
lastSyncedAt?: Date;
|
||||
importStatus?: string;
|
||||
lastImportJobId?: string;
|
||||
lastImportMessage?: string;
|
||||
lastImportedAt?: Date;
|
||||
removeStatus?: string;
|
||||
lastRemoveJobId?: string;
|
||||
lastRemoveMessage?: string;
|
||||
lastRemovedAt?: Date;
|
||||
appConnectionName: string;
|
||||
appConnectionApp: string;
|
||||
connection: {
|
||||
id: string;
|
||||
name: string;
|
||||
app: string;
|
||||
encryptedCredentials: unknown;
|
||||
orgId: string;
|
||||
projectId?: string;
|
||||
method: string;
|
||||
description?: string;
|
||||
version: number;
|
||||
gatewayId?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
isPlatformManagedCredentials?: boolean;
|
||||
};
|
||||
subscriber?: {
|
||||
id: string;
|
||||
name: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type TPkiSyncWithCredentials = TPkiSync & {
|
||||
connection: {
|
||||
id: string;
|
||||
name: string;
|
||||
app: string;
|
||||
credentials: Record<string, unknown>;
|
||||
orgId: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type TPkiSyncListItem = TPkiSync & {
|
||||
appConnectionName: string;
|
||||
appConnectionApp: string;
|
||||
};
|
||||
|
||||
export type TCertificateMap = Record<string, { cert: string; privateKey: string; certificateChain?: string }>;
|
||||
|
||||
export type TCreatePkiSyncDTO = {
|
||||
name: string;
|
||||
description?: string;
|
||||
destination: PkiSync;
|
||||
isAutoSyncEnabled?: boolean;
|
||||
destinationConfig: Record<string, unknown>;
|
||||
syncOptions?: Record<string, unknown>;
|
||||
subscriberId?: string;
|
||||
connectionId: string;
|
||||
projectId: string;
|
||||
auditLogInfo: AuditLogInfo;
|
||||
resourceMetadata?: ResourceMetadataDTO;
|
||||
};
|
||||
|
||||
export type TUpdatePkiSyncDTO = {
|
||||
id: string;
|
||||
projectId?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
isAutoSyncEnabled?: boolean;
|
||||
destinationConfig?: Record<string, unknown>;
|
||||
syncOptions?: Record<string, unknown>;
|
||||
subscriberId?: string;
|
||||
connectionId?: string;
|
||||
auditLogInfo: AuditLogInfo;
|
||||
resourceMetadata?: ResourceMetadataDTO;
|
||||
};
|
||||
|
||||
export type TDeletePkiSyncDTO = {
|
||||
id: string;
|
||||
projectId?: string;
|
||||
auditLogInfo: AuditLogInfo;
|
||||
};
|
||||
|
||||
export type TListPkiSyncsByProjectId = {
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export type TFindPkiSyncByIdDTO = {
|
||||
id: string;
|
||||
projectId?: string;
|
||||
};
|
||||
|
||||
export type TTriggerPkiSyncSyncCertificatesByIdDTO = {
|
||||
id: string;
|
||||
projectId?: string;
|
||||
auditLogInfo: AuditLogInfo;
|
||||
};
|
||||
|
||||
export type TTriggerPkiSyncImportCertificatesByIdDTO = {
|
||||
id: string;
|
||||
projectId?: string;
|
||||
auditLogInfo: AuditLogInfo;
|
||||
};
|
||||
|
||||
export type TTriggerPkiSyncRemoveCertificatesByIdDTO = {
|
||||
id: string;
|
||||
projectId?: string;
|
||||
auditLogInfo: AuditLogInfo;
|
||||
};
|
||||
|
||||
export type TPkiSyncRaw = NonNullable<Awaited<ReturnType<TPkiSyncDALFactory["findById"]>>>;
|
||||
|
||||
export type TQueuePkiSyncSyncCertificatesByIdDTO = {
|
||||
syncId: string;
|
||||
failedToAcquireLockCount?: number;
|
||||
auditLogInfo?: AuditLogInfo;
|
||||
};
|
||||
|
||||
export type TQueuePkiSyncImportCertificatesByIdDTO = {
|
||||
syncId: string;
|
||||
auditLogInfo?: AuditLogInfo;
|
||||
};
|
||||
|
||||
export type TQueuePkiSyncRemoveCertificatesByIdDTO = {
|
||||
syncId: string;
|
||||
auditLogInfo?: AuditLogInfo;
|
||||
deleteSyncOnComplete?: boolean;
|
||||
};
|
||||
|
||||
export type TPkiSyncSyncCertificatesDTO = Job<
|
||||
TQueuePkiSyncSyncCertificatesByIdDTO,
|
||||
void,
|
||||
QueueJobs.PkiSyncSyncCertificates
|
||||
>;
|
||||
export type TPkiSyncImportCertificatesDTO = Job<
|
||||
TQueuePkiSyncImportCertificatesByIdDTO,
|
||||
void,
|
||||
QueueJobs.PkiSyncImportCertificates
|
||||
>;
|
||||
export type TPkiSyncRemoveCertificatesDTO = Job<
|
||||
TQueuePkiSyncRemoveCertificatesByIdDTO,
|
||||
void,
|
||||
QueueJobs.PkiSyncRemoveCertificates
|
||||
>;
|
||||
27
backend/src/services/pki-sync/pki-sync-utils.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { logger } from "@app/lib/logger";
|
||||
|
||||
import { TPkiSyncDALFactory } from "./pki-sync-dal";
|
||||
import { TPkiSyncQueueFactory } from "./pki-sync-queue";
|
||||
|
||||
export const triggerAutoSyncForSubscriber = async (
|
||||
subscriberId: string,
|
||||
dependencies: {
|
||||
pkiSyncDAL: Pick<TPkiSyncDALFactory, "find">;
|
||||
pkiSyncQueue: Pick<TPkiSyncQueueFactory, "queuePkiSyncSyncCertificatesById">;
|
||||
}
|
||||
) => {
|
||||
try {
|
||||
const pkiSyncs = await dependencies.pkiSyncDAL.find({
|
||||
subscriberId,
|
||||
isAutoSyncEnabled: true
|
||||
});
|
||||
|
||||
// Queue sync jobs for each auto sync enabled PKI sync
|
||||
const syncPromises = pkiSyncs.map((pkiSync) =>
|
||||
dependencies.pkiSyncQueue.queuePkiSyncSyncCertificatesById({ syncId: pkiSync.id })
|
||||
);
|
||||
await Promise.all(syncPromises);
|
||||
} catch (error) {
|
||||
logger.error(error, `Failed to trigger auto sync for subscriber ${subscriberId}:`);
|
||||
}
|
||||
};
|
||||
@@ -597,19 +597,27 @@ export const expandSecretReferencesFactory = ({
|
||||
return secretCache[cacheKey][secretKey] || { value: "", tags: [] };
|
||||
}
|
||||
|
||||
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
|
||||
if (!folder) return { value: "", tags: [] };
|
||||
const secrets = await secretDAL.findByFolderId({ folderId: folder.id });
|
||||
try {
|
||||
const folder = await folderDAL.findBySecretPath(projectId, environment, secretPath);
|
||||
if (!folder) return { value: "", tags: [] };
|
||||
const secrets = await secretDAL.findByFolderId({ folderId: folder.id });
|
||||
|
||||
const decryptedSecret = secrets.reduce<Record<string, { value: string; tags: string[] }>>((prev, secret) => {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
prev[secret.key] = { value: decryptSecret(secret.encryptedValue) || "", tags: secret.tags?.map((el) => el.slug) };
|
||||
return prev;
|
||||
}, {});
|
||||
const decryptedSecret = secrets.reduce<Record<string, { value: string; tags: string[] }>>((prev, secret) => {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
prev[secret.key] = {
|
||||
value: decryptSecret(secret.encryptedValue) || "",
|
||||
tags: secret.tags?.map((el) => el.slug)
|
||||
};
|
||||
return prev;
|
||||
}, {});
|
||||
|
||||
secretCache[cacheKey] = decryptedSecret;
|
||||
secretCache[cacheKey] = decryptedSecret;
|
||||
|
||||
return secretCache[cacheKey][secretKey] || { value: "", tags: [] };
|
||||
return secretCache[cacheKey][secretKey] || { value: "", tags: [] };
|
||||
} catch (error) {
|
||||
secretCache[cacheKey] = {};
|
||||
return { value: "", tags: [] };
|
||||
}
|
||||
};
|
||||
|
||||
const recursivelyExpandSecret = async (dto: {
|
||||
@@ -622,11 +630,16 @@ export const expandSecretReferencesFactory = ({
|
||||
const stackTrace = { ...dto, key: "root", children: [] } as TSecretReferenceTraceNode;
|
||||
|
||||
if (!dto.value) return { expandedValue: "", stackTrace };
|
||||
const stack = [{ ...dto, depth: 0, trace: stackTrace }];
|
||||
|
||||
// Track visited secrets to prevent circular references
|
||||
const createSecretId = (env: string, secretPath: string, key: string) => `${env}:${secretPath}:${key}`;
|
||||
|
||||
const currentSecretId = createSecretId(dto.environment, dto.secretPath, dto.secretKey);
|
||||
const stack = [{ ...dto, depth: 0, trace: stackTrace, visitedSecrets: new Set<string>([currentSecretId]) }];
|
||||
let expandedValue = dto.value;
|
||||
|
||||
while (stack.length) {
|
||||
const { value, secretPath, environment, depth, trace } = stack.pop()!;
|
||||
const { value, secretPath, environment, depth, trace, visitedSecrets } = stack.pop()!;
|
||||
|
||||
// eslint-disable-next-line no-continue
|
||||
if (depth > MAX_SECRET_REFERENCE_DEPTH) continue;
|
||||
@@ -664,6 +677,7 @@ export const expandSecretReferencesFactory = ({
|
||||
});
|
||||
|
||||
const cacheKey = getCacheUniqueKey(environment, secretPath);
|
||||
if (!secretCache[cacheKey]) secretCache[cacheKey] = {};
|
||||
secretCache[cacheKey][secretKey] = referredValue;
|
||||
|
||||
referencedSecretValue = referredValue.value;
|
||||
@@ -683,6 +697,7 @@ export const expandSecretReferencesFactory = ({
|
||||
});
|
||||
|
||||
const cacheKey = getCacheUniqueKey(secretReferenceEnvironment, secretReferencePath);
|
||||
if (!secretCache[cacheKey]) secretCache[cacheKey] = {};
|
||||
secretCache[cacheKey][secretReferenceKey] = referedValue;
|
||||
|
||||
referencedSecretValue = referedValue.value;
|
||||
@@ -700,17 +715,27 @@ export const expandSecretReferencesFactory = ({
|
||||
trace
|
||||
};
|
||||
|
||||
const shouldExpandMore = INTERPOLATION_TEST_REGEX.test(referencedSecretValue);
|
||||
// Check for circular reference
|
||||
const referencedSecretId = createSecretId(
|
||||
referencedSecretEnvironmentSlug,
|
||||
referencedSecretPath,
|
||||
referencedSecretKey
|
||||
);
|
||||
const isCircular = visitedSecrets.has(referencedSecretId);
|
||||
|
||||
const newVisitedSecrets = new Set([...visitedSecrets, referencedSecretId]);
|
||||
|
||||
const shouldExpandMore = INTERPOLATION_TEST_REGEX.test(referencedSecretValue) && !isCircular;
|
||||
if (dto.shouldStackTrace) {
|
||||
const stackTraceNode = { ...node, children: [], key: referencedSecretKey, trace: null };
|
||||
trace?.children.push(stackTraceNode);
|
||||
// if stack trace this would be child node
|
||||
if (shouldExpandMore) {
|
||||
stack.push({ ...node, trace: stackTraceNode });
|
||||
stack.push({ ...node, trace: stackTraceNode, visitedSecrets: newVisitedSecrets });
|
||||
}
|
||||
} else if (shouldExpandMore) {
|
||||
// if no stack trace is needed we just keep going with root node
|
||||
stack.push(node);
|
||||
stack.push({ ...node, visitedSecrets: newVisitedSecrets });
|
||||
}
|
||||
|
||||
if (referencedSecretValue) {
|
||||
|
||||
@@ -159,17 +159,14 @@ export const secretV2BridgeServiceFactory = ({
|
||||
const uniqueReferenceEnvironmentSlugs = Array.from(new Set(references.map((el) => el.environment)));
|
||||
const referencesEnvironments = await projectEnvDAL.findBySlugs(projectId, uniqueReferenceEnvironmentSlugs, tx);
|
||||
|
||||
if (referencesEnvironments.length !== uniqueReferenceEnvironmentSlugs.length)
|
||||
throw new BadRequestError({
|
||||
message: `Referenced environment not found. Missing ${diff(
|
||||
uniqueReferenceEnvironmentSlugs,
|
||||
referencesEnvironments.map((el) => el.slug)
|
||||
).join(",")}`
|
||||
});
|
||||
|
||||
// Filter out references to non-existent environments
|
||||
const referencesEnvironmentGroupBySlug = groupBy(referencesEnvironments, (i) => i.slug);
|
||||
const validEnvironmentReferences = references.filter((el) => referencesEnvironmentGroupBySlug[el.environment]);
|
||||
|
||||
if (validEnvironmentReferences.length === 0) return;
|
||||
|
||||
const referredFolders = await folderDAL.findByManySecretPath(
|
||||
references.map((el) => ({
|
||||
validEnvironmentReferences.map((el) => ({
|
||||
secretPath: el.secretPath,
|
||||
envId: referencesEnvironmentGroupBySlug[el.environment][0].id
|
||||
})),
|
||||
@@ -177,58 +174,71 @@ export const secretV2BridgeServiceFactory = ({
|
||||
);
|
||||
|
||||
const referencesFolderGroupByPath = groupBy(referredFolders.filter(Boolean), (i) => `${i?.envId}-${i?.path}`);
|
||||
|
||||
// Find only references that have valid folders (don't throw for missing paths)
|
||||
const validReferences = validEnvironmentReferences.filter((el) => {
|
||||
const folderId =
|
||||
referencesFolderGroupByPath[`${referencesEnvironmentGroupBySlug[el.environment][0].id}-${el.secretPath}`]?.[0]
|
||||
?.id;
|
||||
return folderId;
|
||||
});
|
||||
|
||||
if (validReferences.length === 0) return;
|
||||
|
||||
const referredSecrets = await secretDAL.find(
|
||||
{
|
||||
$complex: {
|
||||
operator: "or",
|
||||
value: references.map((el) => {
|
||||
const folderId =
|
||||
referencesFolderGroupByPath[
|
||||
`${referencesEnvironmentGroupBySlug[el.environment][0].id}-${el.secretPath}`
|
||||
][0]?.id;
|
||||
if (!folderId) throw new BadRequestError({ message: `Referenced path ${el.secretPath} doesn't exist` });
|
||||
value: validReferences
|
||||
.map((el) => {
|
||||
const folderGroup =
|
||||
referencesFolderGroupByPath[
|
||||
`${referencesEnvironmentGroupBySlug[el.environment][0].id}-${el.secretPath}`
|
||||
];
|
||||
if (!folderGroup || !folderGroup[0]) return null;
|
||||
|
||||
return {
|
||||
operator: "and",
|
||||
value: [
|
||||
{
|
||||
operator: "eq",
|
||||
field: "folderId",
|
||||
value: folderId
|
||||
},
|
||||
{
|
||||
operator: "eq",
|
||||
field: `${TableName.SecretV2}.key` as "key",
|
||||
value: el.secretKey
|
||||
}
|
||||
]
|
||||
};
|
||||
})
|
||||
const folderId = folderGroup[0].id;
|
||||
|
||||
return {
|
||||
operator: "and",
|
||||
value: [
|
||||
{
|
||||
operator: "eq",
|
||||
field: "folderId",
|
||||
value: folderId
|
||||
},
|
||||
{
|
||||
operator: "eq",
|
||||
field: `${TableName.SecretV2}.key` as "key",
|
||||
value: el.secretKey
|
||||
}
|
||||
]
|
||||
};
|
||||
})
|
||||
.filter((query) => query !== null) as Array<{
|
||||
operator: "and";
|
||||
value: Array<{
|
||||
operator: "eq";
|
||||
field: "folderId" | "key";
|
||||
value: string;
|
||||
}>;
|
||||
}>
|
||||
}
|
||||
},
|
||||
{ tx }
|
||||
);
|
||||
|
||||
if (
|
||||
referredSecrets.length !==
|
||||
new Set(references.map(({ secretKey, secretPath, environment }) => `${secretKey}.${secretPath}.${environment}`))
|
||||
.size // only count unique references
|
||||
)
|
||||
throw new BadRequestError({
|
||||
message: `Referenced secret(s) not found: ${diff(
|
||||
references.map((el) => el.secretKey),
|
||||
referredSecrets.map((el) => el.key)
|
||||
).join(",")}`
|
||||
});
|
||||
|
||||
const referredSecretsGroupBySecretKey = groupBy(referredSecrets, (i) => i.key);
|
||||
references.forEach((el) => {
|
||||
throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret, {
|
||||
environment: el.environment,
|
||||
secretPath: el.secretPath,
|
||||
secretName: el.secretKey,
|
||||
secretTags: referredSecretsGroupBySecretKey[el.secretKey][0]?.tags?.map((i) => i.slug)
|
||||
});
|
||||
// Only check permissions for secrets that actually exist
|
||||
referredSecrets.forEach((secret) => {
|
||||
const reference = validReferences.find((ref) => ref.secretKey === secret.key);
|
||||
if (reference) {
|
||||
throwIfMissingSecretReadValueOrDescribePermission(permission, ProjectPermissionSecretActions.DescribeSecret, {
|
||||
environment: reference.environment,
|
||||
secretPath: reference.secretPath,
|
||||
secretName: reference.secretKey,
|
||||
secretTags: secret.tags?.map((i) => i.slug)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return referredSecrets;
|
||||
@@ -548,6 +558,14 @@ export const secretV2BridgeServiceFactory = ({
|
||||
);
|
||||
}
|
||||
|
||||
if (secretValue) {
|
||||
const { nestedReferences, localReferences } = getAllSecretReferences(secretValue);
|
||||
const allSecretReferences = nestedReferences.concat(
|
||||
localReferences.map((el) => ({ secretKey: el, secretPath, environment }))
|
||||
);
|
||||
await $validateSecretReferences(projectId, permission, allSecretReferences);
|
||||
}
|
||||
|
||||
const { encryptor: secretManagerEncryptor } = await kmsService.createCipherPairWithDataKey({
|
||||
type: KmsDataKey.SecretManager,
|
||||
projectId
|
||||
@@ -3161,6 +3179,7 @@ export const secretV2BridgeServiceFactory = ({
|
||||
getSecretById,
|
||||
getAccessibleSecrets,
|
||||
getSecretVersionsByIds,
|
||||
findSecretIdsByFolderIdAndKeys
|
||||
findSecretIdsByFolderIdAndKeys,
|
||||
$validateSecretReferences
|
||||
};
|
||||
};
|
||||
|
||||
4
docs/api-reference/endpoints/certificate-syncs/list.mdx
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "List PKI Syncs"
|
||||
openapi: "GET /api/v1/pki/syncs"
|
||||
---
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Options"
|
||||
openapi: "GET /api/v1/pki/syncs/options"
|
||||
---
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Create Azure Key Vault PKI Sync"
|
||||
openapi: "POST /api/v1/pki/syncs/azure-key-vault"
|
||||
---
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Delete Azure Key Vault PKI Sync"
|
||||
openapi: "DELETE /api/v1/pki/syncs/azure-key-vault/{pkiSyncId}"
|
||||
---
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Get Azure Key Vault PKI Sync by ID"
|
||||
openapi: "GET /api/v1/pki/syncs/azure-key-vault/{pkiSyncId}"
|
||||
---
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "List Azure Key Vault PKI Syncs"
|
||||
openapi: "GET /api/v1/pki/syncs/azure-key-vault"
|
||||
---
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Remove Certificates from Azure Key Vault"
|
||||
openapi: "POST /api/v1/pki/syncs/azure-key-vault/{pkiSyncId}/remove-certificates"
|
||||
---
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Sync Certificates to Azure Key Vault"
|
||||
openapi: "POST /api/v1/pki/syncs/azure-key-vault/{pkiSyncId}/sync"
|
||||
---
|
||||
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Update Azure Key Vault PKI Sync"
|
||||
openapi: "PATCH /api/v1/pki/syncs/azure-key-vault/{pkiSyncId}"
|
||||
---
|
||||
4
docs/api-reference/endpoints/pki/syncs/get-by-id.mdx
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "Get PKI Sync by ID"
|
||||
openapi: "GET /api/v1/pki/syncs/{pkiSyncId}"
|
||||
---
|
||||
4
docs/api-reference/endpoints/pki/syncs/list.mdx
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "List PKI Syncs"
|
||||
openapi: "GET /api/v1/pki/syncs"
|
||||
---
|
||||
4
docs/api-reference/endpoints/pki/syncs/options.mdx
Normal file
@@ -0,0 +1,4 @@
|
||||
---
|
||||
title: "List PKI Sync Options"
|
||||
openapi: "GET /api/v1/pki/syncs/options"
|
||||
---
|
||||
@@ -714,6 +714,18 @@
|
||||
"documentation/platform/pki/pki-issuer",
|
||||
"documentation/platform/pki/integration-guides/gloo-mesh"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Certificate Syncs",
|
||||
"pages": [
|
||||
"documentation/platform/pki/certificate-syncs/overview",
|
||||
{
|
||||
"group": "Syncs",
|
||||
"pages": [
|
||||
"documentation/platform/pki/certificate-syncs/azure-key-vault"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2503,6 +2515,26 @@
|
||||
"api-reference/endpoints/pki-alerts/update",
|
||||
"api-reference/endpoints/pki-alerts/delete"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Certificate Syncs",
|
||||
"pages": [
|
||||
"api-reference/endpoints/pki/syncs/list",
|
||||
"api-reference/endpoints/pki/syncs/get-by-id",
|
||||
"api-reference/endpoints/pki/syncs/options",
|
||||
{
|
||||
"group": "Azure Key Vault",
|
||||
"pages": [
|
||||
"api-reference/endpoints/pki/syncs/azure-key-vault/list",
|
||||
"api-reference/endpoints/pki/syncs/azure-key-vault/get-by-id",
|
||||
"api-reference/endpoints/pki/syncs/azure-key-vault/create",
|
||||
"api-reference/endpoints/pki/syncs/azure-key-vault/update",
|
||||
"api-reference/endpoints/pki/syncs/azure-key-vault/delete",
|
||||
"api-reference/endpoints/pki/syncs/azure-key-vault/sync-certificates",
|
||||
"api-reference/endpoints/pki/syncs/azure-key-vault/remove-certificates"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
---
|
||||
title: "Azure Key Vault"
|
||||
description: "Learn how to configure an Azure Key Vault Certificate Sync for Infisical PKI."
|
||||
---
|
||||
|
||||
**Prerequisites:**
|
||||
|
||||
- Set up and configure a [Certificate Authority](/documentation/platform/pki/overview)
|
||||
- Create an [Azure Key Vault Connection](/integrations/app-connections/azure-key-vault)
|
||||
- Ensure your network security policies allow incoming requests from Infisical to this certificate sync provider, if network restrictions apply.
|
||||
|
||||
<Note>
|
||||
The Azure Key Vault Certificate Sync requires the following certificate permissions to be set on the user / service principal
|
||||
for Infisical to sync certificates to Azure Key Vault: `certificates/list`, `certificates/get`, `certificates/import`, `certificates/delete`.
|
||||
|
||||
Any role with these permissions would work such as the **Key Vault Certificates Officer** role.
|
||||
</Note>
|
||||
|
||||
<Note>
|
||||
Certificates synced to Azure Key Vault will be stored as certificate objects, preserving both the certificate and private key components.
|
||||
</Note>
|
||||
|
||||
<Tabs>
|
||||
<Tab title="Infisical UI">
|
||||
1. Navigate to **Project** > **Integrations** and select the **Certificate Syncs** tab. Click on the **Add Sync** button.
|
||||

|
||||
|
||||
2. Select the **Azure Key Vault** option.
|
||||

|
||||
|
||||
3. Configure the **Source** from where certificates should be retrieved, then click **Next**.
|
||||

|
||||
|
||||
- **PKI Subscriber**: The PKI subscriber to retrieve certificates from.
|
||||
|
||||
4. Configure the **Destination** to where certificates should be deployed, then click **Next**.
|
||||

|
||||
|
||||
- **Azure Connection**: The Azure Connection to authenticate with.
|
||||
- **Vault Base URL**: The URL of your Azure Key Vault.
|
||||
<p class="height:1px" />
|
||||
|
||||
5. Configure the **Sync Options** to specify how certificates should be synced, then click **Next**.
|
||||

|
||||
|
||||
- **Auto-Sync Enabled**: If enabled, certificates will automatically be synced from the source PKI subscriber when changes occur. Disable to enforce manual syncing only.
|
||||
- **Enable Certificate Removal**: If enabled, Infisical will remove expired certificates from the destination during sync operations. Disable this option if you intend to manage certificate cleanup manually.
|
||||
- **Certificate Name Schema** (Optional): Customize how certificate names are generated in Azure Key Vault. Use `{{certificateId}}` as a placeholder for the certificate ID. If not specified, defaults to `Infisical-{{certificateId}}`.
|
||||
|
||||
<Tip>
|
||||
**Azure Key Vault Soft Delete**: When certificates are removed from Azure Key Vault, they are placed in a soft-deleted state rather than being permanently deleted. This means:
|
||||
- Subsequent syncs will not re-add these soft-deleted certificates automatically
|
||||
- To resync removed certificates, you must either manually **purge** them from Azure Key Vault or **recover** them through the Azure portal/CLI
|
||||
</Tip>
|
||||
|
||||
6. Configure the **Details** of your Azure Key Vault Certificate Sync, then click **Next**.
|
||||

|
||||
|
||||
- **Name**: The name of your sync. Must be slug-friendly.
|
||||
- **Description**: An optional description for your sync.
|
||||
|
||||
7. Review your Azure Key Vault Certificate Sync configuration, then click **Create Sync**.
|
||||

|
||||
|
||||
8. If enabled, your Azure Key Vault Certificate Sync will begin syncing your certificates to the destination endpoint.
|
||||

|
||||
|
||||
</Tab>
|
||||
<Tab title="API">
|
||||
To create an **Azure Key Vault Certificate Sync**, make an API request to the [Create Azure Key Vault Certificate Sync](/api-reference/endpoints/pki/syncs/azure-key-vault/create) API endpoint.
|
||||
|
||||
### Sample request
|
||||
|
||||
```bash Request
|
||||
curl --request POST \
|
||||
--url https://app.infisical.com/api/v1/pki/syncs/azure-key-vault \
|
||||
--header 'Content-Type: application/json' \
|
||||
--data '{
|
||||
"name": "my-key-vault-cert-sync",
|
||||
"projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
|
||||
"description": "an example certificate sync",
|
||||
"connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
|
||||
"subscriberId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
|
||||
"destination": "azure-key-vault",
|
||||
"isAutoSyncEnabled": true,
|
||||
"syncOptions": {
|
||||
"canRemoveCertificates": true,
|
||||
"certificateNameSchema": "myapp-{{certificateId}}"
|
||||
},
|
||||
"destinationConfig": {
|
||||
"vaultBaseUrl": "https://my-key-vault.vault.azure.net"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
### Sample response
|
||||
|
||||
```json Response
|
||||
{
|
||||
"pkiSync": {
|
||||
"id": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
|
||||
"name": "my-key-vault-cert-sync",
|
||||
"description": "an example certificate sync",
|
||||
"destination": "azure-key-vault",
|
||||
"isAutoSyncEnabled": true,
|
||||
"destinationConfig": {
|
||||
"vaultBaseUrl": "https://my-key-vault.vault.azure.net"
|
||||
},
|
||||
"syncOptions": {
|
||||
"canRemoveCertificates": true,
|
||||
"certificateNameSchema": "myapp-{{certificateId}}"
|
||||
},
|
||||
"projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
|
||||
"subscriberId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
|
||||
"connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a",
|
||||
"createdAt": "2023-01-01T00:00:00.000Z",
|
||||
"updatedAt": "2023-01-01T00:00:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## Certificate Management
|
||||
|
||||
Your Azure Key Vault Certificate Sync will:
|
||||
|
||||
- **Automatic Deployment**: Deploy new certificates issued by your PKI subscriber to Azure Key Vault
|
||||
- **Certificate Updates**: Update certificates in Azure Key Vault when renewals occur
|
||||
- **Expiration Handling**: Optionally remove expired certificates from Azure Key Vault (if enabled)
|
||||
- **Format Preservation**: Maintain certificate format and metadata during sync operations
|
||||
|
||||
<Note>
|
||||
Azure Key Vault Certificate Syncs support both automatic and manual synchronization modes. When auto-sync is enabled, certificates are automatically deployed as they are issued or renewed.
|
||||
</Note>
|
||||
|
||||
## Manual Certificate Sync
|
||||
|
||||
You can manually trigger certificate synchronization from your PKI subscriber to Azure Key Vault using the sync certificates functionality. This is useful for:
|
||||
|
||||
- Initial setup when you have existing certificates to deploy
|
||||
- One-time sync of specific certificates
|
||||
- Testing certificate sync configurations
|
||||
- Force sync after making changes
|
||||
|
||||
To manually sync certificates, use the [Sync Certificates](/api-reference/endpoints/pki/syncs/azure-key-vault/sync-certificates) API endpoint or the manual sync option in the Infisical UI.
|
||||
|
||||
<Note>
|
||||
Azure Key Vault does not support importing certificates back into Infisical due to security limitations where private keys cannot be extracted from Azure Key Vault.
|
||||
</Note>
|
||||
142
docs/documentation/platform/pki/certificate-syncs/overview.mdx
Normal file
@@ -0,0 +1,142 @@
|
||||
---
|
||||
sidebarTitle: "Overview"
|
||||
description: "Learn how to sync certificates from Infisical PKI to third-party services."
|
||||
---
|
||||
|
||||
Certificate Syncs enable you to sync certificates from Infisical PKI to third-party services using [App Connections](/integrations/app-connections/overview).
|
||||
|
||||
<Note>
|
||||
Certificate Syncs are designed to automatically deploy certificates issued by your Certificate Authority to external services, ensuring your certificates are always up-to-date across your infrastructure.
|
||||
</Note>
|
||||
|
||||
## Concept
|
||||
|
||||
Certificate Syncs are a project-level resource used to sync certificates, via an [App Connection](/integrations/app-connections/overview), from a particular PKI subscriber (source)
|
||||
to a third-party service (destination). When new certificates are issued or existing certificates are renewed, changes will automatically be propagated to the destination, ensuring
|
||||
your certificates are always current.
|
||||
|
||||
<br />
|
||||
|
||||
<div align="center">
|
||||
|
||||
```mermaid
|
||||
%%{init: {'flowchart': {'curve': 'linear'} } }%%
|
||||
graph LR
|
||||
A[App Connection]
|
||||
B[Certificate Sync]
|
||||
C[Certificate 1]
|
||||
D[Certificate 2]
|
||||
E[Certificate 3]
|
||||
F[Third-Party Service]
|
||||
G[Certificate 1]
|
||||
H[Certificate 2]
|
||||
I[Certificate 3]
|
||||
J[PKI Subscriber]
|
||||
|
||||
B --> A
|
||||
C --> J
|
||||
D --> J
|
||||
E --> J
|
||||
A --> F
|
||||
F --> G
|
||||
F --> H
|
||||
F --> I
|
||||
J --> B
|
||||
|
||||
classDef default fill:#ffffff,stroke:#666,stroke-width:2px,rx:10px,color:black
|
||||
classDef connection fill:#FFF2B2,stroke:#E6C34A,stroke-width:2px,color:black,rx:15px
|
||||
classDef certificate fill:#E6F4FF,stroke:#0096D6,stroke-width:2px,color:black,rx:15px
|
||||
classDef sync fill:#F4FFE6,stroke:#96D600,stroke-width:2px,color:black,rx:15px
|
||||
classDef service fill:#E6E6FF,stroke:#6B4E96,stroke-width:2px,color:black,rx:15px
|
||||
classDef subscriber fill:#FFE6E6,stroke:#D63F3F,stroke-width:2px,color:black,rx:15px
|
||||
|
||||
class A connection
|
||||
class B sync
|
||||
class C,D,E,G,H,I certificate
|
||||
class F service
|
||||
class J subscriber
|
||||
```
|
||||
|
||||
</div>
|
||||
|
||||
## Workflow
|
||||
|
||||
Configuring a Certificate Sync requires three components: a <strong>source</strong> PKI subscriber to retrieve certificates from,
|
||||
a <strong>destination</strong> endpoint to deploy certificates to, and <strong>configuration options</strong> to determine how your certificates
|
||||
should be synced. Follow these steps to start syncing:
|
||||
|
||||
<Note>
|
||||
For step-by-step guides on syncing to a particular third-party service, refer to the Certificate Syncs section in the Navigation Bar.
|
||||
</Note>
|
||||
|
||||
1. <strong>Create App Connection:</strong> If you have not already done so, create an [App Connection](/integrations/app-connections/overview)
|
||||
via the UI or API for the third-party service you intend to sync certificates to.
|
||||
|
||||
2. <strong>Create Certificate Sync:</strong> Configure a Certificate Sync in the desired project by specifying the following parameters via the UI or API:
|
||||
- <strong>Source:</strong> The PKI subscriber you wish to retrieve certificates from.
|
||||
- <strong>Destination:</strong> The App Connection to utilize and the destination endpoint to deploy certificates to. These can vary between services.
|
||||
- <strong>Options:</strong> Customize how certificates should be synced, including:
|
||||
- Whether certificates should be removed from the destination when they expire
|
||||
- Certificate naming schema to control how certificate names are generated in the destination
|
||||
|
||||
<Note>
|
||||
Only certificates managed by Infisical will be affected during sync operations. Certificates not created or
|
||||
managed by Infisical will remain untouched, and changes made to Infisical-managed certificates directly
|
||||
in the destination service may be overwritten by future syncs.
|
||||
</Note>
|
||||
|
||||
<Info>
|
||||
Some third-party services do not support removing expired certificates automatically.
|
||||
</Info>
|
||||
|
||||
3. <strong>Utilize Sync:</strong> Any new certificates issued or renewals from the source PKI subscriber will now automatically be propagated to the destination endpoint.
|
||||
|
||||
<Note>
|
||||
Infisical is continuously expanding its Certificate Sync third-party service support. If the service you need isn't available,
|
||||
contact us at team@infisical.com to make a request.
|
||||
</Note>
|
||||
|
||||
## Certificate Naming
|
||||
|
||||
Certificate Syncs support flexible certificate naming through configurable naming schemas. This allows you to customize how certificate names appear in your destination services.
|
||||
|
||||
### Default Naming
|
||||
|
||||
By default, certificates are named using the pattern `Infisical-{certificateId}` where `{certificateId}` is the unique identifier of the certificate with hyphens removed for compatibility with services like Azure Key Vault.
|
||||
|
||||
### Custom Naming Schema
|
||||
|
||||
You can customize certificate naming by providing a **Certificate Name Schema** when creating or updating a Certificate Sync. The schema supports the following placeholders:
|
||||
|
||||
- `{{certificateId}}` - The unique certificate identifier (required)
|
||||
|
||||
**Examples:**
|
||||
- `myapp-{{certificateId}}` → `myapp-abc123def456`
|
||||
- `ssl/{{certificateId}}` → `ssl/abc123def456`
|
||||
|
||||
**Rules:**
|
||||
- Must include exactly one `{{certificateId}}` placeholder
|
||||
- Only alphanumeric characters, dashes (-), underscores (_), and slashes (/) are allowed
|
||||
- Certificate names matching your schema will be managed by Infisical during sync operations
|
||||
|
||||
## Certificate Management
|
||||
|
||||
Certificate Syncs handle the full lifecycle of certificate management:
|
||||
|
||||
- **Automatic Deployment**: New certificates are automatically deployed to configured destinations
|
||||
- **Renewal Propagation**: Certificate renewals are seamlessly pushed to all connected services
|
||||
- **Expiration Handling**: Expired certificates can be automatically removed from destinations (service-dependent)
|
||||
- **Certificate Validation**: Certificates are validated before deployment to ensure integrity
|
||||
|
||||
<div align="center">
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Certificate Issued] -->|Deploy| B[Destination Service]
|
||||
C[Certificate Renewed] -->|Update| B
|
||||
D[Certificate Expired] -->|Remove| B
|
||||
style B fill:#F4FFE6,stroke:#96D600,stroke-width:2px,color:black,rx:15px
|
||||
style A fill:#E6F4FF,stroke:#0096D6,stroke-width:2px,color:black,rx:15px
|
||||
style C fill:#E6F4FF,stroke:#0096D6,stroke-width:2px,color:black,rx:15px
|
||||
style D fill:#FFE6E6,stroke:#D63F3F,stroke-width:2px,color:black,rx:15px
|
||||
```
|
||||
</div>
|
||||
|
After Width: | Height: | Size: 470 KiB |
|
After Width: | Height: | Size: 525 KiB |
BIN
docs/images/certificate-syncs/azure-key-vault/vault-details.png
Normal file
|
After Width: | Height: | Size: 522 KiB |
BIN
docs/images/certificate-syncs/azure-key-vault/vault-options.png
Normal file
|
After Width: | Height: | Size: 520 KiB |
BIN
docs/images/certificate-syncs/azure-key-vault/vault-review.png
Normal file
|
After Width: | Height: | Size: 544 KiB |
BIN
docs/images/certificate-syncs/azure-key-vault/vault-source.png
Normal file
|
After Width: | Height: | Size: 498 KiB |
BIN
docs/images/certificate-syncs/azure-key-vault/vault-synced.png
Normal file
|
After Width: | Height: | Size: 807 KiB |
BIN
docs/images/certificate-syncs/general/certificate-sync-tab.png
Normal file
|
After Width: | Height: | Size: 782 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 459 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 425 KiB |
@@ -24,7 +24,7 @@ Infisical supports two methods for connecting to Heroku: **OAuth** and **Auth To
|
||||

|
||||

|
||||
|
||||
Create the API client. As part of the form, set the **OAuth callback URL** to `https://your-domain.com/integrations/heroku/oauth2/callback`.
|
||||
Create the API client. As part of the form, set the **OAuth callback URL** to `https://your-domain.com/organization/app-connections/heroku/oauth/callback`.
|
||||
|
||||
<Tip>
|
||||
The domain you defined in the OAuth callback URL should be equivalent to the `SITE_URL` configured in your Infisical instance.
|
||||
@@ -39,8 +39,8 @@ Infisical supports two methods for connecting to Heroku: **OAuth** and **Auth To
|
||||
|
||||
Back in your Infisical instance, add two new environment variables for the credentials of your Heroku API client:
|
||||
|
||||
- `CLIENT_ID_HEROKU`: The **Client ID** of your Heroku API client.
|
||||
- `CLIENT_SECRET_HEROKU`: The **Client Secret** of your Heroku API client.
|
||||
- `INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_ID`: The **Client ID** of your Heroku API client.
|
||||
- `INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_SECRET`: The **Client Secret** of your Heroku API client.
|
||||
|
||||
Once added, restart your Infisical instance and use the Heroku Connection.
|
||||
</Step>
|
||||
|
||||
@@ -366,3 +366,21 @@ password = "{{ .Value }}"
|
||||
**Returns**: A list of secret objects with the following keys `Key, WorkspaceId, Value, Type, ID, and Comment`
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="dynamic_secret">
|
||||
```bash
|
||||
dynamic_secret "<project-slug>" "<environment-slug>" "<secret-path>" "<dynamic-secret-name>" "<lease-ttl>"
|
||||
```
|
||||
|
||||
```bash example-redis-dynamic-secret
|
||||
{{ with dynamic_secret "aaa-o7en-s5qm" "dev" "/" "redis" "1m" }}
|
||||
{{ .DB_USERNAME }}={{ .DB_PASSWORD }}
|
||||
{{- end }}
|
||||
|
||||
**Function Name**: dynamic_secret
|
||||
|
||||
**Description**: This function can be used to render a dynamic secret lease credentials. The credentials are automatically renewed before they expire, ensuring that the rendered credentials are always up-to-date.
|
||||
|
||||
**Returns**: An object with keys corresponding to the dynamic secret lease credentials.
|
||||
```
|
||||
</Accordion>
|
||||
@@ -59,9 +59,10 @@ The Infisical Agent Injector supports the following annotations:
|
||||
The inject annotation is used to enable the injector on a pod. Set the value to `true` and the pod will be patched with an Infisical Agent container on update or create.
|
||||
</Accordion>
|
||||
<Accordion title="org.infisical.com/inject-mode">
|
||||
The inject mode annotation is used to specify the mode to use to inject the secrets into the pod. Currently only `init` mode is supported.
|
||||
The inject mode annotation is used to specify the mode to use to inject the secrets into the pod.
|
||||
|
||||
- `init`: The init method will create an init container for the pod that will render the secrets into a shared volume mount within the pod. The agent init container will run before any other containers in the pod runs, including other init containers.
|
||||
- `sidecar`: The sidecar method will create a sidecar container for the pod that will render the secrets into a shared volume mount within the pod. The agent sidecar container will run alongside the main container in the pod. This means that the secrets rendered will always be in sync with your Infisical secrets.
|
||||
</Accordion>
|
||||
<Accordion title="org.infisical.com/agent-config-map">
|
||||
The agent config map annotation is used to specify the name of the config map that contains the configuration for the injector. The config map must be in the same namespace as the pod.
|
||||
@@ -203,7 +204,7 @@ metadata:
|
||||
app: demo
|
||||
annotations:
|
||||
org.infisical.com/inject: "true" # Set to true for the injector to patch the pod on create/update events
|
||||
org.infisical.com/inject-mode: "init" # The mode to use to inject the secrets into the pod. Currently only `init` mode is supported.
|
||||
org.infisical.com/inject-mode: "init" # The mode to use to inject the secrets into the pod. init|sidecar
|
||||
org.infisical.com/agent-config-map: "name-of-config-map" # The name of the config map that you created above, which contains all the settings for injecting the secrets into the pod
|
||||
spec:
|
||||
# ...
|
||||
|
||||
@@ -146,6 +146,7 @@ spec:
|
||||
projectSlug: <project-slug> # <-- project slug
|
||||
projectId: <project-id> # <-- project id
|
||||
|
||||
secretName: <secret-name> # OPTIONAL: If you want to fetch a single Infisical secret, you can specify the secret name here. If not specified, all secrets in the specified scope will be fetched.
|
||||
envSlug: <env-slug> # "dev", "staging", "prod", etc..
|
||||
secretsPath: "<secrets-path>" # Root is "/"
|
||||
credentialsRef:
|
||||
@@ -331,6 +332,7 @@ spec:
|
||||
projectSlug: your-project-slug
|
||||
envSlug: prod
|
||||
secretsPath: "/path"
|
||||
secretName: <secret-name> # OPTIONAL: If you want to fetch a single Infisical secret, you can specify the secret name here. If not specified, all secrets in the specified scope will be fetched.
|
||||
recursive: true
|
||||
...
|
||||
```
|
||||
@@ -526,6 +528,7 @@ spec:
|
||||
projectSlug: your-project-slug
|
||||
envSlug: prod
|
||||
secretsPath: "/path"
|
||||
secretName: <secret-name> # OPTIONAL: If you want to fetch a single Infisical secret, you can specify the secret name here. If not specified, all secrets in the specified scope will be fetched.
|
||||
recursive: true
|
||||
...
|
||||
```
|
||||
@@ -574,6 +577,7 @@ spec:
|
||||
projectSlug: your-project-slug
|
||||
envSlug: prod
|
||||
secretsPath: "/path"
|
||||
secretName: <secret-name> # OPTIONAL: If you want to fetch a single Infisical secret, you can specify the secret name here. If not specified, all secrets in the specified scope will be fetched.
|
||||
recursive: true
|
||||
...
|
||||
```
|
||||
@@ -619,6 +623,7 @@ spec:
|
||||
projectSlug: your-project-slug
|
||||
envSlug: prod
|
||||
secretsPath: "/path"
|
||||
secretName: <secret-name> # OPTIONAL: If you want to fetch a single Infisical secret, you can specify the secret name here. If not specified, all secrets in the specified scope will be fetched.
|
||||
recursive: true
|
||||
...
|
||||
```
|
||||
@@ -664,6 +669,7 @@ spec:
|
||||
projectSlug: your-project-slug
|
||||
envSlug: prod
|
||||
secretsPath: "/path"
|
||||
secretName: <secret-name> # OPTIONAL: If you want to fetch a single Infisical secret, you can specify the secret name here. If not specified, all secrets in the specified scope will be fetched.
|
||||
recursive: true
|
||||
...
|
||||
```
|
||||
@@ -711,6 +717,7 @@ spec:
|
||||
projectSlug: your-project-slug
|
||||
envSlug: prod
|
||||
secretsPath: "/path"
|
||||
secretName: <secret-name> # OPTIONAL: If you want to fetch a single Infisical secret, you can specify the secret name here. If not specified, all secrets in the specified scope will be fetched.
|
||||
recursive: true
|
||||
...
|
||||
```
|
||||
@@ -764,6 +771,7 @@ spec:
|
||||
projectSlug: <project-slug> # <-- project slug
|
||||
envSlug: <env-slug> # "dev", "staging", "prod", etc..
|
||||
secretsPath: "<secrets-path>" # Root is "/"
|
||||
secretName: <secret-name> # OPTIONAL: If you want to fetch a single Infisical secret, you can specify the secret name here. If not specified, all secrets in the specified scope will be fetched.
|
||||
identityId: <machine-identity-id>
|
||||
credentialsRef:
|
||||
secretName: ldap-auth-credentials # <-- name of the Kubernetes secret that stores our machine identity credentials
|
||||
|
||||
@@ -672,6 +672,16 @@ You can configure third-party app connections for re-use across Infisical Projec
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Heroku OAuth Connection">
|
||||
<ParamField query="INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_ID" type="string" default="none" optional>
|
||||
The Application ID of your Heroku OAuth application.
|
||||
</ParamField>
|
||||
|
||||
<ParamField query="INF_APP_CONNECTION_HEROKU_OAUTH_CLIENT_SECRET" type="string" default="none" optional>
|
||||
The Secret of your Heroku OAuth application.
|
||||
</ParamField>
|
||||
</Accordion>
|
||||
|
||||
## Native Secret Integrations
|
||||
|
||||
To help you sync secrets from Infisical to services such as Github and Gitlab, Infisical provides native integrations out of the box.
|
||||
|
||||
76
frontend/src/components/pki-syncs/CreatePkiSyncModal.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Modal, ModalContent } from "@app/components/v2";
|
||||
import { PkiSync, TPkiSync } from "@app/hooks/api/pkiSyncs";
|
||||
|
||||
import { CreatePkiSyncForm } from "./forms";
|
||||
import { PkiSyncModalHeader } from "./PkiSyncModalHeader";
|
||||
import { PkiSyncSelect } from "./PkiSyncSelect";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
selectSync?: PkiSync | null;
|
||||
};
|
||||
|
||||
type ContentProps = {
|
||||
onComplete: (pkiSync: TPkiSync) => void;
|
||||
selectedSync: PkiSync | null;
|
||||
setSelectedSync: (selectedSync: PkiSync | null) => void;
|
||||
};
|
||||
|
||||
const Content = ({ onComplete, setSelectedSync, selectedSync }: ContentProps) => {
|
||||
if (selectedSync) {
|
||||
return (
|
||||
<CreatePkiSyncForm
|
||||
onComplete={onComplete}
|
||||
onCancel={() => setSelectedSync(null)}
|
||||
destination={selectedSync}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <PkiSyncSelect onSelect={setSelectedSync} />;
|
||||
};
|
||||
|
||||
export const CreatePkiSyncModal = ({ onOpenChange, selectSync = null, ...props }: Props) => {
|
||||
const [selectedSync, setSelectedSync] = useState<PkiSync | null>(selectSync);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedSync(selectSync);
|
||||
}, [selectSync]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
{...props}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) setSelectedSync(null);
|
||||
onOpenChange(isOpen);
|
||||
}}
|
||||
>
|
||||
<ModalContent
|
||||
title={
|
||||
selectedSync ? (
|
||||
<PkiSyncModalHeader isConfigured={false} destination={selectedSync} />
|
||||
) : (
|
||||
"Add Sync"
|
||||
)
|
||||
}
|
||||
className="max-w-2xl"
|
||||
bodyClassName="overflow-visible"
|
||||
subTitle={
|
||||
selectedSync ? undefined : "Select a third-party service to sync certificates to."
|
||||
}
|
||||
>
|
||||
<Content
|
||||
onComplete={() => {
|
||||
setSelectedSync(null);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
selectedSync={selectedSync}
|
||||
setSelectedSync={setSelectedSync}
|
||||
/>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
56
frontend/src/components/pki-syncs/DeletePkiSyncModal.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { DeleteActionModal } from "@app/components/v2";
|
||||
import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs";
|
||||
import { TPkiSync, useDeletePkiSync } from "@app/hooks/api/pkiSyncs";
|
||||
|
||||
type Props = {
|
||||
pkiSync?: TPkiSync;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
onComplete?: () => void;
|
||||
};
|
||||
|
||||
export const DeletePkiSyncModal = ({ isOpen, onOpenChange, pkiSync, onComplete }: Props) => {
|
||||
const deleteSync = useDeletePkiSync();
|
||||
|
||||
if (!pkiSync) return null;
|
||||
|
||||
const { id: syncId, name, destination, projectId } = pkiSync;
|
||||
|
||||
const handleDeletePkiSync = async () => {
|
||||
const destinationName = PKI_SYNC_MAP[destination].name;
|
||||
|
||||
try {
|
||||
await deleteSync.mutateAsync({
|
||||
syncId,
|
||||
projectId,
|
||||
destination
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: `Successfully deleted ${destinationName} PKI Sync`,
|
||||
type: "success"
|
||||
});
|
||||
|
||||
if (onComplete) onComplete();
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
createNotification({
|
||||
text: `Failed to delete ${destinationName} PKI Sync`,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DeleteActionModal
|
||||
isOpen={isOpen}
|
||||
onChange={onOpenChange}
|
||||
title={`Are you sure you want to delete ${name}?`}
|
||||
deleteKey={name}
|
||||
onDeleteApproved={handleDeletePkiSync}
|
||||
/>
|
||||
);
|
||||
};
|
||||
29
frontend/src/components/pki-syncs/EditPkiSyncModal.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { PkiSyncEditFields } from "@app/components/pki-syncs/types";
|
||||
import { Modal, ModalContent } from "@app/components/v2";
|
||||
import { TPkiSync } from "@app/hooks/api/pkiSyncs";
|
||||
|
||||
import { EditPkiSyncForm } from "./forms";
|
||||
import { PkiSyncModalHeader } from "./PkiSyncModalHeader";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
pkiSync?: TPkiSync;
|
||||
fields: PkiSyncEditFields;
|
||||
};
|
||||
|
||||
export const EditPkiSyncModal = ({ pkiSync, onOpenChange, fields, ...props }: Props) => {
|
||||
if (!pkiSync) return null;
|
||||
|
||||
return (
|
||||
<Modal {...props} onOpenChange={onOpenChange}>
|
||||
<ModalContent
|
||||
title={<PkiSyncModalHeader isConfigured destination={pkiSync.destination} />}
|
||||
className="max-w-2xl"
|
||||
bodyClassName="overflow-visible"
|
||||
>
|
||||
<EditPkiSyncForm onComplete={() => onOpenChange(false)} fields={fields} pkiSync={pkiSync} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,95 @@
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, Modal, ModalClose, ModalContent } from "@app/components/v2";
|
||||
import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs";
|
||||
import { TPkiSync, useTriggerPkiSyncImportCertificates } from "@app/hooks/api/pkiSyncs";
|
||||
|
||||
type Props = {
|
||||
pkiSync?: TPkiSync;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
type ContentProps = {
|
||||
pkiSync: TPkiSync;
|
||||
onComplete: () => void;
|
||||
};
|
||||
|
||||
const Content = ({ pkiSync, onComplete }: ContentProps) => {
|
||||
const { id: syncId, destination, projectId } = pkiSync;
|
||||
const destinationName = PKI_SYNC_MAP[destination].name;
|
||||
|
||||
const triggerImportCertificates = useTriggerPkiSyncImportCertificates();
|
||||
|
||||
const handleTriggerImportCertificates = async () => {
|
||||
try {
|
||||
await triggerImportCertificates.mutateAsync({
|
||||
syncId,
|
||||
destination,
|
||||
projectId
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: `Successfully triggered certificate import for ${destinationName} Sync`,
|
||||
type: "success"
|
||||
});
|
||||
|
||||
onComplete();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
createNotification({
|
||||
text: `Failed to trigger certificate import for ${destinationName} Sync`,
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleTriggerImportCertificates();
|
||||
}}
|
||||
>
|
||||
<p className="mb-8 text-sm text-mineshaft-200">
|
||||
Are you sure you want to import certificates from this {destinationName} destination into
|
||||
Infisical?
|
||||
</p>
|
||||
<p className="mb-6 text-xs text-bunker-300">
|
||||
This operation will retrieve certificates from {destinationName} and make them available in
|
||||
your PKI subscriber. Only certificates that are not already imported will be processed.
|
||||
</p>
|
||||
<div className="mt-8 flex w-full items-center justify-between gap-2">
|
||||
<ModalClose asChild>
|
||||
<Button colorSchema="secondary" variant="plain">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={triggerImportCertificates.isPending}
|
||||
colorSchema="secondary"
|
||||
>
|
||||
Import Certificates
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export const PkiSyncImportCertificatesModal = ({ isOpen, onOpenChange, pkiSync }: Props) => {
|
||||
if (!pkiSync) return null;
|
||||
|
||||
const destinationName = PKI_SYNC_MAP[pkiSync.destination].name;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent
|
||||
title="Import Certificates"
|
||||
subTitle={`Import certificates into Infisical from this ${destinationName} Sync destination.`}
|
||||
>
|
||||
<Content pkiSync={pkiSync} onComplete={() => onOpenChange(false)} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
112
frontend/src/components/pki-syncs/PkiSyncImportStatusBadge.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { ReactNode, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
faCheck,
|
||||
faDownload,
|
||||
faTriangleExclamation,
|
||||
faXmark,
|
||||
IconDefinition
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { differenceInSeconds } from "date-fns";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { Badge, Tooltip } from "@app/components/v2";
|
||||
import { BadgeProps } from "@app/components/v2/Badge/Badge";
|
||||
import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs";
|
||||
import { PkiSyncStatus, TPkiSync } from "@app/hooks/api/pkiSyncs";
|
||||
|
||||
type Props = {
|
||||
pkiSync: TPkiSync;
|
||||
className?: string;
|
||||
mini?: boolean;
|
||||
};
|
||||
|
||||
export const PkiSyncImportStatusBadge = ({ pkiSync, className, mini }: Props) => {
|
||||
const { importStatus, lastImportMessage, lastImportedAt, destination } = pkiSync;
|
||||
const [hide, setHide] = useState(importStatus === PkiSyncStatus.Succeeded);
|
||||
const destinationName = PKI_SYNC_MAP[destination].name;
|
||||
|
||||
useEffect(() => {
|
||||
if (importStatus === PkiSyncStatus.Succeeded) {
|
||||
setTimeout(() => setHide(true), 3000);
|
||||
} else {
|
||||
setHide(false);
|
||||
}
|
||||
}, [importStatus]);
|
||||
|
||||
const failureMessage = useMemo(() => {
|
||||
if (importStatus === PkiSyncStatus.Failed) {
|
||||
if (lastImportMessage)
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(lastImportMessage), null, 2);
|
||||
} catch {
|
||||
return lastImportMessage;
|
||||
}
|
||||
|
||||
return "An Unknown Error Occurred.";
|
||||
}
|
||||
return null;
|
||||
}, [importStatus, lastImportMessage]);
|
||||
|
||||
if (!importStatus || hide) return null;
|
||||
|
||||
let variant: BadgeProps["variant"];
|
||||
let label: string;
|
||||
let icon: IconDefinition;
|
||||
let tooltipContent: ReactNode;
|
||||
|
||||
switch (importStatus) {
|
||||
case PkiSyncStatus.Pending:
|
||||
case PkiSyncStatus.Running:
|
||||
variant = "primary";
|
||||
label = "Importing Certificates...";
|
||||
tooltipContent = `Importing certificates from ${destinationName}. This may take a moment.`;
|
||||
icon = faDownload;
|
||||
|
||||
break;
|
||||
case PkiSyncStatus.Failed:
|
||||
variant = "danger";
|
||||
label = "Failed to Import Certificates";
|
||||
icon = faTriangleExclamation;
|
||||
tooltipContent = (
|
||||
<div className="flex flex-col gap-2 whitespace-normal py-1">
|
||||
{failureMessage && (
|
||||
<div>
|
||||
<div className="mb-2 flex self-start text-red">
|
||||
<FontAwesomeIcon icon={faXmark} className="ml-1 pr-1.5 pt-0.5 text-sm" />
|
||||
<div className="text-xs">
|
||||
{mini ? "Failed to Import Certificates" : "Failure Reason"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded bg-mineshaft-600 p-2 text-xs">{failureMessage}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
break;
|
||||
case PkiSyncStatus.Succeeded:
|
||||
default:
|
||||
// only show success for a bit...
|
||||
if (lastImportedAt && differenceInSeconds(new Date(), lastImportedAt) > 15) return null;
|
||||
|
||||
tooltipContent = "Successfully imported certificates.";
|
||||
variant = "success";
|
||||
label = "Certificates Imported";
|
||||
icon = faCheck;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip position="bottom" className="max-w-sm" content={tooltipContent}>
|
||||
<div>
|
||||
<Badge
|
||||
className={twMerge("flex h-5 w-min items-center gap-1.5 whitespace-nowrap", className)}
|
||||
variant={variant}
|
||||
>
|
||||
<FontAwesomeIcon icon={icon} />
|
||||
{!mini && <span>{label}</span>}
|
||||
</Badge>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
49
frontend/src/components/pki-syncs/PkiSyncModalHeader.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { faArrowUpRightFromSquare, faBookOpen } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs";
|
||||
import { PkiSync } from "@app/hooks/api/pkiSyncs";
|
||||
|
||||
type Props = {
|
||||
destination: PkiSync;
|
||||
isConfigured: boolean;
|
||||
};
|
||||
|
||||
export const PkiSyncModalHeader = ({ destination, isConfigured }: Props) => {
|
||||
const destinationDetails = PKI_SYNC_MAP[destination];
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-start gap-2">
|
||||
<img
|
||||
alt={`${destinationDetails.name} logo`}
|
||||
src={`/images/integrations/${destinationDetails.image}`}
|
||||
className="h-12 w-12 rounded-md bg-bunker-500 p-2"
|
||||
/>
|
||||
<div>
|
||||
<div className="flex items-center text-mineshaft-300">
|
||||
{destinationDetails.name} Certificate Sync
|
||||
<a
|
||||
target="_blank"
|
||||
href={`https://infisical.com/docs/integrations/pki-syncs/${destination}`}
|
||||
className="mb-1 ml-1"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<div className="inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
|
||||
<FontAwesomeIcon icon={faBookOpen} className="mb-[0.03rem] mr-1 text-[12px]" />
|
||||
<span>Docs</span>
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowUpRightFromSquare}
|
||||
className="mb-[0.07rem] ml-1 text-[10px]"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<p className="text-sm leading-4 text-mineshaft-400">
|
||||
{isConfigured
|
||||
? `Edit ${destinationDetails.name} Certificate Sync`
|
||||
: `Sync certificates to ${destinationDetails.name}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||