diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index bbc27ebc1..7ff31ed99 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -62,6 +62,9 @@ import { TCertificateSecretsUpdate, TCertificatesInsert, TCertificatesUpdate, + TCertificateSyncs, + TCertificateSyncsInsert, + TCertificateSyncsUpdate, TCertificateTemplateEstConfigs, TCertificateTemplateEstConfigsInsert, TCertificateTemplateEstConfigsUpdate, @@ -738,6 +741,11 @@ declare module "knex/types/tables" { TPkiSubscribersUpdate >; [TableName.PkiSync]: KnexOriginal.CompositeTableType; + [TableName.CertificateSync]: KnexOriginal.CompositeTableType< + TCertificateSyncs, + TCertificateSyncsInsert, + TCertificateSyncsUpdate + >; [TableName.UserGroupMembership]: KnexOriginal.CompositeTableType< TUserGroupMembership, TUserGroupMembershipInsert, diff --git a/backend/src/db/migrations/20251028120000_add-certificate-sync-table.ts b/backend/src/db/migrations/20251028120000_add-certificate-sync-table.ts new file mode 100644 index 000000000..14904e5bd --- /dev/null +++ b/backend/src/db/migrations/20251028120000_add-certificate-sync-table.ts @@ -0,0 +1,35 @@ +import { Knex } from "knex"; + +import { TableName } from "@app/db/schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "@app/db/utils"; +import { CertificateSyncStatus } from "@app/services/certificate-sync/certificate-sync-enums"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.CertificateSync))) { + await knex.schema.createTable(TableName.CertificateSync, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.uuid("pkiSyncId").notNullable(); + t.foreign("pkiSyncId").references("id").inTable(TableName.PkiSync).onDelete("CASCADE"); + t.uuid("certificateId").notNullable(); + t.foreign("certificateId").references("id").inTable(TableName.Certificate).onDelete("CASCADE"); + t.string("syncStatus").defaultTo(CertificateSyncStatus.Pending); + t.text("lastSyncMessage"); + t.datetime("lastSyncedAt"); + t.timestamps(true, true, true); + + // Ensure unique combination of pki sync and certificate + t.unique(["pkiSyncId", "certificateId"]); + + t.index("pkiSyncId"); + t.index("certificateId"); + t.index("syncStatus"); + }); + + await createOnUpdateTrigger(knex, TableName.CertificateSync); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.CertificateSync); + await dropOnUpdateTrigger(knex, TableName.CertificateSync); +} diff --git a/backend/src/db/migrations/20251031044512_add-certificate-sync-external-identifier.ts b/backend/src/db/migrations/20251031044512_add-certificate-sync-external-identifier.ts new file mode 100644 index 000000000..69e15795a --- /dev/null +++ b/backend/src/db/migrations/20251031044512_add-certificate-sync-external-identifier.ts @@ -0,0 +1,21 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.CertificateSync, "externalIdentifier"))) { + await knex.schema.alterTable(TableName.CertificateSync, (t) => { + t.text("externalIdentifier").nullable(); + t.index("externalIdentifier"); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.CertificateSync, "externalIdentifier")) { + await knex.schema.alterTable(TableName.CertificateSync, (t) => { + t.dropIndex("externalIdentifier"); + t.dropColumn("externalIdentifier"); + }); + } +} diff --git a/backend/src/db/schemas/certificate-syncs.ts b/backend/src/db/schemas/certificate-syncs.ts new file mode 100644 index 000000000..241684db2 --- /dev/null +++ b/backend/src/db/schemas/certificate-syncs.ts @@ -0,0 +1,24 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const CertificateSyncsSchema = z.object({ + id: z.string().uuid(), + pkiSyncId: z.string().uuid(), + certificateId: z.string().uuid(), + syncStatus: z.string().default("pending").nullable().optional(), + lastSyncMessage: z.string().nullable().optional(), + lastSyncedAt: z.date().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date(), + externalIdentifier: z.string().nullable().optional() +}); + +export type TCertificateSyncs = z.infer; +export type TCertificateSyncsInsert = Omit, TImmutableDBKeys>; +export type TCertificateSyncsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 4f0f221ff..fba195746 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -17,6 +17,7 @@ export * from "./certificate-authority-crl"; export * from "./certificate-authority-secret"; export * from "./certificate-bodies"; export * from "./certificate-secrets"; +export * from "./certificate-syncs"; export * from "./certificate-template-est-configs"; export * from "./certificate-templates"; export * from "./certificates"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 86bc929b8..e10c6dcbe 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -161,6 +161,7 @@ export enum TableName { AppConnection = "app_connections", SecretSync = "secret_syncs", PkiSync = "pki_syncs", + CertificateSync = "certificate_syncs", KmipClient = "kmip_clients", KmipOrgConfig = "kmip_org_configs", KmipOrgServerCertificates = "kmip_org_server_certificates", diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index b58503110..c6ac6ff3b 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -426,6 +426,7 @@ export enum EventType { SECRET_SYNC_REMOVE_SECRETS = "secret-sync-remove-secrets", GET_PKI_SYNCS = "get-pki-syncs", GET_PKI_SYNC = "get-pki-sync", + GET_PKI_SYNC_CERTIFICATES = "get-pki-sync-certificates", CREATE_PKI_SYNC = "create-pki-sync", UPDATE_PKI_SYNC = "update-pki-sync", DELETE_PKI_SYNC = "delete-pki-sync", @@ -3161,6 +3162,16 @@ interface GetPkiSyncEvent { }; } +interface GetPkiSyncCertificatesEvent { + type: EventType.GET_PKI_SYNC_CERTIFICATES; + metadata: { + syncId: string; + count: number; + certificateIds: string[]; + destination: string; + }; +} + interface CreatePkiSyncEvent { type: EventType.CREATE_PKI_SYNC; metadata: { @@ -4329,6 +4340,7 @@ export type Event = | SecretSyncRemoveSecretsEvent | GetPkiSyncsEvent | GetPkiSyncEvent + | GetPkiSyncCertificatesEvent | CreatePkiSyncEvent | UpdatePkiSyncEvent | DeletePkiSyncEvent diff --git a/backend/src/ee/services/gateway-v2/gateway-v2-service.ts b/backend/src/ee/services/gateway-v2/gateway-v2-service.ts index fd4954a00..eaec31e50 100644 --- a/backend/src/ee/services/gateway-v2/gateway-v2-service.ts +++ b/backend/src/ee/services/gateway-v2/gateway-v2-service.ts @@ -25,7 +25,7 @@ import { KmsDataKey } from "@app/services/kms/kms-types"; import { TNotificationServiceFactory } from "@app/services/notification/notification-service"; import { NotificationType } from "@app/services/notification/notification-types"; import { TOrgDALFactory } from "@app/services/org/org-dal"; -import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; +import { TSmtpService } from "@app/services/smtp/smtp-service"; import { TLicenseServiceFactory } from "../license/license-service"; import { PamResource } from "../pam-resource/pam-resource-enums"; @@ -61,8 +61,7 @@ export const gatewayV2ServiceFactory = ({ relayDAL, permissionService, orgDAL, - notificationService, - smtpService + notificationService }: TGatewayV2ServiceFactoryDep) => { const $validateIdentityAccessToGateway = async (orgId: string, actorId: string, actorAuthMethod: ActorAuthMethod) => { const orgLicensePlan = await licenseService.getPlan(orgId); @@ -931,15 +930,17 @@ export const gatewayV2ServiceFactory = ({ })) ); - await smtpService.sendMail({ - recipients: admins.map((admin) => admin.user.email).filter((v): v is string => !!v), - subjectLine: "Gateway Health Alert", - substitutions: { - type: "gateway", - names: gatewayNames - }, - template: SmtpTemplates.HealthAlert - }); + // Temporarily disabled email notifications due to excessive noise. Will be revised later + // + // await smtpService.sendMail({ + // recipients: admins.map((admin) => admin.user.email).filter((v): v is string => !!v), + // subjectLine: "Gateway Health Alert", + // substitutions: { + // type: "gateway", + // names: gatewayNames + // }, + // template: SmtpTemplates.HealthAlert + // }); await Promise.all(gateways.map((gw) => gatewayV2DAL.updateById(gw.id, { healthAlertedAt: new Date() }))); } catch (error) { diff --git a/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts b/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts index 99ce2d25f..413990c5d 100644 --- a/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts +++ b/backend/src/ee/services/pam-resource/shared/sql/sql-resource-factory.ts @@ -1,6 +1,5 @@ import knex from "knex"; import mysql, { Connection } from "mysql2/promise"; -import * as pg from "pg"; import tls, { PeerCertificate } from "tls"; import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic-secret-fns"; @@ -97,7 +96,7 @@ const makeSqlConnection = ( try { await client.raw(SIMPLE_QUERY); } catch (error) { - if (error instanceof pg.DatabaseError) { + if (error instanceof Error) { // Hacky way to know if we successfully hit the database. // TODO: potentially two approaches to solve the problem. // 1. change the work flow, add account first then resource diff --git a/backend/src/ee/services/relay/relay-service.ts b/backend/src/ee/services/relay/relay-service.ts index b2eb932ed..096011765 100644 --- a/backend/src/ee/services/relay/relay-service.ts +++ b/backend/src/ee/services/relay/relay-service.ts @@ -1268,15 +1268,17 @@ export const relayServiceFactory = ({ })) ); - await smtpService.sendMail({ - recipients: admins.map((admin) => admin.user.email).filter((v): v is string => !!v), - subjectLine: "Relay Health Alert", - substitutions: { - type: "relay", - names: relayNames - }, - template: SmtpTemplates.HealthAlert - }); + // Temporarily disabled email notifications due to excessive noise. Will be revised later + // + // await smtpService.sendMail({ + // recipients: admins.map((admin) => admin.user.email).filter((v): v is string => !!v), + // subjectLine: "Relay Health Alert", + // substitutions: { + // type: "relay", + // names: relayNames + // }, + // template: SmtpTemplates.HealthAlert + // }); } await Promise.all(relays.map((r) => relayDAL.updateById(r.id, { healthAlertedAt: new Date() }))); diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 6b032c6f0..0cb606cbf 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -2379,6 +2379,12 @@ export const AppConnections = { }, LARAVEL_FORGE: { apiToken: "The API token used to authenticate with Laravel Forge." + }, + CHEF: { + serverUrl: "The URL of the Chef server to connect to.", + orgName: "The short name of the Chef organization to connect to.", + userName: "The username used to access Chef.", + privateKey: "The private key used to access Chef." } } }; @@ -2624,6 +2630,10 @@ export const SecretSyncs = { siteId: "The ID of the Netlify site to sync secrets to.", context: "The Netlify context to sync secrets to." }, + CHEF: { + dataBagName: "The name of the Chef data bag to sync secrets to.", + dataBagItemName: "The name of the Chef data bag item to sync secrets to." + }, NORTHFLANK: { projectId: "The ID of the Northflank project to sync secrets to.", projectName: "The name of the Northflank project to sync secrets to.", diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index cf268b24e..6bd37e991 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -172,6 +172,7 @@ import { internalCertificateAuthorityServiceFactory } from "@app/services/certif import { certificateEstV3ServiceFactory } from "@app/services/certificate-est-v3/certificate-est-v3-service"; import { certificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; import { certificateProfileServiceFactory } from "@app/services/certificate-profile/certificate-profile-service"; +import { certificateSyncDALFactory } from "@app/services/certificate-sync/certificate-sync-dal"; import { certificateTemplateDALFactory } from "@app/services/certificate-template/certificate-template-dal"; import { certificateTemplateEstConfigDALFactory } from "@app/services/certificate-template/certificate-template-est-config-dal"; import { certificateTemplateServiceFactory } from "@app/services/certificate-template/certificate-template-service"; @@ -1064,6 +1065,7 @@ export const registerRoutes = async ( const certificateDAL = certificateDALFactory(db); const certificateBodyDAL = certificateBodyDALFactory(db); const certificateSecretDAL = certificateSecretDALFactory(db); + const certificateSyncDAL = certificateSyncDALFactory(db); const pkiAlertDAL = pkiAlertDALFactory(db); const pkiCollectionDAL = pkiCollectionDALFactory(db); @@ -2027,7 +2029,8 @@ export const registerRoutes = async ( certificateBodyDAL, certificateSecretDAL, certificateAuthorityDAL, - certificateAuthorityCertDAL + certificateAuthorityCertDAL, + certificateSyncDAL }); const pkiSyncCleanup = pkiSyncCleanupQueueServiceFactory({ @@ -2138,6 +2141,7 @@ export const registerRoutes = async ( permissionService, pkiCollectionDAL, pkiCollectionItemDAL, + certificateSyncDAL, pkiSyncDAL, pkiSyncQueue }); @@ -2149,7 +2153,10 @@ export const registerRoutes = async ( certificateProfileDAL, certificateTemplateV2Service, internalCaService: internalCertificateAuthorityService, - permissionService + permissionService, + certificateSyncDAL, + pkiSyncDAL, + pkiSyncQueue }); const certificateV3Queue = certificateV3QueueServiceFactory({ @@ -2191,6 +2198,8 @@ export const registerRoutes = async ( const pkiSyncService = pkiSyncServiceFactory({ pkiSyncDAL, + certificateDAL, + certificateSyncDAL, pkiSubscriberDAL, appConnectionService, permissionService, diff --git a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts index a3250c6a9..9a4d7f38b 100644 --- a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts @@ -48,6 +48,7 @@ import { ChecklyConnectionListItemSchema, SanitizedChecklyConnectionSchema } from "@app/services/app-connection/checkly"; +import { ChefConnectionListItemSchema, SanitizedChefConnectionSchema } from "@app/services/app-connection/chef"; import { CloudflareConnectionListItemSchema, SanitizedCloudflareConnectionSchema @@ -168,7 +169,8 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedOktaConnectionSchema.options, ...SanitizedAzureADCSConnectionSchema.options, ...SanitizedRedisConnectionSchema.options, - ...SanitizedLaravelForgeConnectionSchema.options + ...SanitizedLaravelForgeConnectionSchema.options, + ...SanitizedChefConnectionSchema.options ]); const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ @@ -212,7 +214,8 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ OktaConnectionListItemSchema, AzureADCSConnectionListItemSchema, RedisConnectionListItemSchema, - LaravelForgeConnectionListItemSchema + LaravelForgeConnectionListItemSchema, + ChefConnectionListItemSchema ]); export const registerAppConnectionRouter = async (server: FastifyZodProvider) => { diff --git a/backend/src/server/routes/v1/app-connection-routers/chef-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/chef-connection-router.ts new file mode 100644 index 000000000..6bfd83391 --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/chef-connection-router.ts @@ -0,0 +1,85 @@ +import z from "zod"; + +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + CreateChefConnectionSchema, + SanitizedChefConnectionSchema, + UpdateChefConnectionSchema +} from "@app/services/app-connection/chef"; +import { AuthMode } from "@app/services/auth/auth-type"; + +import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; + +export const registerChefConnectionRouter = async (server: FastifyZodProvider) => { + registerAppConnectionEndpoints({ + app: AppConnection.Chef, + server, + sanitizedResponseSchema: SanitizedChefConnectionSchema, + createSchema: CreateChefConnectionSchema, + updateSchema: UpdateChefConnectionSchema + }); + + server.route({ + method: "GET", + url: `/:connectionId/data-bags`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z + .object({ + name: z.string() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + const dataBags = await server.services.appConnection.chef.listDataBags(connectionId, req.permission); + + return dataBags; + } + }); + + server.route({ + method: "GET", + url: `/:connectionId/data-bag-items`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + querystring: z.object({ + dataBagName: z.string() + }), + response: { + 200: z + .object({ + name: z.string() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + const { dataBagName } = req.query; + const dataBagItems = await server.services.appConnection.chef.listDataBagItems( + connectionId, + dataBagName, + req.permission + ); + + return dataBagItems; + } + }); +}; diff --git a/backend/src/server/routes/v1/app-connection-routers/index.ts b/backend/src/server/routes/v1/app-connection-routers/index.ts index d8bcdce23..32e9efe4c 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -13,6 +13,7 @@ import { registerAzureKeyVaultConnectionRouter } from "./azure-key-vault-connect import { registerBitbucketConnectionRouter } from "./bitbucket-connection-router"; import { registerCamundaConnectionRouter } from "./camunda-connection-router"; import { registerChecklyConnectionRouter } from "./checkly-connection-router"; +import { registerChefConnectionRouter } from "./chef-connection-router"; import { registerCloudflareConnectionRouter } from "./cloudflare-connection-router"; import { registerDatabricksConnectionRouter } from "./databricks-connection-router"; import { registerDigitalOceanConnectionRouter } from "./digital-ocean-connection-router"; @@ -86,5 +87,6 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record; description?: string; isAutoSyncEnabled?: boolean; - subscriberId?: string; + subscriberId?: string | null; }>; updateSchema: z.ZodType<{ connectionId?: string; @@ -35,7 +35,7 @@ export const registerSyncPkiEndpoints = ({ syncOptions?: Record; description?: string; isAutoSyncEnabled?: boolean; - subscriberId?: string; + subscriberId?: string | null; }>; responseSchema: z.ZodTypeAny; syncOptions: { diff --git a/backend/src/server/routes/v1/pki-sync-routers/pki-sync-router.ts b/backend/src/server/routes/v1/pki-sync-routers/pki-sync-router.ts index 158d9e4dc..0710a91fc 100644 --- a/backend/src/server/routes/v1/pki-sync-routers/pki-sync-router.ts +++ b/backend/src/server/routes/v1/pki-sync-routers/pki-sync-router.ts @@ -2,10 +2,11 @@ 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 { readLimit, writeLimit } 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 { CertificateSyncStatus } from "@app/services/certificate-sync/certificate-sync-enums"; import { PkiSync } from "@app/services/pki-sync/pki-sync-enums"; const PkiSyncSchema = z.object({ @@ -60,7 +61,8 @@ const PkiSyncSchema = z.object({ name: z.string() }) .nullable() - .optional() + .optional(), + hasCertificate: z.boolean().optional() }); const PkiSyncOptionsSchema = z.object({ @@ -76,6 +78,27 @@ const PkiSyncOptionsSchema = z.object({ minCertificateNameLength: z.number().optional() }); +const PkiSyncCertificateSchema = z.object({ + id: z.string().uuid(), + pkiSyncId: z.string().uuid(), + certificateId: z.string().uuid(), + syncStatus: z.nativeEnum(CertificateSyncStatus), + lastSyncMessage: z.string().nullable().optional(), + lastSyncedAt: z.date().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date(), + certificateSerialNumber: z.string().optional(), + certificateCommonName: z.string().optional(), + certificateAltNames: z.string().optional(), + certificateStatus: z.string().optional(), + certificateNotBefore: z.date().optional(), + certificateNotAfter: z.date().optional(), + certificateRenewBeforeDays: z.number().nullish(), + certificateRenewalError: z.string().nullish(), + pkiSyncName: z.string().optional(), + pkiSyncDestination: z.string().optional() +}); + export const registerPkiSyncRouter = async (server: FastifyZodProvider) => { server.route({ method: "GET", @@ -111,7 +134,8 @@ export const registerPkiSyncRouter = async (server: FastifyZodProvider) => { tags: [ApiDocsTags.PkiSyncs], description: "List all the PKI Syncs for the specified project.", querystring: z.object({ - projectId: z.string().trim().min(1) + projectId: z.string().trim().min(1), + certificateId: z.string().uuid().optional() }), response: { 200: z.object({ pkiSyncs: PkiSyncSchema.array() }) @@ -120,11 +144,11 @@ export const registerPkiSyncRouter = async (server: FastifyZodProvider) => { onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { const { - query: { projectId }, + query: { projectId, certificateId }, permission } = req; - const pkiSyncs = await server.services.pkiSync.listPkiSyncsByProjectId({ projectId }, permission); + const pkiSyncs = await server.services.pkiSync.listPkiSyncsByProjectId({ projectId, certificateId }, permission); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, @@ -179,4 +203,163 @@ export const registerPkiSyncRouter = async (server: FastifyZodProvider) => { return pkiSync; } }); + + server.route({ + method: "GET", + url: "/:pkiSyncId/certificates", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiSyncs], + description: "List all certificates associated with a PKI Sync.", + params: z.object({ + pkiSyncId: z.string().uuid() + }), + querystring: z.object({ + offset: z.coerce.number().min(0).default(0), + limit: z.coerce.number().min(1).max(100).default(20) + }), + response: { + 200: z.object({ + certificates: PkiSyncCertificateSchema.array(), + totalCount: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { pkiSyncId } = req.params; + const { offset, limit } = req.query; + + const { certificates, totalCount, pkiSyncInfo } = await server.services.pkiSync.listPkiSyncCertificates( + { pkiSyncId, offset, limit }, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: pkiSyncInfo.projectId, + event: { + type: EventType.GET_PKI_SYNC_CERTIFICATES, + metadata: { + syncId: pkiSyncId, + destination: pkiSyncInfo.destination, + count: certificates.length, + certificateIds: certificates.map((c) => c.certificateId) + } + } + }); + + return { certificates, totalCount }; + } + }); + + server.route({ + method: "POST", + url: "/:pkiSyncId/certificates", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiSyncs], + description: "Add certificates to a PKI Sync.", + params: z.object({ + pkiSyncId: z.string().uuid() + }), + body: z.object({ + certificateIds: z.array(z.string().uuid()).min(1, "At least one certificate ID is required") + }), + response: { + 200: z.object({ + addedCertificates: z.array( + z.object({ + id: z.string().uuid(), + pkiSyncId: z.string().uuid(), + certificateId: z.string().uuid(), + syncStatus: z.string().default("pending").optional().nullable(), + lastSyncMessage: z.string().optional().nullable(), + lastSyncedAt: z.date().optional().nullable(), + createdAt: z.date(), + updatedAt: z.date() + }) + ) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { pkiSyncId } = req.params; + const { certificateIds } = req.body; + + const { addedCertificates, pkiSyncInfo } = await server.services.pkiSync.addCertificatesToPkiSync( + { pkiSyncId, certificateIds }, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: pkiSyncInfo.projectId, + event: { + type: EventType.UPDATE_PKI_SYNC, + metadata: { + pkiSyncId, + name: pkiSyncInfo.name + } + } + }); + + return { addedCertificates }; + } + }); + + server.route({ + method: "DELETE", + url: "/:pkiSyncId/certificates", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiSyncs], + description: "Remove certificates from a PKI Sync.", + params: z.object({ + pkiSyncId: z.string().uuid() + }), + body: z.object({ + certificateIds: z.array(z.string().uuid()).min(1, "At least one certificate ID is required") + }), + response: { + 200: z.object({ + removedCount: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { pkiSyncId } = req.params; + const { certificateIds } = req.body; + + const { removedCount, pkiSyncInfo } = await server.services.pkiSync.removeCertificatesFromPkiSync( + { pkiSyncId, certificateIds }, + req.permission + ); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: pkiSyncInfo.projectId, + event: { + type: EventType.UPDATE_PKI_SYNC, + metadata: { + pkiSyncId, + name: pkiSyncInfo.name + } + } + }); + + return { removedCount }; + } + }); }; diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index c3bffa2fc..1054d359b 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -1195,8 +1195,13 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { querystring: z.object({ friendlyName: z.string().optional().describe(PROJECTS.LIST_CERTIFICATES.friendlyName), commonName: z.string().optional().describe(PROJECTS.LIST_CERTIFICATES.commonName), - offset: z.coerce.number().min(0).max(100).default(0).describe(PROJECTS.LIST_CERTIFICATES.offset), - limit: z.coerce.number().min(1).max(100).default(25).describe(PROJECTS.LIST_CERTIFICATES.limit) + offset: z.coerce.number().min(0).default(0).describe(PROJECTS.LIST_CERTIFICATES.offset), + limit: z.coerce.number().min(1).max(100).default(25).describe(PROJECTS.LIST_CERTIFICATES.limit), + forPkiSync: z.coerce + .boolean() + .default(false) + .optional() + .describe("Retrieve only certificates available for PKI sync") }), response: { 200: z.object({ diff --git a/backend/src/server/routes/v1/secret-sync-routers/chef-sync-router.ts b/backend/src/server/routes/v1/secret-sync-routers/chef-sync-router.ts new file mode 100644 index 000000000..6972a9b70 --- /dev/null +++ b/backend/src/server/routes/v1/secret-sync-routers/chef-sync-router.ts @@ -0,0 +1,13 @@ +import { ChefSyncSchema, CreateChefSyncSchema, UpdateChefSyncSchema } from "@app/services/secret-sync/chef"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; + +import { registerSyncSecretsEndpoints } from "./secret-sync-endpoints"; + +export const registerChefSyncRouter = async (server: FastifyZodProvider) => + registerSyncSecretsEndpoints({ + destination: SecretSync.Chef, + server, + responseSchema: ChefSyncSchema, + createSchema: CreateChefSyncSchema, + updateSchema: UpdateChefSyncSchema + }); diff --git a/backend/src/server/routes/v1/secret-sync-routers/index.ts b/backend/src/server/routes/v1/secret-sync-routers/index.ts index 2acf2dfc9..d305dc342 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/index.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/index.ts @@ -10,6 +10,7 @@ import { registerAzureKeyVaultSyncRouter } from "./azure-key-vault-sync-router"; import { registerBitbucketSyncRouter } from "./bitbucket-sync-router"; import { registerCamundaSyncRouter } from "./camunda-sync-router"; import { registerChecklySyncRouter } from "./checkly-sync-router"; +import { registerChefSyncRouter } from "./chef-sync-router"; import { registerCloudflarePagesSyncRouter } from "./cloudflare-pages-sync-router"; import { registerCloudflareWorkersSyncRouter } from "./cloudflare-workers-sync-router"; import { registerDatabricksSyncRouter } from "./databricks-sync-router"; @@ -67,5 +68,6 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record { diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index 1e731ed77..1c184a436 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -39,6 +39,7 @@ export enum AppConnection { Okta = "okta", Redis = "redis", LaravelForge = "laravel-forge", + Chef = "chef", Northflank = "northflank" } diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index efc3deb99..3c511fd4d 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -68,6 +68,7 @@ import { } from "./bitbucket"; import { CamundaConnectionMethod, getCamundaConnectionListItem, validateCamundaConnectionCredentials } from "./camunda"; import { ChecklyConnectionMethod, getChecklyConnectionListItem, validateChecklyConnectionCredentials } from "./checkly"; +import { ChefConnectionMethod, getChefConnectionListItem, validateChefConnectionCredentials } from "./chef"; import { CloudflareConnectionMethod } from "./cloudflare/cloudflare-connection-enum"; import { getCloudflareConnectionListItem, @@ -210,7 +211,8 @@ export const listAppConnectionOptions = (projectType?: ProjectType) => { getNetlifyConnectionListItem(), getNorthflankConnectionListItem(), getOktaConnectionListItem(), - getRedisConnectionListItem() + getRedisConnectionListItem(), + getChefConnectionListItem() ] .filter((option) => { switch (projectType) { @@ -341,6 +343,7 @@ export const validateAppConnectionCredentials = async ( [AppConnection.Netlify]: validateNetlifyConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Northflank]: validateNorthflankConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Okta]: validateOktaConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.Chef]: validateChefConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Redis]: validateRedisConnectionCredentials as TAppConnectionCredentialsValidator }; @@ -409,6 +412,8 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => case RenderConnectionMethod.ApiKey: case ChecklyConnectionMethod.ApiKey: return "API Key"; + case ChefConnectionMethod.UserKey: + return "User Key"; case SupabaseConnectionMethod.AccessToken: return "Access Token"; default: @@ -483,7 +488,8 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.Northflank]: platformManagedCredentialsNotSupported, [AppConnection.Okta]: platformManagedCredentialsNotSupported, [AppConnection.Redis]: platformManagedCredentialsNotSupported, - [AppConnection.LaravelForge]: platformManagedCredentialsNotSupported + [AppConnection.LaravelForge]: platformManagedCredentialsNotSupported, + [AppConnection.Chef]: platformManagedCredentialsNotSupported }; export const enterpriseAppCheck = async ( diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index c684765bd..08ca0c681 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -41,6 +41,7 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.Netlify]: "Netlify", [AppConnection.Okta]: "Okta", [AppConnection.Redis]: "Redis", + [AppConnection.Chef]: "Chef", [AppConnection.Northflank]: "Northflank" }; @@ -85,5 +86,6 @@ export const APP_CONNECTION_PLAN_MAP: Record>>; @@ -330,6 +337,7 @@ export type TAppConnectionInput = { id: string } & ( | TNorthflankConnectionInput | TOktaConnectionInput | TRedisConnectionInput + | TChefConnectionInput ); export type TSqlConnectionInput = @@ -395,7 +403,8 @@ export type TAppConnectionConfig = | TNetlifyConnectionConfig | TNorthflankConnectionConfig | TOktaConnectionConfig - | TRedisConnectionConfig; + | TRedisConnectionConfig + | TChefConnectionConfig; export type TValidateAppConnectionCredentialsSchema = | TValidateAwsConnectionCredentialsSchema @@ -438,7 +447,8 @@ export type TValidateAppConnectionCredentialsSchema = | TValidateNetlifyConnectionCredentialsSchema | TValidateNorthflankConnectionCredentialsSchema | TValidateOktaConnectionCredentialsSchema - | TValidateRedisConnectionCredentialsSchema; + | TValidateRedisConnectionCredentialsSchema + | TValidateChefConnectionCredentialsSchema; export type TListAwsConnectionKmsKeys = { connectionId: string; diff --git a/backend/src/services/app-connection/chef/chef-connection-enums.ts b/backend/src/services/app-connection/chef/chef-connection-enums.ts new file mode 100644 index 000000000..58e59c3ba --- /dev/null +++ b/backend/src/services/app-connection/chef/chef-connection-enums.ts @@ -0,0 +1,3 @@ +export enum ChefConnectionMethod { + UserKey = "user-key" +} diff --git a/backend/src/services/app-connection/chef/chef-connection-fns.ts b/backend/src/services/app-connection/chef/chef-connection-fns.ts new file mode 100644 index 000000000..1b35628bc --- /dev/null +++ b/backend/src/services/app-connection/chef/chef-connection-fns.ts @@ -0,0 +1,288 @@ +import { AxiosError } from "axios"; +import crypto from "crypto"; + +import { request } from "@app/lib/config/request"; +import { BadRequestError } from "@app/lib/errors"; +import { removeTrailingSlash } from "@app/lib/fn"; +import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; + +import { TChefDataBagItemContent } from "../../secret-sync/chef/chef-sync-types"; +import { AppConnection } from "../app-connection-enums"; +import { ChefConnectionMethod } from "./chef-connection-enums"; +import { + TChefConnection, + TChefConnectionConfig, + TChefDataBag, + TChefDataBagItem, + TGetChefDataBagItem, + TUpdateChefDataBagItem +} from "./chef-connection-types"; + +export const getChefServerUrl = async (serverUrl?: string) => { + const chefServerUrl = serverUrl ? removeTrailingSlash(serverUrl) : IntegrationUrls.CHEF_API_URL; + + await blockLocalAndPrivateIpAddresses(chefServerUrl); + + return chefServerUrl; +}; + +// Helper to ensure private key is in proper PEM format +const formatPrivateKey = (key: string): string => { + let formattedKey = key.trim(); + + // Ensure proper line breaks in PEM format (handle escaped newlines) + formattedKey = formattedKey.replace(/\\n/g, "\n"); + + // Remove any extra whitespace between lines + formattedKey = formattedKey.replace(/\n\s+/g, "\n"); + + // If key doesn't have headers, add PKCS#1 RSA headers + if (!formattedKey.includes("BEGIN")) { + formattedKey = `-----BEGIN RSA PRIVATE KEY-----\n${formattedKey}\n-----END RSA PRIVATE KEY-----`; + } + + // Ensure the key has proper line breaks after headers and before footers + formattedKey = formattedKey.replace(/(-----BEGIN[^-]+-----)\s*/g, "$1\n").replace(/\s*(-----END[^-]+-----)/g, "\n$1"); + + // Remove any duplicate newlines + formattedKey = formattedKey.replace(/\n{3,}/g, "\n\n"); + + return formattedKey; +}; + +const getChefAuthHeaders = ( + method: string, + path: string, + body: string, + userId: string, + privateKey: string, + apiVersion: "1.0" | "1.3" = "1.3" +) => { + const timestamp = new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); // Remove milliseconds from timestamp + + // Calculate content hash based on version + let contentHash: string; + if (apiVersion === "1.3") { + contentHash = crypto.createHash("sha256").update(body).digest("base64"); + } else { + contentHash = crypto.createHash("sha1").update(body).digest("base64"); + } + + // Build canonical request based on version + let canonicalRequest: string; + if (apiVersion === "1.3") { + canonicalRequest = [ + `Method:${method}`, + `Path:${path}`, + `X-Ops-Content-Hash:${contentHash}`, + "X-Ops-Sign:version=1.3", + `X-Ops-Timestamp:${timestamp}`, + `X-Ops-UserId:${userId}`, + "X-Ops-Server-API-Version:1" + ].join("\n"); + } else { + const hashedPath = crypto.createHash("sha1").update(path).digest("base64"); + canonicalRequest = [ + `Method:${method}`, + `Hashed Path:${hashedPath}`, + `X-Ops-Content-Hash:${contentHash}`, + `X-Ops-Timestamp:${timestamp}`, + `X-Ops-UserId:${userId}` + ].join("\n"); + } + + // Format the private key properly + const formattedKey = formatPrivateKey(privateKey); + + // Sign the canonical request + const sign = crypto.createSign(apiVersion === "1.3" ? "RSA-SHA256" : "RSA-SHA1"); + sign.update(canonicalRequest); + const signature = sign.sign(formattedKey, "base64"); + + // Split signature into 60-character chunks + const authHeaders: Record = {}; + const signatureLines = signature.match(/.{1,60}/g) || []; + signatureLines.forEach((line, index) => { + authHeaders[`X-Ops-Authorization-${index + 1}`] = line; + }); + + return { + Accept: "application/json", + "Content-Type": "application/json", + "X-Chef-Version": "14.0.0", + "X-Ops-Timestamp": timestamp, + "X-Ops-UserId": userId, + "X-Ops-Sign": apiVersion === "1.3" ? "version=1.3" : "algorithm=sha1;version=1.0", + "X-Ops-Content-Hash": contentHash, + ...(apiVersion === "1.3" && { "X-Ops-Server-API-Version": "1" }), + ...authHeaders + }; +}; + +export const getChefConnectionListItem = () => { + return { + name: "Chef" as const, + app: AppConnection.Chef as const, + methods: Object.values(ChefConnectionMethod) as [ChefConnectionMethod.UserKey] + }; +}; + +export const validateChefConnectionCredentials = async (config: TChefConnectionConfig) => { + const { credentials: inputCredentials } = config; + + try { + const path = `/organizations/${inputCredentials.orgName}/users/${inputCredentials.userName}`; + + const hostServerUrl = await getChefServerUrl(inputCredentials.serverUrl); + + const headers = getChefAuthHeaders("GET", path, "", inputCredentials.userName, inputCredentials.privateKey); + + await request.get(`${hostServerUrl}${path}`, { + headers + }); + } catch (error: unknown) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to validate Chef credentials: ${error.message || "Unknown error"}` + }); + } + throw new BadRequestError({ + message: "Unable to validate Chef connection: verify credentials" + }); + } + + return inputCredentials; +}; + +export const listChefDataBags = async (appConnection: TChefConnection): Promise => { + const { + credentials: { serverUrl, userName, privateKey, orgName } + } = appConnection; + + try { + const path = `/organizations/${orgName}/data`; + const body = ""; + + const hostServerUrl = await getChefServerUrl(serverUrl); + + const headers = getChefAuthHeaders("GET", path, body, userName, privateKey); + + const res = await request.get>(`${hostServerUrl}${path}`, { + headers + }); + + return Object.keys(res.data).map((name) => ({ + name + })); + } catch (error) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to list Chef data bags: ${error.message || "Unknown error"}` + }); + } + throw new BadRequestError({ + message: "Unable to list Chef data bags" + }); + } +}; + +export const listChefDataBagItems = async ( + appConnection: TChefConnection, + dataBagName: string +): Promise => { + const { + credentials: { serverUrl, userName, privateKey, orgName } + } = appConnection; + + try { + const path = `/organizations/${orgName}/data/${dataBagName}`; + const body = ""; + + const hostServerUrl = await getChefServerUrl(serverUrl); + + const headers = getChefAuthHeaders("GET", path, body, userName, privateKey); + + const res = await request.get>(`${hostServerUrl}${path}`, { + headers + }); + + return Object.keys(res.data).map((name) => ({ + name + })); + } catch (error) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to list Chef data bag items: ${error.message || "Unknown error"}` + }); + } + throw new BadRequestError({ + message: "Unable to list Chef data bag items" + }); + } +}; + +export const getChefDataBagItem = async ({ + serverUrl, + userName, + privateKey, + orgName, + dataBagName, + dataBagItemName +}: TGetChefDataBagItem): Promise => { + try { + const path = `/organizations/${orgName}/data/${dataBagName}/${dataBagItemName}`; + const body = ""; + + const hostServerUrl = await getChefServerUrl(serverUrl); + + const headers = getChefAuthHeaders("GET", path, body, userName, privateKey); + + const res = await request.get(`${hostServerUrl}${path}`, { + headers + }); + + return res.data; + } catch (error) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to get Chef data bag item: ${error.message || "Unknown error"}` + }); + } + throw new BadRequestError({ + message: "Unable to get Chef data bag item" + }); + } +}; + +export const updateChefDataBagItem = async ({ + serverUrl, + userName, + privateKey, + orgName, + dataBagName, + dataBagItemName, + data +}: TUpdateChefDataBagItem): Promise => { + try { + const path = `/organizations/${orgName}/data/${dataBagName}/${dataBagItemName}`; + const body = JSON.stringify(data); + + const hostServerUrl = await getChefServerUrl(serverUrl); + + const headers = getChefAuthHeaders("PUT", path, body, userName, privateKey); + + await request.put(`${hostServerUrl}${path}`, data, { + headers + }); + } catch (error) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to update Chef data bag item: ${error.message || "Unknown error"}` + }); + } + throw new BadRequestError({ + message: "Unable to update Chef data bag item" + }); + } +}; diff --git a/backend/src/services/app-connection/chef/chef-connection-schemas.ts b/backend/src/services/app-connection/chef/chef-connection-schemas.ts new file mode 100644 index 000000000..e5a3687a2 --- /dev/null +++ b/backend/src/services/app-connection/chef/chef-connection-schemas.ts @@ -0,0 +1,77 @@ +import z from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { ChefConnectionMethod } from "./chef-connection-enums"; + +export const ChefConnectionUserKeyCredentialsSchema = z.object({ + serverUrl: z + .string() + .trim() + .url("Valid Chef Server URL required") + .optional() + .describe(AppConnections.CREDENTIALS.CHEF.serverUrl), + orgName: z + .string() + .trim() + .min(1, "Organization name required") + .max(256, "Organization name cannot exceed 256 characters") + .describe(AppConnections.CREDENTIALS.CHEF.orgName), + userName: z + .string() + .trim() + .min(1, "User name required") + .max(256, "User name cannot exceed 256 characters") + .describe(AppConnections.CREDENTIALS.CHEF.userName), + privateKey: z + .string() + .trim() + .min(1, "Private key required") + .max(16384, "Private key cannot exceed 16384 characters") + .describe(AppConnections.CREDENTIALS.CHEF.privateKey) +}); + +const BaseChefConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.Chef) }); + +export const ChefConnectionSchema = BaseChefConnectionSchema.extend({ + method: z.literal(ChefConnectionMethod.UserKey), + credentials: ChefConnectionUserKeyCredentialsSchema +}); + +export const SanitizedChefConnectionSchema = z.discriminatedUnion("method", [ + BaseChefConnectionSchema.extend({ + method: z.literal(ChefConnectionMethod.UserKey), + credentials: ChefConnectionUserKeyCredentialsSchema.pick({ serverUrl: true, orgName: true, userName: true }) + }) +]); + +export const ValidateChefConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z.literal(ChefConnectionMethod.UserKey).describe(AppConnections.CREATE(AppConnection.Chef).method), + credentials: ChefConnectionUserKeyCredentialsSchema.describe(AppConnections.CREATE(AppConnection.Chef).credentials) + }) +]); + +export const CreateChefConnectionSchema = ValidateChefConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.Chef) +); + +export const UpdateChefConnectionSchema = z + .object({ + credentials: ChefConnectionUserKeyCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.Chef).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.Chef)); + +export const ChefConnectionListItemSchema = z.object({ + name: z.literal("Chef"), + app: z.literal(AppConnection.Chef), + methods: z.nativeEnum(ChefConnectionMethod).array() +}); diff --git a/backend/src/services/app-connection/chef/chef-connection-service.ts b/backend/src/services/app-connection/chef/chef-connection-service.ts new file mode 100644 index 000000000..c989e7eaf --- /dev/null +++ b/backend/src/services/app-connection/chef/chef-connection-service.ts @@ -0,0 +1,39 @@ +import { ForbiddenRequestError } from "@app/lib/errors"; +import { OrgServiceActor } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { listChefDataBagItems, listChefDataBags } from "./chef-connection-fns"; +import { TChefConnection } from "./chef-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +export const chefConnectionService = (getAppConnection: TGetAppConnectionFunc) => { + const listDataBags = async (appConnectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.Chef, appConnectionId, actor); + + if (!appConnection) { + throw new ForbiddenRequestError({ message: "App connection not found" }); + } + + return listChefDataBags(appConnection); + }; + + const listDataBagItems = async (appConnectionId: string, dataBagName: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.Chef, appConnectionId, actor); + + if (!appConnection) { + throw new ForbiddenRequestError({ message: "App connection not found" }); + } + + return listChefDataBagItems(appConnection, dataBagName); + }; + + return { + listDataBags, + listDataBagItems + }; +}; diff --git a/backend/src/services/app-connection/chef/chef-connection-types.ts b/backend/src/services/app-connection/chef/chef-connection-types.ts new file mode 100644 index 000000000..a2da80d3d --- /dev/null +++ b/backend/src/services/app-connection/chef/chef-connection-types.ts @@ -0,0 +1,50 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; +import { TChefDataBagItemContent } from "@app/services/secret-sync/chef"; + +import { AppConnection } from "../app-connection-enums"; +import { + ChefConnectionSchema, + CreateChefConnectionSchema, + ValidateChefConnectionCredentialsSchema +} from "./chef-connection-schemas"; + +export type TChefConnection = z.infer; + +export type TChefConnectionInput = z.infer & { + app: AppConnection.Chef; +}; + +export type TValidateChefConnectionCredentialsSchema = typeof ValidateChefConnectionCredentialsSchema; + +export type TChefConnectionConfig = DiscriminativePick & { + orgName: string; +}; + +export type TChefDataBag = { + name: string; +}; + +export type TChefDataBagItem = { + name: string; +}; + +export type TGetChefDataBagItem = { + serverUrl?: string; + userName: string; + privateKey: string; + orgName: string; + dataBagName: string; + dataBagItemName: string; +}; + +export type TUpdateChefDataBagItem = { + serverUrl?: string; + userName: string; + privateKey: string; + orgName: string; + dataBagName: string; + dataBagItemName: string; + data: TChefDataBagItemContent; +}; diff --git a/backend/src/services/app-connection/chef/index.ts b/backend/src/services/app-connection/chef/index.ts new file mode 100644 index 000000000..e02479d0e --- /dev/null +++ b/backend/src/services/app-connection/chef/index.ts @@ -0,0 +1,4 @@ +export * from "./chef-connection-enums"; +export * from "./chef-connection-fns"; +export * from "./chef-connection-schemas"; +export * from "./chef-connection-types"; diff --git a/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-fns.ts b/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-fns.ts index 4937a5331..26f59a402 100644 --- a/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/azure-ad-cs/azure-ad-cs-certificate-authority-fns.ts @@ -192,7 +192,7 @@ export const castDbEntryToAzureAdCsCertificateAuthority = ( ca: Awaited> ): TAzureAdCsCertificateAuthority & { credentials: unknown } => { if (!ca.externalCa?.id) { - throw new BadRequestError({ message: "Malformed Azure AD Certificate Service certificate authority" }); + throw new BadRequestError({ message: "Malformed Active Directory Certificate Service certificate authority" }); } if (!ca.externalCa.dnsAppConnectionId) { @@ -776,7 +776,7 @@ export const AzureAdCsCertificateAuthorityFns = ({ const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(subscriber.caId); if (!ca.externalCa || ca.externalCa.type !== CaType.AZURE_AD_CS) { - throw new BadRequestError({ message: "CA is not an Azure AD Certificate Service CA" }); + throw new BadRequestError({ message: "CA is not an Active Directory Certificate Service CA" }); } const azureCa = castDbEntryToAzureAdCsCertificateAuthority(ca); diff --git a/backend/src/services/certificate-authority/certificate-authority-maps.ts b/backend/src/services/certificate-authority/certificate-authority-maps.ts index ef844a1ed..bae444cae 100644 --- a/backend/src/services/certificate-authority/certificate-authority-maps.ts +++ b/backend/src/services/certificate-authority/certificate-authority-maps.ts @@ -2,8 +2,8 @@ import { CaCapability, CaType } from "./certificate-authority-enums"; export const CERTIFICATE_AUTHORITIES_TYPE_MAP: Record = { [CaType.INTERNAL]: "Internal", - [CaType.ACME]: "ACME", - [CaType.AZURE_AD_CS]: "Azure AD Certificate Service" + [CaType.ACME]: "ACME-compatible CA", + [CaType.AZURE_AD_CS]: "Active Directory Certificate Service" }; export const CERTIFICATE_AUTHORITIES_CAPABILITIES_MAP: Record = { diff --git a/backend/src/services/certificate-profile/certificate-profile-dal.ts b/backend/src/services/certificate-profile/certificate-profile-dal.ts index 1ffa3e295..b66475bbf 100644 --- a/backend/src/services/certificate-profile/certificate-profile-dal.ts +++ b/backend/src/services/certificate-profile/certificate-profile-dal.ts @@ -10,10 +10,8 @@ import { TCertificateProfile, TCertificateProfileCertificate, TCertificateProfileInsert, - TCertificateProfileMetrics, TCertificateProfileUpdate, - TCertificateProfileWithConfigs, - TCertificateProfileWithRawMetrics + TCertificateProfileWithConfigs } from "./certificate-profile-types"; export type TCertificateProfileDALFactory = ReturnType; @@ -203,21 +201,11 @@ export const certificateProfileDALFactory = (db: TDbClient) => { search?: string; enrollmentType?: EnrollmentType; caId?: string; - includeMetrics?: boolean; - expiringDays?: number; } = {}, tx?: Knex - ): Promise => { + ): Promise => { try { - const { - offset = 0, - limit = 20, - search, - enrollmentType, - caId, - includeMetrics = false, - expiringDays = 7 - } = options; + const { offset = 0, limit = 20, search, enrollmentType, caId } = options; let baseQuery = (tx || db)(TableName.PkiCertificateProfile).where( `${TableName.PkiCertificateProfile}.projectId`, @@ -242,7 +230,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => { baseQuery = baseQuery.where(`${TableName.PkiCertificateProfile}.caId`, caId); } - let query = baseQuery + const query = baseQuery .leftJoin( TableName.PkiEstEnrollmentConfig, `${TableName.PkiCertificateProfile}.estConfigId`, @@ -267,52 +255,6 @@ export const certificateProfileDALFactory = (db: TDbClient) => { db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiRenewBeforeDays") ); - if (includeMetrics) { - query = query.leftJoin( - TableName.Certificate, - `${TableName.PkiCertificateProfile}.id`, - `${TableName.Certificate}.profileId` - ); - - const now = new Date(); - const expiringDate = new Date(); - expiringDate.setDate(now.getDate() + expiringDays); - - query = query - .select( - selectAllTableCols(TableName.PkiCertificateProfile), - db.ref("id").withSchema(TableName.PkiEstEnrollmentConfig).as("estId"), - db - .ref("disableBootstrapCaValidation") - .withSchema(TableName.PkiEstEnrollmentConfig) - .as("estDisableBootstrapCaValidation"), - db.ref("hashedPassphrase").withSchema(TableName.PkiEstEnrollmentConfig).as("estHashedPassphrase"), - db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estEncryptedCaChain"), - db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiId"), - db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenew"), - db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiRenewBeforeDays"), - db.raw("COUNT(certificates.id) as total_certificates"), - db.raw( - 'COUNT(CASE WHEN certificates."revokedAt" IS NULL AND certificates."notAfter" > ? THEN 1 END) as active_certificates', - [expiringDate] - ), - db.raw( - 'COUNT(CASE WHEN certificates."revokedAt" IS NULL AND certificates."notAfter" <= ? THEN 1 END) as expired_certificates', - [now] - ), - db.raw( - 'COUNT(CASE WHEN certificates."revokedAt" IS NULL AND certificates."notAfter" > ? AND certificates."notAfter" <= ? THEN 1 END) as expiring_certificates', - [now, expiringDate] - ), - db.raw('COUNT(CASE WHEN certificates."revokedAt" IS NOT NULL THEN 1 END) as revoked_certificates') - ) - .groupBy( - `${TableName.PkiCertificateProfile}.id`, - `${TableName.PkiEstEnrollmentConfig}.id`, - `${TableName.PkiApiEnrollmentConfig}.id` - ); - } - const results = (await query .orderBy(`${TableName.PkiCertificateProfile}.createdAt`, "desc") .offset(offset) @@ -353,17 +295,6 @@ export const certificateProfileDALFactory = (db: TDbClient) => { apiConfig }; - if (includeMetrics) { - return { - ...baseProfile, - total_certificates: result.total_certificates, - active_certificates: result.active_certificates, - expired_certificates: result.expired_certificates, - expiring_certificates: result.expiring_certificates, - revoked_certificates: result.revoked_certificates - } as TCertificateProfileWithRawMetrics & TCertificateProfileWithConfigs; - } - return baseProfile as TCertificateProfileWithConfigs; }); } catch (error) { @@ -485,45 +416,6 @@ export const certificateProfileDALFactory = (db: TDbClient) => { } }; - const getProfileMetrics = async ( - profileId: string, - expiringDays: number = 7, - tx?: Knex - ): Promise => { - try { - const now = new Date(); - const expiringDate = new Date(); - expiringDate.setDate(now.getDate() + expiringDays); - - const metrics = await (tx || db)(TableName.Certificate) - .where("profileId", profileId) - .select( - db.raw("COUNT(*) as total_certificates"), - db.raw('COUNT(CASE WHEN "revokedAt" IS NULL AND "notAfter" > ? THEN 1 END) as active_certificates', [ - expiringDate - ]), - db.raw('COUNT(CASE WHEN "revokedAt" IS NULL AND "notAfter" <= ? THEN 1 END) as expired_certificates', [now]), - db.raw( - 'COUNT(CASE WHEN "revokedAt" IS NULL AND "notAfter" > ? AND "notAfter" <= ? THEN 1 END) as expiring_certificates', - [now, expiringDate] - ), - db.raw('COUNT(CASE WHEN "revokedAt" IS NOT NULL THEN 1 END) as revoked_certificates') - ) - .first(); - - return { - profileId, - totalCertificates: parseInt(String((metrics as Record)?.total_certificates || 0), 10), - activeCertificates: parseInt(String((metrics as Record)?.active_certificates || 0), 10), - expiredCertificates: parseInt(String((metrics as Record)?.expired_certificates || 0), 10), - expiringCertificates: parseInt(String((metrics as Record)?.expiring_certificates || 0), 10), - revokedCertificates: parseInt(String((metrics as Record)?.revoked_certificates || 0), 10) - }; - } catch (error) { - throw new DatabaseError({ error, name: "Get certificate profile metrics" }); - } - }; - const isProfileInUse = async (profileId: string, tx?: Knex) => { try { const doc = await (tx || db)(TableName.Certificate).where("profileId", profileId).count("*").first(); @@ -546,7 +438,6 @@ export const certificateProfileDALFactory = (db: TDbClient) => { countByProjectId, findByNameAndProjectId, getCertificatesByProfile, - getProfileMetrics, isProfileInUse }; }; diff --git a/backend/src/services/certificate-profile/certificate-profile-schemas.ts b/backend/src/services/certificate-profile/certificate-profile-schemas.ts index a2c391c2a..8ac494fe6 100644 --- a/backend/src/services/certificate-profile/certificate-profile-schemas.ts +++ b/backend/src/services/certificate-profile/certificate-profile-schemas.ts @@ -127,8 +127,3 @@ export const listCertificatesByProfileSchema = z.object({ status: z.enum(["active", "expired", "revoked"]).optional(), search: z.string().optional() }); - -export const getCertificateProfileMetricsSchema = z.object({ - profileId: z.string().uuid(), - expiringDays: z.coerce.number().min(1).max(365).default(30) -}); diff --git a/backend/src/services/certificate-profile/certificate-profile-service.test.ts b/backend/src/services/certificate-profile/certificate-profile-service.test.ts index 26b1e976a..bb30b8d5c 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.test.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.test.ts @@ -47,7 +47,6 @@ describe("CertificateProfileService", () => { findByNameAndProjectId: vi.fn(), findByIdWithConfigs: vi.fn(), getCertificatesByProfile: vi.fn(), - getProfileMetrics: vi.fn(), isProfileInUse: vi.fn(), transaction: vi.fn(), find: vi.fn(), @@ -493,9 +492,7 @@ describe("CertificateProfileService", () => { limit: 20, search: undefined, enrollmentType: undefined, - caId: undefined, - includeMetrics: false, - expiringDays: 30 + caId: undefined }); }); @@ -515,51 +512,7 @@ describe("CertificateProfileService", () => { limit: 5, search: "test", enrollmentType: EnrollmentType.API, - caId: "ca-123", - includeMetrics: false, - expiringDays: 30 - }); - }); - - it("should list profiles with metrics when includeMetrics is true", async () => { - const mockProfilesWithMetrics = [ - { - ...sampleProfile, - total_certificates: 10, - active_certificates: 8, - expired_certificates: 1, - expiring_certificates: 1, - revoked_certificates: 0 - } - ]; - (mockCertificateProfileDAL.findByProjectId as any).mockResolvedValue(mockProfilesWithMetrics); - - const result = await service.listProfiles({ - ...mockActor, - projectId: "project-123", - includeMetrics: true, - expiringDays: 15 - }); - - expect(result.profiles).toHaveLength(1); - expect(result.profiles[0]).toHaveProperty("metrics"); - expect(result.profiles[0].metrics).toEqual({ - profileId: sampleProfile.id, - totalCertificates: 10, - activeCertificates: 8, - expiredCertificates: 1, - expiringCertificates: 1, - revokedCertificates: 0 - }); - - expect(mockCertificateProfileDAL.findByProjectId).toHaveBeenCalledWith("project-123", { - offset: 0, - limit: 20, - search: undefined, - enrollmentType: undefined, - caId: undefined, - includeMetrics: true, - expiringDays: 15 + caId: "ca-123" }); }); }); @@ -659,54 +612,6 @@ describe("CertificateProfileService", () => { }); }); - describe("getProfileMetrics", () => { - const mockMetrics = { - profileId: "profile-123", - totalCertificates: 10, - activeCertificates: 8, - expiredCertificates: 1, - expiringCertificates: 2, - revokedCertificates: 1 - }; - - beforeEach(() => { - (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile); - (mockCertificateProfileDAL.getProfileMetrics as any).mockResolvedValue(mockMetrics); - }); - - it("should get profile metrics successfully", async () => { - const result = await service.getProfileMetrics({ - ...mockActor, - profileId: "profile-123" - }); - - expect(result).toEqual(mockMetrics); - expect(mockCertificateProfileDAL.findById).toHaveBeenCalledWith("profile-123"); - expect(mockCertificateProfileDAL.getProfileMetrics).toHaveBeenCalledWith("profile-123", 30); - }); - - it("should get profile metrics with custom expiring days", async () => { - await service.getProfileMetrics({ - ...mockActor, - profileId: "profile-123", - expiringDays: 60 - }); - - expect(mockCertificateProfileDAL.getProfileMetrics).toHaveBeenCalledWith("profile-123", 60); - }); - - it("should throw NotFoundError when profile not found", async () => { - (mockCertificateProfileDAL.findById as any).mockResolvedValue(null); - - await expect( - service.getProfileMetrics({ - ...mockActor, - profileId: "profile-123" - }) - ).rejects.toThrow(NotFoundError); - }); - }); - describe("comprehensive certificate profile scenarios", () => { describe("profile configuration validation", () => { it("should validate EST enrollment configuration", async () => { @@ -929,53 +834,6 @@ describe("CertificateProfileService", () => { }); }); - describe("metrics and monitoring", () => { - it("should calculate profile metrics correctly", async () => { - const detailedMetrics = { - profileId: "profile-123", - totalCertificates: 50, - activeCertificates: 40, - expiredCertificates: 5, - expiringCertificates: 3, - revokedCertificates: 2 - }; - - (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile); - (mockCertificateProfileDAL.getProfileMetrics as any).mockResolvedValue(detailedMetrics); - - const result = await service.getProfileMetrics({ - ...mockActor, - profileId: "profile-123", - expiringDays: 14 - }); - - expect(result).toEqual(detailedMetrics); - expect(mockCertificateProfileDAL.getProfileMetrics).toHaveBeenCalledWith("profile-123", 14); - }); - - it("should handle zero certificate metrics", async () => { - const emptyMetrics = { - profileId: "profile-123", - totalCertificates: 0, - activeCertificates: 0, - expiredCertificates: 0, - expiringCertificates: 0, - revokedCertificates: 0 - }; - - (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile); - (mockCertificateProfileDAL.getProfileMetrics as any).mockResolvedValue(emptyMetrics); - - const result = await service.getProfileMetrics({ - ...mockActor, - profileId: "profile-123" - }); - - expect(result.totalCertificates).toBe(0); - expect(result.activeCertificates).toBe(0); - }); - }); - describe("error scenarios", () => { it("should handle database connection errors gracefully", async () => { (mockCertificateProfileDAL.findById as any).mockRejectedValue(new Error("Database connection failed")); diff --git a/backend/src/services/certificate-profile/certificate-profile-service.ts b/backend/src/services/certificate-profile/certificate-profile-service.ts index 7b48af8f1..f858a8d4f 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.ts @@ -27,10 +27,8 @@ import { TCertificateProfile, TCertificateProfileCertificate, TCertificateProfileInsert, - TCertificateProfileMetrics, TCertificateProfileUpdate, - TCertificateProfileWithConfigs, - TCertificateProfileWithRawMetrics + TCertificateProfileWithConfigs } from "./certificate-profile-types"; const validateAndEncryptPemCaChain = async ( @@ -361,18 +359,14 @@ export const certificateProfileServiceFactory = ({ actorId, actorAuthMethod, actorOrgId, - profileId, - includeMetrics = false, - expiringDays = 30 + profileId }: { actor: ActorType; actorId: string; actorAuthMethod: ActorAuthMethod; actorOrgId: string; profileId: string; - includeMetrics?: boolean; - expiringDays?: number; - }): Promise => { + }): Promise => { const profile = await certificateProfileDAL.findById(profileId); if (!profile) { throw new NotFoundError({ message: "Certificate profile not found" }); @@ -393,14 +387,6 @@ export const certificateProfileServiceFactory = ({ const converted = convertDalToService(profile); - if (includeMetrics) { - const metrics = await certificateProfileDAL.getProfileMetrics(profileId, expiringDays); - return { - ...converted, - metrics - }; - } - return converted; }; @@ -506,9 +492,7 @@ export const certificateProfileServiceFactory = ({ limit = 20, search, enrollmentType, - caId, - includeMetrics = false, - expiringDays = 30 + caId }: { actor: ActorType; actorId: string; @@ -520,10 +504,8 @@ export const certificateProfileServiceFactory = ({ search?: string; enrollmentType?: EnrollmentType; caId?: string; - includeMetrics?: boolean; - expiringDays?: number; }): Promise<{ - profiles: (TCertificateProfileWithConfigs & { metrics?: TCertificateProfileMetrics })[]; + profiles: TCertificateProfileWithConfigs[]; totalCount: number; }> => { const { permission } = await permissionService.getProjectPermission({ @@ -544,9 +526,7 @@ export const certificateProfileServiceFactory = ({ limit, search, enrollmentType, - caId, - includeMetrics, - expiringDays + caId }); const totalCount = await certificateProfileDAL.countByProjectId(projectId, { @@ -591,27 +571,12 @@ export const certificateProfileServiceFactory = ({ } const converted = convertDalToService(profileWithConfigs); - let result: TCertificateProfileWithConfigs & { metrics?: TCertificateProfileMetrics } = { + const result: TCertificateProfileWithConfigs = { ...converted, estConfig: decryptedEstConfig, apiConfig: profileWithConfigs.apiConfig }; - if (includeMetrics) { - const profileWithMetrics = profile as TCertificateProfileWithRawMetrics; - result = { - ...result, - metrics: { - profileId: converted.id, - totalCertificates: parseInt(String(profileWithMetrics.total_certificates || 0), 10), - activeCertificates: parseInt(String(profileWithMetrics.active_certificates || 0), 10), - expiredCertificates: parseInt(String(profileWithMetrics.expired_certificates || 0), 10), - expiringCertificates: parseInt(String(profileWithMetrics.expiring_certificates || 0), 10), - revokedCertificates: parseInt(String(profileWithMetrics.revoked_certificates || 0), 10) - } - }; - } - return result; }) ); @@ -709,43 +674,6 @@ export const certificateProfileServiceFactory = ({ return certificates; }; - const getProfileMetrics = async ({ - actor, - actorId, - actorAuthMethod, - actorOrgId, - profileId, - expiringDays = 30 - }: { - actor: ActorType; - actorId: string; - actorAuthMethod: ActorAuthMethod; - actorOrgId: string; - profileId: string; - expiringDays?: number; - }): Promise => { - const profile = await certificateProfileDAL.findById(profileId); - if (!profile) { - throw new NotFoundError({ message: "Certificate profile not found" }); - } - - const { permission } = await permissionService.getProjectPermission({ - actor, - actorId, - projectId: profile.projectId, - actorAuthMethod, - actorOrgId, - actionProjectType: ActionProjectType.CertificateManager - }); - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionCertificateProfileActions.Read, - ProjectPermissionSub.CertificateProfiles - ); - - const metrics = await certificateProfileDAL.getProfileMetrics(profileId, expiringDays); - return metrics; - }; - const getEstConfigurationByProfile = async ( params: | { @@ -818,7 +746,6 @@ export const certificateProfileServiceFactory = ({ listProfiles, deleteProfile, getProfileCertificates, - getProfileMetrics, getEstConfigurationByProfile }; }; diff --git a/backend/src/services/certificate-profile/certificate-profile-types.ts b/backend/src/services/certificate-profile/certificate-profile-types.ts index 1c22a5e75..5dac470c8 100644 --- a/backend/src/services/certificate-profile/certificate-profile-types.ts +++ b/backend/src/services/certificate-profile/certificate-profile-types.ts @@ -54,18 +54,8 @@ export type TCertificateProfileWithConfigs = TCertificateProfile & { autoRenew: boolean; renewBeforeDays?: number; }; - metrics?: TCertificateProfileMetrics; }; -export interface TCertificateProfileMetrics { - profileId: string; - totalCertificates: number; - activeCertificates: number; - expiredCertificates: number; - expiringCertificates: number; - revokedCertificates: number; -} - export interface TCertificateProfileCertificate { id: string; serialNumber: string; @@ -76,11 +66,3 @@ export interface TCertificateProfileCertificate { revokedAt: Date | null; createdAt: Date; } - -export type TCertificateProfileWithRawMetrics = TCertificateProfile & { - total_certificates?: string; - active_certificates?: string; - expired_certificates?: string; - expiring_certificates?: string; - revoked_certificates?: string; -}; diff --git a/backend/src/services/certificate-sync/certificate-sync-dal.ts b/backend/src/services/certificate-sync/certificate-sync-dal.ts new file mode 100644 index 000000000..a46ae8e5a --- /dev/null +++ b/backend/src/services/certificate-sync/certificate-sync-dal.ts @@ -0,0 +1,272 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName, TCertificateSyncs } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { buildFindFilter, ormify, selectAllTableCols } from "@app/lib/knex"; + +import { CertificateSyncStatus } from "./certificate-sync-enums"; + +export type TCertificateSyncDALFactory = ReturnType; + +type CertificateSyncFindFilter = Parameters>[0]; + +export const certificateSyncDALFactory = (db: TDbClient) => { + const certificateSyncOrm = ormify(db, TableName.CertificateSync); + + const findByPkiSyncId = async (pkiSyncId: string, tx?: Knex) => { + try { + const docs = await (tx || db.replicaNode())(TableName.CertificateSync) + .where({ pkiSyncId }) + .select(selectAllTableCols(TableName.CertificateSync)); + return docs; + } catch (error) { + throw new DatabaseError({ error, name: "FindByPkiSyncId" }); + } + }; + + const findByCertificateId = async (certificateId: string, tx?: Knex) => { + try { + const docs = await (tx || db.replicaNode())(TableName.CertificateSync) + .where({ certificateId }) + .select(selectAllTableCols(TableName.CertificateSync)); + return docs; + } catch (error) { + throw new DatabaseError({ error, name: "FindByCertificateId" }); + } + }; + + const findByPkiSyncAndCertificate = async (pkiSyncId: string, certificateId: string, tx?: Knex) => { + try { + const doc = await (tx || db.replicaNode())(TableName.CertificateSync) + .where({ pkiSyncId, certificateId }) + .select(selectAllTableCols(TableName.CertificateSync)) + .first(); + return doc; + } catch (error) { + throw new DatabaseError({ error, name: "FindByPkiSyncAndCertificate" }); + } + }; + + const findCertificateIdsByPkiSyncId = async (pkiSyncId: string, tx?: Knex): Promise => { + try { + const docs = (await (tx || db.replicaNode())(TableName.CertificateSync) + .where({ pkiSyncId }) + .select("certificateId")) as Array<{ certificateId: string }>; + return docs.map((doc) => doc.certificateId); + } catch (error) { + throw new DatabaseError({ error, name: "FindCertificateIdsByPkiSyncId" }); + } + }; + + const findPkiSyncIdsByCertificateId = async (certificateId: string, tx?: Knex): Promise => { + try { + const docs = (await (tx || db.replicaNode())(TableName.CertificateSync) + .where({ certificateId }) + .select("pkiSyncId")) as Array<{ pkiSyncId: string }>; + return docs.map((doc) => doc.pkiSyncId); + } catch (error) { + throw new DatabaseError({ error, name: "FindPkiSyncIdsByCertificateId" }); + } + }; + + const addCertificates = async ( + pkiSyncId: string, + certificateData: Array<{ certificateId: string; externalIdentifier?: string }>, + tx?: Knex + ): Promise => { + try { + const insertData = certificateData.map(({ certificateId, externalIdentifier }) => ({ + pkiSyncId, + certificateId, + syncStatus: CertificateSyncStatus.Pending, + externalIdentifier + })); + + const docs = await (tx || db)(TableName.CertificateSync).insert(insertData).returning("*"); + + return docs; + } catch (error) { + throw new DatabaseError({ error, name: "AddCertificates" }); + } + }; + + const removeCertificates = async (pkiSyncId: string, certificateIds: string[], tx?: Knex): Promise => { + try { + const deletedCount = await (tx || db)(TableName.CertificateSync) + .where({ pkiSyncId }) + .whereIn("certificateId", certificateIds) + .del(); + + return deletedCount; + } catch (error) { + throw new DatabaseError({ error, name: "RemoveCertificates" }); + } + }; + + const removeAllCertificatesFromSync = async (pkiSyncId: string, tx?: Knex): Promise => { + try { + const deletedCount = await (tx || db)(TableName.CertificateSync).where({ pkiSyncId }).del(); + return deletedCount; + } catch (error) { + throw new DatabaseError({ error, name: "RemoveAllCertificatesFromSync" }); + } + }; + + const updateSyncStatus = async ( + pkiSyncId: string, + certificateId: string, + status: string, + message?: string, + tx?: Knex + ): Promise => { + try { + const updateData: Partial = { + syncStatus: status, + lastSyncedAt: new Date() + }; + + if (message !== undefined) { + updateData.lastSyncMessage = message; + } + + const docs = await (tx || db)(TableName.CertificateSync) + .where({ pkiSyncId, certificateId }) + .update(updateData) + .returning("*"); + + return docs[0]; + } catch (error) { + throw new DatabaseError({ error, name: "UpdateSyncStatus" }); + } + }; + + const bulkUpdateSyncStatus = async ( + updates: Array<{ + pkiSyncId: string; + certificateId: string; + status: string; + message?: string; + }>, + tx?: Knex + ): Promise => { + try { + if (tx) { + for (const update of updates) { + // eslint-disable-next-line no-await-in-loop + await updateSyncStatus(update.pkiSyncId, update.certificateId, update.status, update.message, tx); + } + } else { + await certificateSyncOrm.transaction(async (trx) => { + for (const update of updates) { + // eslint-disable-next-line no-await-in-loop + await updateSyncStatus(update.pkiSyncId, update.certificateId, update.status, update.message, trx); + } + }); + } + } catch (error) { + throw new DatabaseError({ error, name: "BulkUpdateSyncStatus" }); + } + }; + + const findWithDetails = async ( + options: { + filter?: CertificateSyncFindFilter; + pkiSyncId?: string; + offset?: number; + limit?: number; + }, + tx?: Knex + ): Promise<{ + certificateDetails: (TCertificateSyncs & { + certificateSerialNumber?: string; + certificateCommonName?: string; + certificateAltNames?: string; + certificateStatus?: string; + certificateNotBefore?: Date; + certificateNotAfter?: Date; + certificateRenewBeforeDays?: number | null; + certificateRenewedByCertificateId?: string; + certificateRenewalError?: string; + pkiSyncName?: string; + pkiSyncDestination?: string; + })[]; + totalCount: number; + }> => { + try { + const { filter, pkiSyncId, offset, limit } = options; + + const baseQuery = (tx || db.replicaNode())(TableName.CertificateSync) + .leftJoin(TableName.Certificate, `${TableName.CertificateSync}.certificateId`, `${TableName.Certificate}.id`) + .leftJoin(TableName.PkiSync, `${TableName.CertificateSync}.pkiSyncId`, `${TableName.PkiSync}.id`); + + if (filter) { + // eslint-disable-next-line @typescript-eslint/no-misused-promises + void baseQuery.where(buildFindFilter(filter)); + } + if (pkiSyncId) { + void baseQuery.where(`${TableName.CertificateSync}.pkiSyncId`, pkiSyncId); + } + + const countResult = await baseQuery.clone().count("* as count"); + const totalCount = Number((countResult[0] as unknown as { count: string | number }).count); + + const query = baseQuery + .select(selectAllTableCols(TableName.CertificateSync)) + .select( + db.ref("serialNumber").withSchema(TableName.Certificate).as("certificateSerialNumber"), + db.ref("commonName").withSchema(TableName.Certificate).as("certificateCommonName"), + db.ref("altNames").withSchema(TableName.Certificate).as("certificateAltNames"), + db.ref("status").withSchema(TableName.Certificate).as("certificateStatus"), + db.ref("notBefore").withSchema(TableName.Certificate).as("certificateNotBefore"), + db.ref("notAfter").withSchema(TableName.Certificate).as("certificateNotAfter"), + db.ref("renewBeforeDays").withSchema(TableName.Certificate).as("certificateRenewBeforeDays"), + db.ref("renewedByCertificateId").withSchema(TableName.Certificate).as("certificateRenewedByCertificateId"), + db.ref("renewalError").withSchema(TableName.Certificate).as("certificateRenewalError"), + db.ref("name").withSchema(TableName.PkiSync).as("pkiSyncName"), + db.ref("destination").withSchema(TableName.PkiSync).as("pkiSyncDestination") + ) + .orderBy(`${TableName.CertificateSync}.createdAt`, "desc"); + + if (offset !== undefined) { + void query.offset(offset); + } + if (limit !== undefined) { + void query.limit(limit); + } + + const certificateDetails = (await query) as (TCertificateSyncs & { + certificateSerialNumber?: string; + certificateCommonName?: string; + certificateAltNames?: string; + certificateStatus?: string; + certificateNotBefore?: Date; + certificateNotAfter?: Date; + certificateRenewBeforeDays?: number; + certificateRenewedByCertificateId?: string; + certificateRenewalError?: string; + pkiSyncName?: string; + pkiSyncDestination?: string; + })[]; + + return { certificateDetails, totalCount }; + } catch (error) { + throw new DatabaseError({ error, name: "FindWithDetails" }); + } + }; + + return { + ...certificateSyncOrm, + findByPkiSyncId, + findByCertificateId, + findByPkiSyncAndCertificate, + findCertificateIdsByPkiSyncId, + findPkiSyncIdsByCertificateId, + addCertificates, + removeCertificates, + removeAllCertificatesFromSync, + updateSyncStatus, + bulkUpdateSyncStatus, + findWithDetails + }; +}; diff --git a/backend/src/services/certificate-sync/certificate-sync-enums.ts b/backend/src/services/certificate-sync/certificate-sync-enums.ts new file mode 100644 index 000000000..7b9eedafb --- /dev/null +++ b/backend/src/services/certificate-sync/certificate-sync-enums.ts @@ -0,0 +1,7 @@ +export enum CertificateSyncStatus { + Pending = "pending", + Syncing = "syncing", + Succeeded = "succeeded", + Failed = "failed", + Running = "running" +} diff --git a/backend/src/services/certificate-v3/certificate-v3-service.test.ts b/backend/src/services/certificate-v3/certificate-v3-service.test.ts index 0c70571dd..d11cce056 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.test.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.test.ts @@ -133,7 +133,18 @@ describe("CertificateV3Service", () => { certificateProfileDAL: mockCertificateProfileDAL, certificateTemplateV2Service: mockCertificateTemplateV2Service, internalCaService: mockInternalCaService, - permissionService: mockPermissionService + permissionService: mockPermissionService, + certificateSyncDAL: { + findPkiSyncIdsByCertificateId: vi.fn().mockResolvedValue([]), + addCertificates: vi.fn().mockResolvedValue([]), + findByPkiSyncAndCertificate: vi.fn().mockResolvedValue(null) + }, + pkiSyncDAL: { + find: vi.fn().mockResolvedValue([]) + }, + pkiSyncQueue: { + queuePkiSyncSyncCertificatesById: vi.fn().mockResolvedValue(undefined) + } }); }); diff --git a/backend/src/services/certificate-v3/certificate-v3-service.ts b/backend/src/services/certificate-v3/certificate-v3-service.ts index 0a721b2db..51c79f135 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.ts @@ -48,6 +48,10 @@ import { mapEnumsForValidation, normalizeDateForApi } from "../certificate-common/certificate-utils"; +import { TCertificateSyncDALFactory } from "../certificate-sync/certificate-sync-dal"; +import { TPkiSyncDALFactory } from "../pki-sync/pki-sync-dal"; +import { TPkiSyncQueueFactory } from "../pki-sync/pki-sync-queue"; +import { addRenewedCertificateToSyncs, triggerAutoSyncForCertificate } from "../pki-sync/pki-sync-utils"; import { TCertificateFromProfileResponse, TCertificateOrderResponse, @@ -72,6 +76,12 @@ type TCertificateV3ServiceFactoryDep = { >; internalCaService: Pick; permissionService: Pick; + certificateSyncDAL: Pick< + TCertificateSyncDALFactory, + "findPkiSyncIdsByCertificateId" | "addCertificates" | "findByPkiSyncAndCertificate" + >; + pkiSyncDAL: Pick; + pkiSyncQueue: Pick; }; export type TCertificateV3ServiceFactory = ReturnType; @@ -328,7 +338,10 @@ export const certificateV3ServiceFactory = ({ certificateProfileDAL, certificateTemplateV2Service, internalCaService, - permissionService + permissionService, + certificateSyncDAL, + pkiSyncDAL, + pkiSyncQueue }: TCertificateV3ServiceFactoryDep) => { const issueCertificateFromProfile = async ({ profileId, @@ -872,6 +885,8 @@ export const certificateV3ServiceFactory = ({ tx ); + await addRenewedCertificateToSyncs(originalCert.id, newCert.id, { certificateSyncDAL }, tx); + return { certificate, certificateChain, @@ -883,6 +898,12 @@ export const certificateV3ServiceFactory = ({ }; }); + await triggerAutoSyncForCertificate(renewalResult.newCert.id, { + certificateSyncDAL, + pkiSyncDAL, + pkiSyncQueue + }); + return { certificate: renewalResult.certificate, issuingCaCertificate: renewalResult.issuingCaCertificate, diff --git a/backend/src/services/certificate/certificate-dal.ts b/backend/src/services/certificate/certificate-dal.ts index eb40b85a5..7af79319b 100644 --- a/backend/src/services/certificate/certificate-dal.ts +++ b/backend/src/services/certificate/certificate-dal.ts @@ -1,3 +1,5 @@ +import RE2 from "re2"; + import { TDbClient } from "@app/db"; import { TableName, TCertificates } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; @@ -60,11 +62,13 @@ export const certificateDALFactory = (db: TDbClient) => { .where(`${TableName.Project}.id`, projectId); if (friendlyName) { - query = query.andWhere(`${TableName.Certificate}.friendlyName`, friendlyName); + const sanitizedValue = String(friendlyName).replace(new RE2("[%_\\\\]", "g"), "\\$&"); + query = query.andWhere(`${TableName.Certificate}.friendlyName`, "like", `%${sanitizedValue}%`); } if (commonName) { - query = query.andWhere(`${TableName.Certificate}.commonName`, commonName); + const sanitizedValue = String(commonName).replace(new RE2("[%_\\\\]", "g"), "\\$&"); + query = query.andWhere(`${TableName.Certificate}.commonName`, "like", `%${sanitizedValue}%`); } const count = await query.count("*").first(); @@ -114,6 +118,109 @@ export const certificateDALFactory = (db: TDbClient) => { } }; + const findActiveCertificatesByIds = async (certificateIds: string[]): Promise => { + try { + if (certificateIds.length === 0) { + return []; + } + + const certs = await db + .replicaNode()(TableName.Certificate) + .whereIn("id", certificateIds) + .where({ status: CertStatus.ACTIVE }) + .where("notAfter", ">", new Date()) + .orderBy("notBefore", "desc") + .select("*"); + + return certs; + } catch (error) { + throw new DatabaseError({ error, name: "Find active certificates by IDs" }); + } + }; + + const findActiveCertificatesForSync = async ( + filter: Partial, + options?: { limit?: number; offset?: number } + ): Promise<(TCertificates & { hasPrivateKey: boolean })[]> => { + try { + let query = db + .replicaNode()(TableName.Certificate) + .leftJoin(TableName.CertificateSecret, `${TableName.Certificate}.id`, `${TableName.CertificateSecret}.certId`) + .select(selectAllTableCols(TableName.Certificate)) + .select(db.ref(`${TableName.CertificateSecret}.certId`).as("privateKeyRef")) + .where({ status: CertStatus.ACTIVE }) + .where("notAfter", ">", new Date()) + .whereNull("renewedByCertificateId"); + + Object.entries(filter).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + if (key === "friendlyName" || key === "commonName") { + const sanitizedValue = String(value).replace(new RE2("[%_\\\\]", "g"), "\\$&"); + query = query.andWhere(`${TableName.Certificate}.${key}`, "like", `%${sanitizedValue}%`); + } else { + query = query.andWhere(`${TableName.Certificate}.${key}`, value); + } + } + }); + + if (options?.offset) { + query = query.offset(options.offset); + } + + if (options?.limit) { + query = query.limit(options.limit); + } + + query = query.orderBy("createdAt", "desc"); + + const certs = await query; + return certs.map((cert) => ({ ...cert, hasPrivateKey: Boolean(cert.privateKeyRef) })); + } catch (error) { + throw new DatabaseError({ error, name: "Find active certificates for sync" }); + } + }; + + const countActiveCertificatesForSync = async ({ + projectId, + friendlyName, + commonName + }: { + projectId: string; + friendlyName?: string; + commonName?: string; + }) => { + try { + interface CountResult { + count: string; + } + + let query = db + .replicaNode()(TableName.Certificate) + .join(TableName.CertificateAuthority, `${TableName.Certificate}.caId`, `${TableName.CertificateAuthority}.id`) + .join(TableName.Project, `${TableName.CertificateAuthority}.projectId`, `${TableName.Project}.id`) + .where(`${TableName.Project}.id`, projectId) + .where(`${TableName.Certificate}.status`, CertStatus.ACTIVE) + .where(`${TableName.Certificate}.notAfter`, ">", new Date()) + .whereNull(`${TableName.Certificate}.renewedByCertificateId`); + + if (friendlyName) { + const sanitizedValue = String(friendlyName).replace(new RE2("[%_\\\\]", "g"), "\\$&"); + query = query.andWhere(`${TableName.Certificate}.friendlyName`, "like", `%${sanitizedValue}%`); + } + + if (commonName) { + const sanitizedValue = String(commonName).replace(new RE2("[%_\\\\]", "g"), "\\$&"); + query = query.andWhere(`${TableName.Certificate}.commonName`, "like", `%${sanitizedValue}%`); + } + + const count = await query.count("*").first(); + + return parseInt((count as unknown as CountResult).count || "0", 10); + } catch (error) { + throw new DatabaseError({ error, name: "Count active certificates for sync" }); + } + }; + const findCertificatesEligibleForRenewal = async ({ limit, offset @@ -159,7 +266,7 @@ export const certificateDALFactory = (db: TDbClient) => { }; const findWithPrivateKeyInfo = async ( - filter: Partial, + filter: Partial, options?: { offset?: number; limit?: number; sort?: [string, "asc" | "desc"][] } ): Promise<(TCertificates & { hasPrivateKey: boolean })[]> => { try { @@ -167,8 +274,18 @@ export const certificateDALFactory = (db: TDbClient) => { .replicaNode()(TableName.Certificate) .leftJoin(TableName.CertificateSecret, `${TableName.Certificate}.id`, `${TableName.CertificateSecret}.certId`) .select(selectAllTableCols(TableName.Certificate)) - .select(db.ref(`${TableName.CertificateSecret}.certId`).as("privateKeyRef")) - .where(filter); + .select(db.ref(`${TableName.CertificateSecret}.certId`).as("privateKeyRef")); + + Object.entries(filter).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + if (key === "friendlyName" || key === "commonName") { + const sanitizedValue = String(value).replace(new RE2("[%_\\\\]", "g"), "\\$&"); + query = query.andWhere(`${TableName.Certificate}.${key}`, "like", `%${sanitizedValue}%`); + } else { + query = query.andWhere(`${TableName.Certificate}.${key}`, value); + } + } + }); if (options?.offset) { query = query.offset(options.offset); @@ -197,10 +314,13 @@ export const certificateDALFactory = (db: TDbClient) => { return { ...certificateOrm, countCertificatesInProject, + countActiveCertificatesForSync, countCertificatesForPkiSubscriber, findLatestActiveCertForSubscriber, findAllActiveCertsForSubscriber, findExpiredSyncedCertificates, + findActiveCertificatesByIds, + findActiveCertificatesForSync, findCertificatesEligibleForRenewal, findWithPrivateKeyInfo }; diff --git a/backend/src/services/certificate/certificate-service.ts b/backend/src/services/certificate/certificate-service.ts index 91731c387..eb8006f00 100644 --- a/backend/src/services/certificate/certificate-service.ts +++ b/backend/src/services/certificate/certificate-service.ts @@ -18,12 +18,13 @@ import { TCertificateAuthorityDALFactory } from "@app/services/certificate-autho import { CaCapability, CaType } from "@app/services/certificate-authority/certificate-authority-enums"; import { caSupportsCapability } from "@app/services/certificate-authority/certificate-authority-maps"; import { TCertificateAuthoritySecretDALFactory } from "@app/services/certificate-authority/certificate-authority-secret-dal"; +import { TCertificateSyncDALFactory } from "@app/services/certificate-sync/certificate-sync-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { 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 { triggerAutoSyncForCertificate } from "@app/services/pki-sync/pki-sync-utils"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; @@ -57,6 +58,7 @@ type TCertificateServiceFactoryDep = { projectDAL: Pick; kmsService: Pick; permissionService: Pick; + certificateSyncDAL: Pick; pkiSyncDAL: Pick; pkiSyncQueue: Pick; }; @@ -76,6 +78,7 @@ export const certificateServiceFactory = ({ projectDAL, kmsService, permissionService, + certificateSyncDAL, pkiSyncDAL, pkiSyncQueue }: TCertificateServiceFactoryDep) => { @@ -166,10 +169,12 @@ 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 }); - } + // Trigger auto sync for PKI syncs connected to this certificate + await triggerAutoSyncForCertificate(cert.id, { + certificateSyncDAL, + pkiSyncDAL, + pkiSyncQueue + }); return { deletedCert @@ -235,10 +240,12 @@ export const certificateServiceFactory = ({ } ); - // Trigger auto sync for PKI syncs connected to this certificate's subscriber - if (cert.pkiSubscriberId) { - await triggerAutoSyncForSubscriber(cert.pkiSubscriberId, { pkiSyncDAL, pkiSyncQueue }); - } + // Trigger auto sync for PKI syncs connected to this certificate + await triggerAutoSyncForCertificate(cert.id, { + certificateSyncDAL, + pkiSyncDAL, + pkiSyncQueue + }); // Note: External CA revocation handling would go here for supported CA types // Currently, only internal CAs and ACME CAs support revocation diff --git a/backend/src/services/integration-auth/integration-list.ts b/backend/src/services/integration-auth/integration-list.ts index 0608bbd4b..e4e1d3126 100644 --- a/backend/src/services/integration-auth/integration-list.ts +++ b/backend/src/services/integration-auth/integration-list.ts @@ -104,7 +104,8 @@ export enum IntegrationUrls { GCP_SERVICE_USAGE_URL = "https://serviceusage.googleapis.com", GCP_CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform", - GITHUB_USER_INSTALLATIONS = "https://api.github.com/user/installations" + GITHUB_USER_INSTALLATIONS = "https://api.github.com/user/installations", + CHEF_API_URL = "https://api.chef.io" } export const getIntegrationOptions = async () => { diff --git a/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-fns.ts b/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-fns.ts index f78bd790e..3e07420b5 100644 --- a/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-fns.ts +++ b/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-fns.ts @@ -3,7 +3,9 @@ import * as AWS from "aws-sdk"; import RE2 from "re2"; import { z } from "zod"; +import { TCertificateSyncs } from "@app/db/schemas"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal"; import { AppConnection, AWSRegion } from "@app/services/app-connection/app-connection-enums"; import { decryptAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns"; @@ -14,6 +16,9 @@ import { AwsConnectionAssumeRoleCredentialsSchema } from "@app/services/app-connection/aws/aws-connection-schemas"; import { TAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-types"; +import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { TCertificateSyncDALFactory } from "@app/services/certificate-sync/certificate-sync-dal"; +import { CertificateSyncStatus } from "@app/services/certificate-sync/certificate-sync-enums"; import { createConnectionQueue, RateLimitConfig } from "@app/services/connection-queue"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TCertificateMap } from "@app/services/pki-sync/pki-sync-types"; @@ -88,39 +93,6 @@ const shouldSkipCertificateExport = (certificate: AWS.ACM.CertificateSummary): b return isAwsIssuedCertificate(certificate); }; -const findTagByKey = (tags: AWS.ACM.TagList | undefined, key: string): AWS.ACM.Tag | undefined => { - if (!tags || !Array.isArray(tags)) { - return undefined; - } - return tags.find((tag: AWS.ACM.Tag) => tag.Key === key && tag.Value); -}; - -const findInfisicalCertificateTag = (tags: AWS.ACM.TagList | undefined): AWS.ACM.Tag | undefined => { - return findTagByKey(tags, INFISICAL_CERTIFICATE_TAG); -}; - -const validateCertificateIdentification = ( - certName: string, - existingCert: { arn?: string; Tags?: AWS.ACM.TagList; cert?: string; privateKey?: string; certificateChain?: string } -): boolean => { - if (!existingCert?.arn || !existingCert?.Tags) { - return false; - } - - const certNameTag = findInfisicalCertificateTag(existingCert.Tags); - - if (!certNameTag || !certNameTag.Value) { - return false; - } - - return certNameTag.Value === certName; -}; - -type TAwsCertificateManagerPkiSyncFactoryDeps = { - appConnectionDAL: Pick; - kmsService: Pick; -}; - const validateCertificateNameSchema = (schema: string): void => { if (!schema.includes("{{certificateId}}")) { throw new Error( @@ -174,6 +146,21 @@ const generateCertificateName = (certificateName: string, pkiSync: TPkiSyncWithC return sanitizedCertificateName; }; +type TAwsCertificateManagerPkiSyncFactoryDeps = { + appConnectionDAL: Pick; + kmsService: Pick; + certificateSyncDAL: Pick< + TCertificateSyncDALFactory, + | "removeCertificates" + | "addCertificates" + | "findByPkiSyncAndCertificate" + | "updateSyncStatus" + | "updateById" + | "findByPkiSyncId" + >; + certificateDAL: Pick; +}; + const getAwsAcmClient = async ( connectionId: string, region: AWSRegion, @@ -230,7 +217,9 @@ const getAwsAcmClient = async ( export const awsCertificateManagerPkiSyncFactory = ({ kmsService, - appConnectionDAL + appConnectionDAL, + certificateSyncDAL, + certificateDAL }: TAwsCertificateManagerPkiSyncFactoryDeps) => { const deleteCertificateFromAcm = async ( acm: AWS.ACM, @@ -392,79 +381,201 @@ export const awsCertificateManagerPkiSyncFactory = ({ kmsService ); - const { acmCertificates } = await $getAwsAcmCertificates(acm, pkiSync.id); + const { + acmCertificates + }: { + acmCertificates: Record< + string, + { cert: string; privateKey: string; certificateChain?: string; arn?: string; Tags?: AWS.ACM.TagList } + >; + } = await $getAwsAcmCertificates(acm, pkiSync.id); + + const acmCertificatesByArn = new Map(); + Object.values(acmCertificates).forEach((acmCert) => { + if (acmCert.arn) { + acmCertificatesByArn.set(acmCert.arn, acmCert); + } + }); + + const existingSyncRecords = await certificateSyncDAL.findByPkiSyncId(pkiSync.id); + const syncRecordsByCertId = new Map(); + const syncRecordsByExternalId = new Map(); + + existingSyncRecords.forEach((record: TCertificateSyncs) => { + if (record.certificateId) { + syncRecordsByCertId.set(record.certificateId, record); + } + if (record.externalIdentifier) { + syncRecordsByExternalId.set(record.externalIdentifier, record); + } + }); const setCertificates: CertificateImportRequest[] = []; + const validationErrors: Array<{ name: string; error: string }> = []; - const activeCertificateNames = Object.keys(certificateMap); + const syncOptions = pkiSync.syncOptions as { preserveArn?: boolean; canRemoveCertificates?: boolean } | undefined; + const preserveArn = syncOptions?.preserveArn ?? true; + const canRemoveCertificates = syncOptions?.canRemoveCertificates ?? true; - Object.entries(certificateMap).forEach(([certName, certData]) => { - const { cert, privateKey, certificateChain } = certData; - const certificateName = generateCertificateName(certName, pkiSync); + const activeExternalIdentifiers = new Set(); - const existingCert = Object.values(acmCertificates).find((acmCert) => - validateCertificateIdentification(certName, acmCert) - ); - - const shouldUpdateCert = !existingCert || existingCert.cert !== cert; + for (const [certName, certData] of Object.entries(certificateMap)) { + const { cert, privateKey, certificateChain, certificateId } = certData; try { validateCertificateContent(cert, privateKey); } catch (validationError) { - throw new PkiSyncError({ - message: `Certificate validation failed for ${certName}: ${validationError instanceof Error ? validationError.message : String(validationError)}`, - shouldRetry: false, - context: { - certificateName, - certName - } + const errorMessage = validationError instanceof Error ? validationError.message : String(validationError); + validationErrors.push({ + name: certName, + error: `Certificate validation failed: ${errorMessage}` }); + // eslint-disable-next-line no-continue + continue; } - if (shouldUpdateCert) { + if (preserveArn && certificateId && typeof certificateId === "string") { + const certificate = await certificateDAL.findById(certificateId); + if (certificate?.renewedByCertificateId) { + // eslint-disable-next-line no-continue + continue; + } + } + + const certificateName = generateCertificateName(certName, pkiSync); + + let targetArn: string | undefined; + let shouldCreateNew = false; + + if (!certificateId || typeof certificateId !== "string") { + shouldCreateNew = true; + } else { + const currentCertificate = await certificateDAL.findById(certificateId); + const isRenewal = !!currentCertificate?.renewedFromCertificateId; + + if (isRenewal) { + const currentSyncRecord = syncRecordsByCertId.get(certificateId); + const oldCertificateId = currentCertificate.renewedFromCertificateId; + const oldSyncRecord = oldCertificateId ? syncRecordsByCertId.get(oldCertificateId) : undefined; + + if (currentSyncRecord?.externalIdentifier) { + const existingAcmCert = acmCertificatesByArn.get(currentSyncRecord.externalIdentifier); + + if (existingAcmCert) { + if (!preserveArn && oldSyncRecord?.externalIdentifier === currentSyncRecord.externalIdentifier) { + shouldCreateNew = true; + } else if (preserveArn && oldSyncRecord?.externalIdentifier === currentSyncRecord.externalIdentifier) { + targetArn = currentSyncRecord.externalIdentifier; + shouldCreateNew = true; + activeExternalIdentifiers.add(targetArn); + + if (oldCertificateId && oldSyncRecord) { + await certificateSyncDAL.removeCertificates(pkiSync.id, [oldCertificateId]); + } + } else { + targetArn = currentSyncRecord.externalIdentifier; + activeExternalIdentifiers.add(targetArn); + shouldCreateNew = false; + } + } else { + shouldCreateNew = true; + } + } else if (preserveArn && oldSyncRecord?.externalIdentifier) { + const existingAcmCert = acmCertificatesByArn.get(oldSyncRecord.externalIdentifier); + + if (existingAcmCert) { + targetArn = oldSyncRecord.externalIdentifier; + shouldCreateNew = true; + activeExternalIdentifiers.add(targetArn); + if (oldCertificateId) { + await certificateSyncDAL.removeCertificates(pkiSync.id, [oldCertificateId]); + } + } else { + shouldCreateNew = true; + } + } else { + shouldCreateNew = true; + } + } else { + const existingSyncRecord = syncRecordsByCertId.get(certificateId); + if (existingSyncRecord?.externalIdentifier) { + const existingAcmCert = acmCertificatesByArn.get(existingSyncRecord.externalIdentifier); + if (existingAcmCert) { + targetArn = existingSyncRecord.externalIdentifier; + activeExternalIdentifiers.add(targetArn); + shouldCreateNew = false; + } else { + shouldCreateNew = true; + } + } else { + shouldCreateNew = true; + } + } + } + + if (shouldCreateNew) { setCertificates.push({ key: certName, name: certificateName, cert, privateKey, certificateChain, - existingArn: existingCert?.arn + existingArn: targetArn, + certificateId: certificateId as string }); } - }); - // Identify expired/removed certificates that need to be cleaned up from ACM - const certificatesToRemove = Object.values(acmCertificates) - .filter((acmCert) => { - if (!acmCert.arn || !acmCert.Tags) { - return false; + if (targetArn) { + activeExternalIdentifiers.add(targetArn); + } + } + + const certificatesToRemove: string[] = []; + + if (canRemoveCertificates) { + existingSyncRecords.forEach((syncRecord) => { + if (syncRecord.externalIdentifier && !activeExternalIdentifiers.has(syncRecord.externalIdentifier)) { + const acmCert = acmCertificatesByArn.get(syncRecord.externalIdentifier); + if (acmCert?.arn) { + certificatesToRemove.push(acmCert.arn); + } } + }); - const certNameTag = findInfisicalCertificateTag(acmCert.Tags); - if (!certNameTag || !certNameTag.Value) { - return false; + Object.values(acmCertificates).forEach((acmCert) => { + if (acmCert.arn && acmCert.Tags) { + const hasInfisicalTag = acmCert.Tags.some((tag) => tag.Key === INFISICAL_CERTIFICATE_TAG && tag.Value); + + if (hasInfisicalTag) { + const isTrackedInSyncRecords = existingSyncRecords.some( + (record) => record.externalIdentifier === acmCert.arn + ); + const isInActiveSet = activeExternalIdentifiers.has(acmCert.arn); + if (!isTrackedInSyncRecords && !isInActiveSet && !certificatesToRemove.includes(acmCert.arn)) { + certificatesToRemove.push(acmCert.arn); + } + } } - - const isActive = activeCertificateNames.includes(certNameTag.Value); - return !isActive; - }) - .map((acmCert) => acmCert.arn!) - .filter((arn) => arn); + }); + } const uploadResults = await executeWithConcurrencyLimit( setCertificates, - async ({ key, name, cert, privateKey, certificateChain, existingArn }) => { + async ({ key, name, cert, privateKey, certificateChain, existingArn, certificateId }) => { try { const importParams: AWS.ACM.ImportCertificateRequest = { Certificate: cert, - PrivateKey: privateKey, - Tags: [ + PrivateKey: privateKey + }; + + if (!existingArn) { + importParams.Tags = [ { Key: INFISICAL_CERTIFICATE_TAG, Value: key } - ] - }; + ]; + } if (certificateChain && certificateChain.trim().length > 0) { importParams.CertificateChain = certificateChain; @@ -478,6 +589,57 @@ export const awsCertificateManagerPkiSyncFactory = ({ syncId: pkiSync.id }); + if (existingArn && response.CertificateArn) { + try { + // Small delay to ensure AWS ACM has processed the certificate import + await new Promise((resolve) => { + setTimeout(() => resolve(), 500); + }); + + await withRateLimitRetry( + () => + acm + .addTagsToCertificate({ + CertificateArn: response.CertificateArn!, + Tags: [ + { + Key: INFISICAL_CERTIFICATE_TAG, + Value: key + } + ] + }) + .promise(), + { + operation: "add-tags-to-certificate", + syncId: pkiSync.id + } + ); + } catch (tagError) { + const errorMessage = tagError instanceof Error ? tagError.message : "Unknown tagging error"; + logger.warn( + `Failed to add tags to certificate ${key} (ARN: ${response.CertificateArn}): ${errorMessage}` + ); + } + } + + if (response.CertificateArn && certificateId) { + const existingCertSync = await certificateSyncDAL.findByPkiSyncAndCertificate(pkiSync.id, certificateId); + if (existingCertSync) { + await certificateSyncDAL.updateById(existingCertSync.id, { + externalIdentifier: response.CertificateArn, + syncStatus: CertificateSyncStatus.Succeeded, + lastSyncedAt: new Date() + }); + } else { + await certificateSyncDAL.addCertificates(pkiSync.id, [ + { + certificateId, + externalIdentifier: response.CertificateArn + } + ]); + } + } + return { key, name, success: true, response }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; @@ -520,15 +682,21 @@ export const awsCertificateManagerPkiSyncFactory = ({ const details: { failedUploads?: Array<{ name: string; error: string }>; failedRemovals?: Array<{ name: string; error: string }>; + validationErrors?: Array<{ name: string; error: string }>; } = {}; + if (validationErrors.length > 0) { + details.validationErrors = validationErrors; + } + if (failedUploads.length > 0) { details.failedUploads = failedUploads.map((failure, index) => { - const certificateName = setCertificates[index]?.name || "unknown"; + const certificateRequest = setCertificates[index]; + const certificateName = certificateRequest?.name || certificateRequest?.key || "unknown"; let errorMessage = "Unknown error"; if (failure.status === "rejected") { - errorMessage = failure.reason instanceof Error ? failure.reason.message : "Unknown error"; + errorMessage = failure.reason instanceof Error ? failure.reason.message : String(failure.reason); } return { @@ -567,7 +735,8 @@ export const awsCertificateManagerPkiSyncFactory = ({ const removeCertificates = async ( pkiSync: TPkiSyncWithCredentials, - certificateNames: string[] + certificateNames: string[], + deps?: { certificateSyncDAL?: TCertificateSyncDALFactory; certificateMap?: TCertificateMap } ): Promise => { const destinationConfig = pkiSync.destinationConfig as TAwsCertificateManagerPkiSyncConfig; const acm = await getAwsAcmClient( @@ -577,22 +746,33 @@ export const awsCertificateManagerPkiSyncFactory = ({ kmsService ); - const { acmCertificates } = await $getAwsAcmCertificates(acm, pkiSync.id); - + const existingSyncRecords = await certificateSyncDAL.findByPkiSyncId(pkiSync.id); const certificateArnsToRemove: string[] = []; - + const certificateIdToArnMap = new Map(); for (const certName of certificateNames) { - const matchingCerts = Object.values(acmCertificates).filter((acmCert) => - validateCertificateIdentification(certName, acmCert) - ); + const certificateData = deps?.certificateMap?.[certName]; + if (certificateData?.certificateId) { + const { certificateId } = certificateData; - for (const acmCert of matchingCerts) { - if (acmCert.arn) { - certificateArnsToRemove.push(acmCert.arn); + if (typeof certificateId === "string") { + const syncRecord = existingSyncRecords.find((record) => record.certificateId === certificateId); + + if (syncRecord?.externalIdentifier) { + certificateArnsToRemove.push(syncRecord.externalIdentifier); + certificateIdToArnMap.set(certificateId, syncRecord.externalIdentifier); + } } } } + if (certificateArnsToRemove.length === 0) { + return { + removed: 0, + failed: 0, + skipped: certificateNames.length + }; + } + const results = await executeWithConcurrencyLimit( certificateArnsToRemove, async (certificateArn) => @@ -602,6 +782,38 @@ export const awsCertificateManagerPkiSyncFactory = ({ const failedRemovals = results.filter((result) => result.status === "rejected"); + if (failedRemovals.length > 0 && deps?.certificateSyncDAL) { + for (const failure of failedRemovals) { + if (failure.status === "rejected") { + const failedArn = certificateArnsToRemove[results.indexOf(failure)]; + const certificateId = Array.from(certificateIdToArnMap.entries()).find(([, arn]) => arn === failedArn)?.[0]; + + if (certificateId) { + const errorMessage = failure.reason instanceof Error ? failure.reason.message : "Unknown error"; + await deps.certificateSyncDAL.updateSyncStatus( + pkiSync.id, + certificateId, + CertificateSyncStatus.Failed, + `Failed to remove from AWS: ${errorMessage}` + ); + } + } + } + } + + const successfulRemovals = results.filter((result) => result.status === "fulfilled"); + if (successfulRemovals.length > 0) { + const successfulArns = new Set(successfulRemovals.map((_, index) => certificateArnsToRemove[index])); + + const certificateIdsToRemove = Array.from(certificateIdToArnMap.entries()) + .filter(([, arn]) => successfulArns.has(arn)) + .map(([certificateId]) => certificateId); + + if (certificateIdsToRemove.length > 0) { + await certificateSyncDAL.removeCertificates(pkiSync.id, certificateIdsToRemove); + } + } + if (failedRemovals.length > 0) { const failedReasons = failedRemovals.map((failure) => { if (failure.status === "rejected") { diff --git a/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-schemas.ts b/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-schemas.ts index eb9ae5444..3b9f5c881 100644 --- a/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-schemas.ts +++ b/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-schemas.ts @@ -14,6 +14,7 @@ export const AwsCertificateManagerPkiSyncConfigSchema = z.object({ const AwsCertificateManagerPkiSyncOptionsSchema = z.object({ canImportCertificates: z.boolean().default(false), canRemoveCertificates: z.boolean().default(true), + preserveArn: z.boolean().default(true), certificateNameSchema: z .string() .optional() @@ -28,6 +29,9 @@ const AwsCertificateManagerPkiSyncOptionsSchema = z.object({ const testName = schema .replace(new RE2("\\{\\{certificateId\\}\\}", "g"), "test-cert-id") + .replace(new RE2("\\{\\{profileId\\}\\}", "g"), "test-profile-id") + .replace(new RE2("\\{\\{commonName\\}\\}", "g"), "test-common-name") + .replace(new RE2("\\{\\{friendlyName\\}\\}", "g"), "test-friendly-name") .replace(new RE2("\\{\\{environment\\}\\}", "g"), "test-env"); const hasForbiddenChars = AWS_CERTIFICATE_MANAGER_CERTIFICATE_NAMING.FORBIDDEN_CHARACTERS.split("").some( @@ -43,7 +47,7 @@ const AwsCertificateManagerPkiSyncOptionsSchema = z.object({ }, { message: - "Certificate name schema must include {{certificateId}} placeholder and result in names that contain only alphanumeric characters, spaces, hyphens, and underscores and be 1-256 characters long when compiled for AWS Certificate Manager" + "Certificate name schema must include {{certificateId}} placeholder and result in names that contain only alphanumeric characters, spaces, hyphens, and underscores and be 1-256 characters long when compiled for AWS Certificate Manager. Available placeholders: {{certificateId}}, {{profileId}}, {{commonName}}, {{friendlyName}}, {{environment}}" } ) }); @@ -60,9 +64,10 @@ export const CreateAwsCertificateManagerPkiSyncSchema = z.object({ isAutoSyncEnabled: z.boolean().default(true), destinationConfig: AwsCertificateManagerPkiSyncConfigSchema, syncOptions: AwsCertificateManagerPkiSyncOptionsSchema.optional().default({}), - subscriberId: z.string().optional(), + subscriberId: z.string().nullish(), connectionId: z.string(), - projectId: z.string().trim().min(1) + projectId: z.string().trim().min(1), + certificateIds: z.array(z.string().uuid()).optional() }); export const UpdateAwsCertificateManagerPkiSyncSchema = z.object({ @@ -71,7 +76,7 @@ export const UpdateAwsCertificateManagerPkiSyncSchema = z.object({ isAutoSyncEnabled: z.boolean().optional(), destinationConfig: AwsCertificateManagerPkiSyncConfigSchema.optional(), syncOptions: AwsCertificateManagerPkiSyncOptionsSchema.optional(), - subscriberId: z.string().optional(), + subscriberId: z.string().nullish(), connectionId: z.string().optional() }); diff --git a/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-types.ts b/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-types.ts index 717e86438..8b2b8b87e 100644 --- a/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-types.ts +++ b/backend/src/services/pki-sync/aws-certificate-manager/aws-certificate-manager-pki-sync-types.ts @@ -39,6 +39,7 @@ export interface SyncCertificatesResult { details?: { failedUploads?: Array<{ name: string; error: string }>; failedRemovals?: Array<{ name: string; error: string }>; + validationErrors?: Array<{ name: string; error: string }>; }; } @@ -55,4 +56,5 @@ export interface CertificateImportRequest { privateKey: string; certificateChain?: string; existingArn?: string; + certificateId?: string; } diff --git a/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-fns.ts b/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-fns.ts index b423f42db..765d0b8ba 100644 --- a/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-fns.ts +++ b/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-fns.ts @@ -2,10 +2,14 @@ import { AxiosError } from "axios"; import * as crypto from "crypto"; +import { TCertificateSyncs } from "@app/db/schemas"; 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 { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { TCertificateSyncDALFactory } from "@app/services/certificate-sync/certificate-sync-dal"; +import { CertificateSyncStatus } from "@app/services/certificate-sync/certificate-sync-enums"; import { createConnectionQueue, RateLimitConfig } from "@app/services/connection-queue"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { matchesCertificateNameSchema } from "@app/services/pki-sync/pki-sync-fns"; @@ -32,7 +36,9 @@ const extractCertificateNameFromId = (certificateId: string): string => { }; const isInfisicalManagedCertificate = (certificateName: string, pkiSync: TPkiSyncWithCredentials): boolean => { - const syncOptions = pkiSync.syncOptions as { certificateNameSchema?: string } | undefined; + const syncOptions = pkiSync.syncOptions as + | { certificateNameSchema?: string; canRemoveCertificates?: boolean } + | undefined; const certificateNameSchema = syncOptions?.certificateNameSchema; if (certificateNameSchema) { @@ -46,6 +52,16 @@ const isInfisicalManagedCertificate = (certificateName: string, pkiSync: TPkiSyn type TAzureKeyVaultPkiSyncFactoryDeps = { appConnectionDAL: Pick; kmsService: Pick; + certificateSyncDAL: Pick< + TCertificateSyncDALFactory, + | "removeCertificates" + | "addCertificates" + | "findByPkiSyncAndCertificate" + | "updateById" + | "findByPkiSyncId" + | "updateSyncStatus" + >; + certificateDAL: Pick; }; const parseCertificateX509Props = (certPem: string) => { @@ -188,7 +204,12 @@ const parseCertificateKeyProps = (certPem: string) => { } }; -export const azureKeyVaultPkiSyncFactory = ({ kmsService, appConnectionDAL }: TAzureKeyVaultPkiSyncFactoryDeps) => { +export const azureKeyVaultPkiSyncFactory = ({ + kmsService, + appConnectionDAL, + certificateSyncDAL, + certificateDAL +}: TAzureKeyVaultPkiSyncFactoryDeps) => { const $getAzureKeyVaultCertificates = async (accessToken: string, vaultBaseUrl: string, syncId = "unknown") => { const paginateAzureKeyVaultCertificates = async () => { let result: GetAzureKeyVaultCertificate[] = []; @@ -325,48 +346,126 @@ export const azureKeyVaultPkiSyncFactory = ({ kmsService, appConnectionDAL }: TA pkiSync.id ); + const existingSyncRecords = await certificateSyncDAL.findByPkiSyncId(pkiSync.id); + const syncRecordsByCertId = new Map(); + const syncRecordsByExternalId = new Map(); + + existingSyncRecords.forEach((record: TCertificateSyncs) => { + if (record.certificateId) { + syncRecordsByCertId.set(record.certificateId, record); + } + if (record.externalIdentifier) { + syncRecordsByExternalId.set(record.externalIdentifier, record); + } + }); + const setCertificates: { key: string; cert: string; privateKey: string; certificateChain?: string; + certificateId?: string; }[] = []; - // Track which certificates should exist in Azure Key Vault - const activeCertificateNames = Object.keys(certificateMap); + const syncOptions = pkiSync.syncOptions as + | { certificateNameSchema?: string; canRemoveCertificates?: boolean; enableVersioning?: boolean } + | undefined; + const canRemoveCertificates = syncOptions?.canRemoveCertificates ?? true; + const enableVersioning = syncOptions?.enableVersioning ?? true; + + const activeExternalIdentifiers = new Set(); // Iterate through certificates to sync to Azure Key Vault - Object.entries(certificateMap).forEach(([certName, { cert, privateKey, certificateChain }]) => { + for (const [certName, { cert, privateKey, certificateChain, certificateId }] of Object.entries(certificateMap)) { if (disabledAzureKeyVaultCertificateKeys.includes(certName)) { - return; + // eslint-disable-next-line no-continue + continue; } - const existingCert = vaultCertificates[certName]; - const shouldUpdateCert = !existingCert || existingCert.cert !== cert; + if (enableVersioning && typeof certificateId === "string") { + const certificate = await certificateDAL.findById(certificateId); + if (certificate?.renewedByCertificateId) { + // eslint-disable-next-line no-continue + continue; + } + } - if (shouldUpdateCert) { + let targetCertName = certName; + let shouldCreateNew = false; + + if (typeof certificateId === "string") { + const existingSyncRecord = syncRecordsByCertId.get(certificateId); + + if (existingSyncRecord?.externalIdentifier) { + const existingAzureCert = vaultCertificates[existingSyncRecord.externalIdentifier]; + + if (existingAzureCert && enableVersioning) { + targetCertName = existingSyncRecord.externalIdentifier; + activeExternalIdentifiers.add(targetCertName); + + const shouldUpdateCert = existingAzureCert.cert !== cert; + if (shouldUpdateCert) { + shouldCreateNew = true; + } + } else if (!existingAzureCert) { + shouldCreateNew = true; + } else if (!enableVersioning) { + shouldCreateNew = true; + } + } else { + shouldCreateNew = true; + } + } else { + shouldCreateNew = true; + } + + if (shouldCreateNew || !vaultCertificates[targetCertName] || vaultCertificates[targetCertName].cert !== cert) { setCertificates.push({ - key: certName, + key: targetCertName, cert, privateKey, - certificateChain + certificateChain, + certificateId }); } - }); - // 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) - ); + if (targetCertName) { + activeExternalIdentifiers.add(targetCertName); + } + } + + const certificatesToRemove: string[] = []; + + if (canRemoveCertificates) { + existingSyncRecords.forEach((syncRecord) => { + if (syncRecord.externalIdentifier && !activeExternalIdentifiers.has(syncRecord.externalIdentifier)) { + if (vaultCertificates[syncRecord.externalIdentifier]) { + certificatesToRemove.push(syncRecord.externalIdentifier); + } + } + }); + + Object.keys(vaultCertificates).forEach((certificateName) => { + const isInfisicalManaged = isInfisicalManagedCertificate(certificateName, pkiSync); + + if (isInfisicalManaged) { + const isTrackedInSyncRecords = existingSyncRecords.some( + (record) => record.externalIdentifier === certificateName + ); + + const isInActiveSet = activeExternalIdentifiers.has(certificateName); + + if (!isTrackedInSyncRecords && !isInActiveSet && !certificatesToRemove.includes(certificateName)) { + certificatesToRemove.push(certificateName); + } + } + }); + } // Upload certificates to Azure Key Vault with rate limiting const uploadResults = await executeWithConcurrencyLimit( setCertificates, - async ({ key, cert, privateKey, certificateChain }) => { + async ({ key, cert, privateKey, certificateChain, certificateId }) => { try { // Combine private key, certificate, and certificate chain in PEM format for Azure Key Vault let combinedPem = ""; @@ -428,6 +527,31 @@ export const azureKeyVaultPkiSyncFactory = ({ kmsService, appConnectionDAL }: TA } ); + if (certificateId) { + const existingCertSync = await certificateSyncDAL.findByPkiSyncAndCertificate(pkiSync.id, certificateId); + if (existingCertSync) { + await certificateSyncDAL.updateById(existingCertSync.id, { + externalIdentifier: key, + syncStatus: CertificateSyncStatus.Succeeded, + lastSyncedAt: new Date() + }); + } else { + await certificateSyncDAL.addCertificates(pkiSync.id, [ + { + certificateId, + externalIdentifier: key + } + ]); + } + + if (enableVersioning) { + const currentCertificate = await certificateDAL.findById(certificateId); + if (currentCertificate?.renewedFromCertificateId) { + await certificateSyncDAL.removeCertificates(pkiSync.id, [currentCertificate.renewedFromCertificateId]); + } + } + } + return { key, success: true, response: response.data as unknown }; } catch (error) { if (error instanceof AxiosError) { @@ -599,19 +723,43 @@ export const azureKeyVaultPkiSyncFactory = ({ kmsService, appConnectionDAL }: TA }; }; - const removeCertificates = async (pkiSync: TPkiSyncWithCredentials, certificateNames: string[]) => { + const removeCertificates = async ( + pkiSync: TPkiSyncWithCredentials, + certificateNames: string[], + deps?: { certificateSyncDAL?: TCertificateSyncDALFactory; certificateMap?: TCertificateMap } + ) => { const { accessToken } = await getAzureConnectionAccessToken(pkiSync.connection.id, appConnectionDAL, kmsService); // 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 existingSyncRecords = await certificateSyncDAL.findByPkiSyncId(pkiSync.id); + const certificateNamesToRemove: string[] = []; + const certificateIdToNameMap = new Map(); + + for (const certName of certificateNames) { + if (deps?.certificateMap?.[certName]?.certificateId) { + const { certificateId } = deps.certificateMap[certName]; + + const syncRecord = existingSyncRecords.find((record) => record.certificateId === certificateId); + + if (syncRecord?.externalIdentifier && typeof certificateId === "string") { + certificateNamesToRemove.push(syncRecord.externalIdentifier); + certificateIdToNameMap.set(certificateId, syncRecord.externalIdentifier); + } + } + } + + if (certificateNamesToRemove.length === 0) { + return { + removed: 0, + failed: 0, + skipped: certificateNames.length + }; + } const results = await executeWithConcurrencyLimit( - infisicalManagedCertNames, + certificateNamesToRemove, async (certName) => { try { const response = await request.delete( @@ -646,8 +794,44 @@ export const azureKeyVaultPkiSyncFactory = ({ kmsService, appConnectionDAL }: TA }, { operation: "remove-specific-certificates", syncId: pkiSync.id } ); + const failedRemovals = results.filter((result) => result.status === "rejected"); + if (failedRemovals.length > 0 && deps?.certificateSyncDAL) { + for (const failure of failedRemovals) { + if (failure.status === "rejected") { + const failedCertName = certificateNamesToRemove[results.indexOf(failure)]; + + const certificateId = Array.from(certificateIdToNameMap.entries()).find( + ([, name]) => name === failedCertName + )?.[0]; + + if (certificateId) { + const errorMessage = (failure.reason as Error)?.message || "Unknown error"; + await deps.certificateSyncDAL.updateSyncStatus( + pkiSync.id, + certificateId, + CertificateSyncStatus.Failed, + `Failed to remove from Azure: ${errorMessage}` + ); + } + } + } + } + + const successfulRemovals = results.filter((result) => result.status === "fulfilled"); + if (successfulRemovals.length > 0) { + const successfulCertNames = new Set(successfulRemovals.map((_, index) => certificateNamesToRemove[index])); + + const certificateIdsToRemove = Array.from(certificateIdToNameMap.entries()) + .filter(([, name]) => successfulCertNames.has(name)) + .map(([certificateId]) => certificateId); + + if (certificateIdsToRemove.length > 0) { + await certificateSyncDAL.removeCertificates(pkiSync.id, certificateIdsToRemove); + } + } + if (failedRemovals.length > 0) { const failedReasons = failedRemovals.map((failure) => { if (failure.status === "rejected") { @@ -660,16 +844,16 @@ export const azureKeyVaultPkiSyncFactory = ({ kmsService, appConnectionDAL }: TA message: `Failed to remove ${failedRemovals.length} certificate(s) from Azure Key Vault`, context: { failedReasons, - totalCertificates: infisicalManagedCertNames.length, + totalCertificates: certificateNamesToRemove.length, failedCount: failedRemovals.length } }); } return { - removed: infisicalManagedCertNames.length - failedRemovals.length, + removed: certificateNamesToRemove.length - failedRemovals.length, failed: failedRemovals.length, - skipped: certificateNames.length - infisicalManagedCertNames.length + skipped: certificateNames.length - certificateNamesToRemove.length }; }; diff --git a/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-schemas.ts b/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-schemas.ts index ef6347e82..90f4a119b 100644 --- a/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-schemas.ts +++ b/backend/src/services/pki-sync/azure-key-vault/azure-key-vault-pki-sync-schemas.ts @@ -14,6 +14,7 @@ export const AzureKeyVaultPkiSyncConfigSchema = z.object({ const AzureKeyVaultPkiSyncOptionsSchema = z.object({ canImportCertificates: z.boolean().default(false), canRemoveCertificates: z.boolean().default(true), + enableVersioning: z.boolean().default(true), certificateNameSchema: z .string() .optional() @@ -50,9 +51,10 @@ export const CreateAzureKeyVaultPkiSyncSchema = z.object({ isAutoSyncEnabled: z.boolean().default(true), destinationConfig: AzureKeyVaultPkiSyncConfigSchema, syncOptions: AzureKeyVaultPkiSyncOptionsSchema.optional().default({}), - subscriberId: z.string().optional(), + subscriberId: z.string().nullish(), connectionId: z.string(), - projectId: z.string().trim().min(1) + projectId: z.string().trim().min(1), + certificateIds: z.array(z.string().uuid()).optional() }); export const UpdateAzureKeyVaultPkiSyncSchema = z.object({ @@ -61,7 +63,7 @@ export const UpdateAzureKeyVaultPkiSyncSchema = z.object({ isAutoSyncEnabled: z.boolean().optional(), destinationConfig: AzureKeyVaultPkiSyncConfigSchema.optional(), syncOptions: AzureKeyVaultPkiSyncOptionsSchema.optional(), - subscriberId: z.string().optional(), + subscriberId: z.string().nullish(), connectionId: z.string().optional() }); diff --git a/backend/src/services/pki-sync/pki-sync-fns.ts b/backend/src/services/pki-sync/pki-sync-fns.ts index 75f312fff..961687f85 100644 --- a/backend/src/services/pki-sync/pki-sync-fns.ts +++ b/backend/src/services/pki-sync/pki-sync-fns.ts @@ -4,6 +4,8 @@ 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 { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { TCertificateSyncDALFactory } from "@app/services/certificate-sync/certificate-sync-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { AWS_CERTIFICATE_MANAGER_PKI_SYNC_LIST_OPTION } from "./aws-certificate-manager/aws-certificate-manager-pki-sync-constants"; @@ -184,6 +186,8 @@ export const PkiSyncFns = { dependencies: { appConnectionDAL: Pick; kmsService: Pick; + certificateDAL: TCertificateDALFactory; + certificateSyncDAL: TCertificateSyncDALFactory; } ): Promise<{ uploaded: number; @@ -194,17 +198,28 @@ export const PkiSyncFns = { failedUploads?: Array<{ name: string; error: string }>; failedRemovals?: Array<{ name: string; error: string }>; skippedCertificates?: Array<{ name: string; reason: string }>; + validationErrors?: Array<{ name: string; error: string }>; }; }> => { switch (pkiSync.destination) { case PkiSync.AzureKeyVault: { checkPkiSyncDestination(pkiSync, PkiSync.AzureKeyVault); - const azureKeyVaultPkiSync = azureKeyVaultPkiSyncFactory(dependencies); + const azureKeyVaultPkiSync = azureKeyVaultPkiSyncFactory({ + appConnectionDAL: dependencies.appConnectionDAL, + kmsService: dependencies.kmsService, + certificateDAL: dependencies.certificateDAL, + certificateSyncDAL: dependencies.certificateSyncDAL + }); return azureKeyVaultPkiSync.syncCertificates(pkiSync, certificateMap); } case PkiSync.AwsCertificateManager: { checkPkiSyncDestination(pkiSync, PkiSync.AwsCertificateManager); - const awsCertificateManagerPkiSync = awsCertificateManagerPkiSyncFactory(dependencies); + const awsCertificateManagerPkiSync = awsCertificateManagerPkiSyncFactory({ + appConnectionDAL: dependencies.appConnectionDAL, + kmsService: dependencies.kmsService, + certificateDAL: dependencies.certificateDAL, + certificateSyncDAL: dependencies.certificateSyncDAL + }); return awsCertificateManagerPkiSync.syncCertificates(pkiSync, certificateMap); } default: @@ -218,19 +233,38 @@ export const PkiSyncFns = { dependencies: { appConnectionDAL: Pick; kmsService: Pick; + certificateSyncDAL: TCertificateSyncDALFactory; + certificateDAL: TCertificateDALFactory; + certificateMap: TCertificateMap; } ): Promise => { switch (pkiSync.destination) { case PkiSync.AzureKeyVault: { checkPkiSyncDestination(pkiSync, PkiSync.AzureKeyVault); - const azureKeyVaultPkiSync = azureKeyVaultPkiSyncFactory(dependencies); - await azureKeyVaultPkiSync.removeCertificates(pkiSync, certificateNames); + const azureKeyVaultPkiSync = azureKeyVaultPkiSyncFactory({ + appConnectionDAL: dependencies.appConnectionDAL, + kmsService: dependencies.kmsService, + certificateDAL: dependencies.certificateDAL, + certificateSyncDAL: dependencies.certificateSyncDAL + }); + await azureKeyVaultPkiSync.removeCertificates(pkiSync, certificateNames, { + certificateSyncDAL: dependencies.certificateSyncDAL, + certificateMap: dependencies.certificateMap + }); break; } case PkiSync.AwsCertificateManager: { checkPkiSyncDestination(pkiSync, PkiSync.AwsCertificateManager); - const awsCertificateManagerPkiSync = awsCertificateManagerPkiSyncFactory(dependencies); - await awsCertificateManagerPkiSync.removeCertificates(pkiSync, certificateNames); + const awsCertificateManagerPkiSync = awsCertificateManagerPkiSyncFactory({ + appConnectionDAL: dependencies.appConnectionDAL, + kmsService: dependencies.kmsService, + certificateDAL: dependencies.certificateDAL, + certificateSyncDAL: dependencies.certificateSyncDAL + }); + await awsCertificateManagerPkiSync.removeCertificates(pkiSync, certificateNames, { + certificateSyncDAL: dependencies.certificateSyncDAL, + certificateMap: dependencies.certificateMap + }); break; } default: diff --git a/backend/src/services/pki-sync/pki-sync-queue.ts b/backend/src/services/pki-sync/pki-sync-queue.ts index 5967a6c97..608162ead 100644 --- a/backend/src/services/pki-sync/pki-sync-queue.ts +++ b/backend/src/services/pki-sync/pki-sync-queue.ts @@ -5,6 +5,7 @@ import { AxiosError } from "axios"; import { Job } from "bullmq"; import handlebars from "handlebars"; +import { TCertificates } from "@app/db/schemas"; 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"; @@ -25,6 +26,8 @@ import { TCertificateSecretDALFactory } from "../certificate/certificate-secret- 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 { TCertificateSyncDALFactory } from "../certificate-sync/certificate-sync-dal"; +import { CertificateSyncStatus } from "../certificate-sync/certificate-sync-enums"; import { TPkiSyncDALFactory } from "./pki-sync-dal"; import { PkiSyncStatus } from "./pki-sync-enums"; import { PkiSyncError } from "./pki-sync-errors"; @@ -55,14 +58,12 @@ type TPkiSyncQueueFactoryDep = { auditLogService: Pick; projectDAL: TProjectDALFactory; licenseService: Pick; - certificateDAL: Pick< - TCertificateDALFactory, - "findLatestActiveCertForSubscriber" | "findAllActiveCertsForSubscriber" | "create" - >; + certificateDAL: TCertificateDALFactory; certificateBodyDAL: Pick; certificateSecretDAL: Pick; certificateAuthorityDAL: Pick; certificateAuthorityCertDAL: Pick; + certificateSyncDAL: TCertificateSyncDALFactory; }; type PkiSyncActionJob = Job< @@ -93,7 +94,8 @@ export const pkiSyncQueueFactory = ({ certificateBodyDAL, certificateSecretDAL, certificateAuthorityDAL, - certificateAuthorityCertDAL + certificateAuthorityCertDAL, + certificateSyncDAL }: TPkiSyncQueueFactoryDep) => { const appCfg = getConfig(); @@ -153,25 +155,39 @@ export const pkiSyncQueueFactory = ({ const $getInfisicalCertificates = async ( pkiSync: TPkiSyncRaw | TPkiSyncWithCredentials - ): Promise => { - 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 - }); - } + ): Promise<{ certificateMap: TCertificateMap; certificateMetadata: Map }> => { + const { projectId, subscriberId, id: pkiSyncId } = pkiSync; const certificateMap: TCertificateMap = {}; + const certificateMetadata = new Map(); + let certificates: Array<{ id: string; projectId: string; caCertId?: string | null }> = []; try { - // Get all active certificates for the subscriber (not just the latest) - const certificates = await certificateDAL.findAllActiveCertsForSubscriber({ - subscriberId - }); + if (subscriberId) { + const subscriberCertificates = await certificateDAL.findAllActiveCertsForSubscriber({ + subscriberId + }); + certificates.push(...subscriberCertificates); + } + + const certificateIds = await certificateSyncDAL.findCertificateIdsByPkiSyncId(pkiSyncId); + if (certificateIds.length > 0) { + const directCertificates = await certificateDAL.findActiveCertificatesByIds(certificateIds); + certificates.push(...directCertificates); + } + + const uniqueCertificates = certificates.filter( + (cert, index, self) => self.findIndex((c) => c.id === cert.id) === index + ); + + if (uniqueCertificates.length === 0) { + return { certificateMap, certificateMetadata }; + } + + certificates = uniqueCertificates; for (const certificate of certificates) { + const cert = certificate as TCertificates; try { // Get the certificate body and decrypt the certificate data const certBody = await certificateBodyDAL.findOne({ certId: certificate.id }); @@ -246,19 +262,45 @@ export const pkiSyncQueueFactory = ({ if (certificateNameSchema) { const environment = "global"; - certificateName = handlebars.compile(certificateNameSchema)({ + const templateData = { certificateId: certificate.id.replace(/-/g, ""), + profileId: cert.profileId?.replace(/-/g, "") || certificate.id.replace(/-/g, ""), + commonName: cert.commonName || "", + friendlyName: cert.friendlyName || "", environment - }); + }; + certificateName = handlebars.compile(certificateNameSchema)(templateData); } else { - certificateName = `Infisical-${certificate.id.replace(/-/g, "")}`; + const stableId = cert.profileId + ? `${cert.profileId.replace(/-/g, "")}-${(cert.commonName || "").replace(/[^a-zA-Z0-9]/g, "")}` + : certificate.id.replace(/-/g, ""); + certificateName = `Infisical-${stableId}`; + } + + const alternativeNames: string[] = []; + + const legacyName = `Infisical-${certificate.id.replace(/-/g, "")}`; + if (legacyName !== certificateName) { + alternativeNames.push(legacyName); + } + + if (cert.renewedFromCertificateId) { + const originalLegacyName = `Infisical-${cert.renewedFromCertificateId.replace(/-/g, "")}`; + alternativeNames.push(originalLegacyName); } certificateMap[certificateName] = { cert: certificatePem, privateKey: certPrivateKey || "", - certificateChain + certificateChain, + alternativeNames, + certificateId: certificate.id }; + + certificateMetadata.set(certificateName, { + id: certificate.id, + name: certificateName + }); } else { logger.warn({ certificateId: certificate.id, subscriberId }, "Certificate body not found for certificate"); } @@ -281,7 +323,7 @@ export const pkiSyncQueueFactory = ({ }); } - return certificateMap; + return { certificateMap, certificateMetadata }; }; const queuePkiSyncSyncCertificatesById = async (payload: TQueuePkiSyncSyncCertificatesByIdDTO) => @@ -348,12 +390,17 @@ export const pkiSyncQueueFactory = ({ try { const { - connection: { orgId, encryptedCredentials, projectId: appConnectionProjectId } + connection: { id: connectionId, orgId, projectId: appConnectionProjectId } } = pkiSync; + const appConnection = await appConnectionDAL.findById(connectionId); + if (!appConnection) { + throw new Error(`App connection not found: ${connectionId}`); + } + const credentials = await decryptAppConnectionCredentials({ orgId, - encryptedCredentials, + encryptedCredentials: appConnection.encryptedCredentials, kmsService, projectId: appConnectionProjectId }); @@ -366,11 +413,24 @@ export const pkiSyncQueueFactory = ({ } } as TPkiSyncWithCredentials; - const certificateMap = await $getInfisicalCertificates(pkiSync); + const { certificateMap, certificateMetadata } = await $getInfisicalCertificates(pkiSync); + + const statusUpdates = Array.from(certificateMetadata.entries()).map(([, metadata]) => ({ + pkiSyncId: pkiSync.id, + certificateId: metadata.id, + status: CertificateSyncStatus.Running, + message: "Syncing certificate to destination" + })); + + if (statusUpdates.length > 0) { + await certificateSyncDAL.bulkUpdateSyncStatus(statusUpdates); + } const syncResult = await PkiSyncFns.syncCertificates(pkiSyncWithCredentials, certificateMap, { appConnectionDAL, - kmsService + kmsService, + certificateDAL, + certificateSyncDAL }); logger.info( @@ -384,6 +444,60 @@ export const pkiSyncQueueFactory = ({ "PKI sync operation completed with certificate cleanup" ); + const postSyncUpdates: Array<{ + pkiSyncId: string; + certificateId: string; + status: string; + message?: string; + }> = []; + + for (const [, metadata] of certificateMetadata.entries()) { + postSyncUpdates.push({ + pkiSyncId: pkiSync.id, + certificateId: metadata.id, + status: CertificateSyncStatus.Succeeded, + message: "Certificate successfully synced to destination" + }); + } + + if (syncResult.details?.validationErrors) { + for (const validationError of syncResult.details.validationErrors) { + const metadata = certificateMetadata.get(validationError.name); + if (metadata) { + const updateIndex = postSyncUpdates.findIndex((u) => u.certificateId === metadata.id); + if (updateIndex >= 0) { + postSyncUpdates[updateIndex] = { + pkiSyncId: pkiSync.id, + certificateId: metadata.id, + status: CertificateSyncStatus.Failed, + message: `${validationError.error}` + }; + } + } + } + } + + if (syncResult.details?.failedUploads) { + for (const failure of syncResult.details.failedUploads) { + const metadata = certificateMetadata.get(failure.name); + if (metadata) { + const updateIndex = postSyncUpdates.findIndex((u) => u.certificateId === metadata.id); + if (updateIndex >= 0) { + postSyncUpdates[updateIndex] = { + pkiSyncId: pkiSync.id, + certificateId: metadata.id, + status: CertificateSyncStatus.Failed, + message: `Failed to sync certificate: ${failure.error}` + }; + } + } + } + } + + if (postSyncUpdates.length > 0) { + await certificateSyncDAL.bulkUpdateSyncStatus(postSyncUpdates); + } + isSynced = true; } catch (err) { logger.error( @@ -550,17 +664,22 @@ export const pkiSyncQueueFactory = ({ try { const { - connection: { orgId, encryptedCredentials, projectId: appConnectionProjectId } + connection: { id: connectionId, orgId, projectId: appConnectionProjectId } } = pkiSync; + const appConnection = await appConnectionDAL.findById(connectionId); + if (!appConnection) { + throw new Error(`App connection not found: ${connectionId}`); + } + const credentials = await decryptAppConnectionCredentials({ orgId, - encryptedCredentials, + encryptedCredentials: appConnection.encryptedCredentials, kmsService, projectId: appConnectionProjectId }); - const certificateMap = await $getInfisicalCertificates(pkiSync); + const { certificateMap } = await $getInfisicalCertificates(pkiSync); await PkiSyncFns.removeCertificates( { @@ -573,7 +692,10 @@ export const pkiSyncQueueFactory = ({ Object.keys(certificateMap), { appConnectionDAL, - kmsService + kmsService, + certificateSyncDAL, + certificateDAL, + certificateMap } ); diff --git a/backend/src/services/pki-sync/pki-sync-service.ts b/backend/src/services/pki-sync/pki-sync-service.ts index f92c9e19f..02a76db2a 100644 --- a/backend/src/services/pki-sync/pki-sync-service.ts +++ b/backend/src/services/pki-sync/pki-sync-service.ts @@ -1,6 +1,6 @@ import { ForbiddenError, subject } from "@casl/ability"; -import { ActionProjectType } from "@app/db/schemas"; +import { ActionProjectType, TCertificateSyncs } from "@app/db/schemas"; import { TLicenseServiceFactory } from "@app/ee/services/license/license-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { ProjectPermissionPkiSyncActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; @@ -10,17 +10,24 @@ import { AppConnection } from "@app/services/app-connection/app-connection-enums import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal"; +import { TCertificateDALFactory } from "../certificate/certificate-dal"; +import { TCertificateSyncDALFactory } from "../certificate-sync/certificate-sync-dal"; +import { CertificateSyncStatus } from "../certificate-sync/certificate-sync-enums"; import { TPkiSyncDALFactory } from "./pki-sync-dal"; import { 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 { + TAddCertificatesToPkiSyncDTO, TCreatePkiSyncDTO, TDeletePkiSyncDTO, TFindPkiSyncByIdDTO, + TListPkiSyncCertificatesDTO, TListPkiSyncsByProjectId, TPkiSync, + TPkiSyncCertificate, + TRemoveCertificatesFromPkiSyncDTO, TTriggerPkiSyncImportCertificatesByIdDTO, TTriggerPkiSyncRemoveCertificatesByIdDTO, TTriggerPkiSyncSyncCertificatesByIdDTO, @@ -42,6 +49,17 @@ type TPkiSyncServiceFactoryDep = { TPkiSyncDALFactory, "findById" | "findByProjectIdWithSubscribers" | "findByNameAndProjectId" | "create" | "updateById" | "deleteById" >; + certificateDAL: Pick; + certificateSyncDAL: Pick< + TCertificateSyncDALFactory, + | "findByPkiSyncId" + | "findByCertificateId" + | "findCertificateIdsByPkiSyncId" + | "addCertificates" + | "removeCertificates" + | "removeAllCertificatesFromSync" + | "findWithDetails" + >; pkiSubscriberDAL: Pick; appConnectionService: Pick; permissionService: Pick; @@ -56,12 +74,41 @@ export type TPkiSyncServiceFactory = ReturnType; export const pkiSyncServiceFactory = ({ pkiSyncDAL, + certificateDAL, + certificateSyncDAL, pkiSubscriberDAL, appConnectionService, permissionService, licenseService, pkiSyncQueue }: TPkiSyncServiceFactoryDep) => { + const validateCertificatesProjectOwnership = async (certificateIds: string[], expectedProjectId: string) => { + if (certificateIds.length === 0) return; + + const certificates = await certificateDAL.findActiveCertificatesByIds(certificateIds); + + if (certificates.length !== certificateIds.length) { + const foundIds = certificates.map((cert) => cert.id); + const missingIds = certificateIds.filter((id) => !foundIds.includes(id)); + throw new NotFoundError({ + message: `Certificates not found or not active: ${missingIds.join(", ")}` + }); + } + + const invalidProjectCertificates = certificates.filter((cert) => cert.projectId !== expectedProjectId); + if (invalidProjectCertificates.length > 0) { + throw new BadRequestError({ + message: `Certificates do not belong to the same project: ${invalidProjectCertificates.map((cert) => cert.id).join(", ")}` + }); + } + + const invalidRenewedCertificates = certificates.filter((cert) => cert.renewedByCertificateId); + if (invalidRenewedCertificates.length > 0) { + throw new BadRequestError({ + message: `Cannot add renewed certificates to PKI sync: ${invalidRenewedCertificates.map((cert) => cert.id).join(", ")}` + }); + } + }; const createPkiSync = async ( { name, @@ -72,7 +119,8 @@ export const pkiSyncServiceFactory = ({ syncOptions = {}, subscriberId, connectionId, - projectId + projectId, + certificateIds = [] }: Omit, actor: OrgServiceActor ): Promise => { @@ -114,6 +162,10 @@ export const pkiSyncServiceFactory = ({ ...syncOptions }; + if (certificateIds.length > 0) { + await validateCertificatesProjectOwnership(certificateIds, projectId); + } + try { const pkiSync = await pkiSyncDAL.create({ name, @@ -128,6 +180,13 @@ export const pkiSyncServiceFactory = ({ ...(isAutoSyncEnabled && { syncStatus: PkiSyncStatus.Pending }) }); + if (certificateIds.length > 0) { + await certificateSyncDAL.addCertificates( + pkiSync.id, + certificateIds.map((id) => ({ certificateId: id })) + ); + } + if (pkiSync.isAutoSyncEnabled) { await pkiSyncQueue.queuePkiSyncSyncCertificatesById({ syncId: pkiSync.id }); } @@ -152,7 +211,8 @@ export const pkiSyncServiceFactory = ({ destinationConfig, syncOptions, subscriberId, - connectionId + connectionId, + certificateIds }: Omit, actor: OrgServiceActor ): Promise => { @@ -221,6 +281,20 @@ export const pkiSyncServiceFactory = ({ }; } + if (certificateIds !== undefined) { + if (certificateIds.length > 0) { + await validateCertificatesProjectOwnership(certificateIds, pkiSync.projectId); + } + + await certificateSyncDAL.removeAllCertificatesFromSync(id); + if (certificateIds.length > 0) { + await certificateSyncDAL.addCertificates( + id, + certificateIds.map((certId) => ({ certificateId: certId })) + ); + } + } + const updatedPkiSync = await pkiSyncDAL.updateById(id, { name, description, @@ -266,7 +340,7 @@ export const pkiSyncServiceFactory = ({ }; const listPkiSyncsByProjectId = async ( - { projectId }: TListPkiSyncsByProjectId, + { projectId, certificateId }: TListPkiSyncsByProjectId, actor: OrgServiceActor ): Promise => { const { permission } = await permissionService.getProjectPermission({ @@ -282,6 +356,29 @@ export const pkiSyncServiceFactory = ({ const pkiSyncsWithSubscribers = await pkiSyncDAL.findByProjectIdWithSubscribers(projectId); + if (certificateId) { + const syncsWithCertificateInfo = await Promise.all( + pkiSyncsWithSubscribers.map(async (sync) => { + try { + const certificateSyncs = await certificateSyncDAL.findByPkiSyncId(sync.id); + const hasCertificate = certificateSyncs.some((certSync) => certSync.certificateId === certificateId); + + return { + ...sync, + hasCertificate + }; + } catch (error) { + return { + ...sync, + hasCertificate: false + }; + } + }) + ); + + return syncsWithCertificateInfo as TPkiSync[]; + } + return pkiSyncsWithSubscribers as TPkiSync[]; }; @@ -433,6 +530,145 @@ export const pkiSyncServiceFactory = ({ return listPkiSyncOptions(); }; + const addCertificatesToPkiSync = async ( + { pkiSyncId, certificateIds }: Omit, + actor: OrgServiceActor + ): Promise<{ + addedCertificates: TCertificateSyncs[]; + pkiSyncInfo: { projectId: string; destination: string; name: string }; + }> => { + const pkiSync = await pkiSyncDAL.findById(pkiSyncId); + if (!pkiSync) throw new NotFoundError({ message: "PKI sync not found" }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.CertificateManager, + projectId: pkiSync.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionPkiSyncActions.Edit, ProjectPermissionSub.PkiSyncs); + + await validateCertificatesProjectOwnership(certificateIds, pkiSync.projectId); + + const addedCertificates = await certificateSyncDAL.addCertificates( + pkiSyncId, + certificateIds.map((id) => ({ certificateId: id })) + ); + + if (pkiSync.isAutoSyncEnabled) { + await pkiSyncQueue.queuePkiSyncSyncCertificatesById({ syncId: pkiSyncId }); + } + + return { + addedCertificates, + pkiSyncInfo: { + projectId: pkiSync.projectId, + destination: pkiSync.destination, + name: pkiSync.name + } + }; + }; + + const removeCertificatesFromPkiSync = async ( + { pkiSyncId, certificateIds }: Omit, + actor: OrgServiceActor + ): Promise<{ removedCount: number; pkiSyncInfo: { projectId: string; destination: string; name: string } }> => { + const pkiSync = await pkiSyncDAL.findById(pkiSyncId); + if (!pkiSync) throw new NotFoundError({ message: "PKI sync not found" }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.CertificateManager, + projectId: pkiSync.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionPkiSyncActions.Edit, ProjectPermissionSub.PkiSyncs); + + const removedCount = await certificateSyncDAL.removeCertificates(pkiSyncId, certificateIds); + + if (pkiSync.isAutoSyncEnabled) { + await pkiSyncQueue.queuePkiSyncSyncCertificatesById({ syncId: pkiSyncId }); + } + + return { + removedCount, + pkiSyncInfo: { + projectId: pkiSync.projectId, + destination: pkiSync.destination, + name: pkiSync.name + } + }; + }; + + const listPkiSyncCertificates = async ( + { pkiSyncId, offset = 0, limit = 20 }: Omit, + actor: OrgServiceActor + ): Promise<{ + certificates: TPkiSyncCertificate[]; + totalCount: number; + pkiSyncInfo: { projectId: string; destination: string; name: string }; + }> => { + const pkiSync = await pkiSyncDAL.findById(pkiSyncId); + if (!pkiSync) throw new NotFoundError({ message: "PKI sync not found" }); + + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.CertificateManager, + projectId: pkiSync.projectId + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionPkiSyncActions.Read, ProjectPermissionSub.PkiSyncs); + + const result = await certificateSyncDAL.findWithDetails({ + pkiSyncId, + offset, + limit + }); + const { certificateDetails, totalCount } = result; + + const certificates = certificateDetails.map((detail) => ({ + id: detail.id, + pkiSyncId: detail.pkiSyncId, + certificateId: detail.certificateId, + syncStatus: (detail.syncStatus as CertificateSyncStatus) || CertificateSyncStatus.Pending, + lastSyncMessage: detail.lastSyncMessage || undefined, + lastSyncedAt: detail.lastSyncedAt || undefined, + createdAt: detail.createdAt, + updatedAt: detail.updatedAt, + certificateSerialNumber: detail.certificateSerialNumber || undefined, + certificateCommonName: detail.certificateCommonName || undefined, + certificateAltNames: detail.certificateAltNames || undefined, + certificateStatus: detail.certificateStatus || undefined, + certificateNotBefore: detail.certificateNotBefore || undefined, + certificateNotAfter: detail.certificateNotAfter || undefined, + certificateRenewBeforeDays: !detail.certificateRenewedByCertificateId + ? detail.certificateRenewBeforeDays || undefined + : undefined, + certificateRenewalError: detail.certificateRenewalError || undefined, + pkiSyncName: detail.pkiSyncName || undefined, + pkiSyncDestination: detail.pkiSyncDestination || undefined + })); + + return { + certificates, + totalCount, + pkiSyncInfo: { + projectId: pkiSync.projectId, + destination: pkiSync.destination, + name: pkiSync.name + } + }; + }; + return { createPkiSync, updatePkiSync, @@ -442,6 +678,9 @@ export const pkiSyncServiceFactory = ({ triggerPkiSyncSyncCertificatesById, triggerPkiSyncImportCertificatesById, triggerPkiSyncRemoveCertificatesById, - getPkiSyncOptions + getPkiSyncOptions, + addCertificatesToPkiSync, + removeCertificatesFromPkiSync, + listPkiSyncCertificates }; }; diff --git a/backend/src/services/pki-sync/pki-sync-types.ts b/backend/src/services/pki-sync/pki-sync-types.ts index bf750beee..f42f64a1b 100644 --- a/backend/src/services/pki-sync/pki-sync-types.ts +++ b/backend/src/services/pki-sync/pki-sync-types.ts @@ -2,6 +2,7 @@ import { Job } from "bullmq"; import { AuditLogInfo } from "@app/ee/services/audit-log/audit-log-types"; import { QueueJobs } from "@app/queue"; +import { CertificateSyncStatus } from "@app/services/certificate-sync/certificate-sync-enums"; import { ResourceMetadataDTO } from "@app/services/resource-metadata/resource-metadata-schema"; import { TPkiSyncDALFactory } from "./pki-sync-dal"; @@ -70,7 +71,10 @@ export type TPkiSyncListItem = TPkiSync & { appConnectionApp: string; }; -export type TCertificateMap = Record; +export type TCertificateMap = Record< + string, + { cert: string; privateKey: string; certificateChain?: string; alternativeNames?: string[]; certificateId?: string } +>; export type TCreatePkiSyncDTO = { name: string; @@ -79,9 +83,10 @@ export type TCreatePkiSyncDTO = { isAutoSyncEnabled?: boolean; destinationConfig: Record; syncOptions?: Record; - subscriberId?: string; + subscriberId?: string | null; connectionId: string; projectId: string; + certificateIds?: string[]; auditLogInfo: AuditLogInfo; resourceMetadata?: ResourceMetadataDTO; }; @@ -94,8 +99,9 @@ export type TUpdatePkiSyncDTO = { isAutoSyncEnabled?: boolean; destinationConfig?: Record; syncOptions?: Record; - subscriberId?: string; + subscriberId?: string | null; connectionId?: string; + certificateIds?: string[]; auditLogInfo: AuditLogInfo; resourceMetadata?: ResourceMetadataDTO; }; @@ -108,6 +114,7 @@ export type TDeletePkiSyncDTO = { export type TListPkiSyncsByProjectId = { projectId: string; + certificateId?: string; }; export type TFindPkiSyncByIdDTO = { @@ -133,6 +140,48 @@ export type TTriggerPkiSyncRemoveCertificatesByIdDTO = { auditLogInfo: AuditLogInfo; }; +export type TAddCertificatesToPkiSyncDTO = { + pkiSyncId: string; + certificateIds: string[]; + projectId?: string; + auditLogInfo: AuditLogInfo; +}; + +export type TRemoveCertificatesFromPkiSyncDTO = { + pkiSyncId: string; + certificateIds: string[]; + projectId?: string; + auditLogInfo: AuditLogInfo; +}; + +export type TListPkiSyncCertificatesDTO = { + pkiSyncId: string; + projectId?: string; + offset?: number; + limit?: number; +}; + +export type TPkiSyncCertificate = { + id: string; + pkiSyncId: string; + certificateId: string; + syncStatus: CertificateSyncStatus; + lastSyncMessage?: string; + lastSyncedAt?: Date; + createdAt: Date; + updatedAt: Date; + certificateSerialNumber?: string; + certificateCommonName?: string; + certificateAltNames?: string; + certificateStatus?: string; + certificateNotBefore?: Date; + certificateNotAfter?: Date; + certificateRenewBeforeDays?: number; + certificateRenewalError?: string; + pkiSyncName?: string; + pkiSyncDestination?: string; +}; + export type TPkiSyncRaw = NonNullable>>; export type TQueuePkiSyncSyncCertificatesByIdDTO = { diff --git a/backend/src/services/pki-sync/pki-sync-utils.ts b/backend/src/services/pki-sync/pki-sync-utils.ts index a81864a17..d216ddd5a 100644 --- a/backend/src/services/pki-sync/pki-sync-utils.ts +++ b/backend/src/services/pki-sync/pki-sync-utils.ts @@ -1,5 +1,8 @@ +import { Knex } from "knex"; + import { logger } from "@app/lib/logger"; +import { TCertificateSyncDALFactory } from "../certificate-sync/certificate-sync-dal"; import { TPkiSyncDALFactory } from "./pki-sync-dal"; import { TPkiSyncQueueFactory } from "./pki-sync-queue"; @@ -25,3 +28,78 @@ export const triggerAutoSyncForSubscriber = async ( logger.error(error, `Failed to trigger auto sync for subscriber ${subscriberId}:`); } }; + +export const triggerAutoSyncForCertificate = async ( + certificateId: string, + dependencies: { + certificateSyncDAL: Pick; + pkiSyncDAL: Pick; + pkiSyncQueue: Pick; + } +) => { + try { + const pkiSyncIds = await dependencies.certificateSyncDAL.findPkiSyncIdsByCertificateId(certificateId); + + if (pkiSyncIds.length === 0) { + return; + } + + const allPkiSyncs = await dependencies.pkiSyncDAL.find({ + isAutoSyncEnabled: true, + $in: { + id: pkiSyncIds + } + }); + + const syncPromises = allPkiSyncs.map((pkiSync) => + dependencies.pkiSyncQueue.queuePkiSyncSyncCertificatesById({ syncId: pkiSync.id }) + ); + await Promise.all(syncPromises); + } catch (error) { + logger.error(error, `Failed to trigger auto sync for certificate ${certificateId}:`); + } +}; + +export const addRenewedCertificateToSyncs = async ( + oldCertificateId: string, + newCertificateId: string, + dependencies: { + certificateSyncDAL: Pick< + TCertificateSyncDALFactory, + "findPkiSyncIdsByCertificateId" | "addCertificates" | "findByPkiSyncAndCertificate" + >; + }, + tx?: Knex +) => { + try { + const pkiSyncIds = await dependencies.certificateSyncDAL.findPkiSyncIdsByCertificateId(oldCertificateId); + + if (pkiSyncIds.length === 0) { + return; + } + + const addPromises = pkiSyncIds.map(async (pkiSyncId) => { + const oldCertificateRecord = await dependencies.certificateSyncDAL.findByPkiSyncAndCertificate( + pkiSyncId, + oldCertificateId + ); + + await dependencies.certificateSyncDAL.addCertificates( + pkiSyncId, + [ + { + certificateId: newCertificateId, + externalIdentifier: oldCertificateRecord?.externalIdentifier || undefined + } + ], + tx + ); + }); + await Promise.all(addPromises); + + logger.info(`Successfully added renewed certificate ${newCertificateId} to PKI sync(s)`); + } catch (error) { + logger.error(error, `Failed to add renewed certificate ${newCertificateId} to syncs:`); + throw error; + } +}; diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index fb2eb70af..25052ba9a 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -156,7 +156,14 @@ type TProjectServiceFactoryDep = { >; pkiSubscriberDAL: Pick; certificateAuthorityDAL: Pick; - certificateDAL: Pick; + certificateDAL: Pick< + TCertificateDALFactory, + | "find" + | "countCertificatesInProject" + | "findWithPrivateKeyInfo" + | "findActiveCertificatesForSync" + | "countActiveCertificatesForSync" + >; certificateTemplateDAL: Pick; pkiAlertDAL: Pick; pkiCollectionDAL: Pick; @@ -929,6 +936,7 @@ export const projectServiceFactory = ({ offset = 0, friendlyName, commonName, + forPkiSync = false, actorId, actorOrgId, actorAuthMethod, @@ -952,20 +960,35 @@ export const projectServiceFactory = ({ ProjectPermissionSub.Certificates ); - const certificates = await certificateDAL.findWithPrivateKeyInfo( - { - projectId, - ...(friendlyName && { friendlyName }), - ...(commonName && { commonName }) - }, - { offset, limit, sort: [["notAfter", "desc"]] } - ); + const certificates = forPkiSync + ? await certificateDAL.findActiveCertificatesForSync( + { + projectId, + ...(friendlyName && { friendlyName }), + ...(commonName && { commonName }) + }, + { offset, limit } + ) + : await certificateDAL.findWithPrivateKeyInfo( + { + projectId, + ...(friendlyName && { friendlyName }), + ...(commonName && { commonName }) + }, + { offset, limit, sort: [["notAfter", "desc"]] } + ); - const count = await certificateDAL.countCertificatesInProject({ - projectId, - friendlyName, - commonName - }); + const count = forPkiSync + ? await certificateDAL.countActiveCertificatesForSync({ + projectId, + friendlyName, + commonName + }) + : await certificateDAL.countCertificatesInProject({ + projectId, + friendlyName, + commonName + }); return { certificates, diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index 18ae74350..2b75b1bc7 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -142,6 +142,7 @@ export type TListProjectCertsDTO = { limit: number; friendlyName?: string; commonName?: string; + forPkiSync?: boolean; } & Omit; export type TListProjectAlertsDTO = TProjectPermission; diff --git a/backend/src/services/secret-sync/chef/chef-sync-constants.ts b/backend/src/services/secret-sync/chef/chef-sync-constants.ts new file mode 100644 index 000000000..567b25bbe --- /dev/null +++ b/backend/src/services/secret-sync/chef/chef-sync-constants.ts @@ -0,0 +1,10 @@ +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { TSecretSyncListItem } from "@app/services/secret-sync/secret-sync-types"; + +export const CHEF_SYNC_LIST_OPTION: TSecretSyncListItem = { + name: "Chef", + destination: SecretSync.Chef, + connection: AppConnection.Chef, + canImportSecrets: true +}; diff --git a/backend/src/services/secret-sync/chef/chef-sync-fns.ts b/backend/src/services/secret-sync/chef/chef-sync-fns.ts new file mode 100644 index 000000000..7f32b398a --- /dev/null +++ b/backend/src/services/secret-sync/chef/chef-sync-fns.ts @@ -0,0 +1,151 @@ +import { getChefDataBagItem, updateChefDataBagItem } from "@app/services/app-connection/chef"; +import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; +import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; + +import { + ChefSecret, + TChefDataBagItemContent, + TChefSecret, + TChefSecrets, + TChefSyncWithCredentials, + TGetChefSecrets +} from "./chef-sync-types"; + +const getChefSecretsRaw = async ({ + serverUrl, + userName, + privateKey, + orgName, + dataBagName, + dataBagItemName +}: TGetChefSecrets): Promise => { + const dataBagItem = await getChefDataBagItem({ + serverUrl, + userName, + privateKey, + orgName, + dataBagName, + dataBagItemName + }); + + // Ensure the data bag item has an id field + if (!dataBagItem.id) { + dataBagItem.id = dataBagItemName; + } + + return dataBagItem; +}; + +const getChefSecrets = async (secretSync: TChefSyncWithCredentials): Promise => { + const { + connection, + destinationConfig: { dataBagName, dataBagItemName } + } = secretSync; + + const { serverUrl, userName, privateKey, orgName } = connection.credentials; + + const dataBagItem = await getChefSecretsRaw({ + serverUrl, + orgName, + userName, + privateKey, + dataBagName, + dataBagItemName + }); + + const { id, ...existingSecrets } = dataBagItem; + + // Convert data bag item to key-value pairs + const secrets: ChefSecret[] = []; + Object.entries(existingSecrets).forEach(([key, value]) => { + if (key !== "id" && value !== null && value !== undefined) { + secrets.push({ key, value: String(value) }); + } + }); + + return { id, secrets }; +}; + +const updateChefSecrets = async ( + secretSync: TChefSyncWithCredentials, + id: string, + secrets: Record +) => { + const { + connection, + destinationConfig: { dataBagName, dataBagItemName } + } = secretSync; + + const { serverUrl, userName, privateKey, orgName } = connection.credentials; + + // Chef data bag items must have an 'id' field + const dataBagItemContent: TChefDataBagItemContent = { + id, + ...secrets + }; + + await updateChefDataBagItem({ + serverUrl, + orgName, + userName, + privateKey, + dataBagName, + dataBagItemName, + data: dataBagItemContent + }); +}; + +export const ChefSyncFns = { + async syncSecrets(secretSync: TChefSyncWithCredentials, secretMap: TSecretMap) { + const { + environment, + syncOptions: { disableSecretDeletion, keySchema } + } = secretSync; + + const { id, secrets } = await getChefSecrets(secretSync); + + // Create a map of the existing secrets + const updatedSecretsMap = new Map(secrets.map((secret) => [secret.key, secret.value])); + + // Add/update new secrets + for (const [key, { value }] of Object.entries(secretMap)) { + updatedSecretsMap.set(key, value); + } + + // Delete secrets if not disabled + if (!disableSecretDeletion) { + secrets.forEach((secret) => { + if (!matchesSchema(secret.key, environment?.slug || "", keySchema)) return; + + if (!secretMap[secret.key]) { + updatedSecretsMap.delete(secret.key); + } + }); + } + + // Convert map to object for Chef API + const updatedSecrets = Object.fromEntries(updatedSecretsMap.entries()); + + await updateChefSecrets(secretSync, id, updatedSecrets); + }, + + async getSecrets(secretSync: TChefSyncWithCredentials): Promise { + const { secrets } = await getChefSecrets(secretSync); + + return Object.fromEntries(secrets.map((secret) => [secret.key, { value: secret.value }])); + }, + + async removeSecrets(secretSync: TChefSyncWithCredentials, secretMap: TSecretMap) { + const { id, secrets: existingSecrets } = await getChefSecrets(secretSync); + + const newSecrets = existingSecrets.filter((secret) => !Object.hasOwn(secretMap, secret.key)); + + if (newSecrets.length === existingSecrets.length) { + return; + } + + const updatedSecrets = Object.fromEntries(newSecrets.map((secret) => [secret.key, secret.value])); + + await updateChefSecrets(secretSync, id, updatedSecrets); + } +}; diff --git a/backend/src/services/secret-sync/chef/chef-sync-schemas.ts b/backend/src/services/secret-sync/chef/chef-sync-schemas.ts new file mode 100644 index 000000000..c2e09011e --- /dev/null +++ b/backend/src/services/secret-sync/chef/chef-sync-schemas.ts @@ -0,0 +1,46 @@ +import { z } from "zod"; + +import { SecretSyncs } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; +import { + BaseSecretSyncSchema, + GenericCreateSecretSyncFieldsSchema, + GenericUpdateSecretSyncFieldsSchema +} from "@app/services/secret-sync/secret-sync-schemas"; +import { TSyncOptionsConfig } from "@app/services/secret-sync/secret-sync-types"; + +const ChefSyncDestinationConfigSchema = z.object({ + dataBagName: z + .string() + .min(1, "Data Bag Name is required") + .max(256, "Data Bag Name cannot exceed 256 characters") + .describe(SecretSyncs.DESTINATION_CONFIG.CHEF.dataBagName), + dataBagItemName: z + .string() + .min(1, "Data Bag Item Name is required") + .max(256, "Data Bag Item Name cannot exceed 256 characters") + .describe(SecretSyncs.DESTINATION_CONFIG.CHEF.dataBagItemName) +}); + +const ChefSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; + +export const ChefSyncSchema = BaseSecretSyncSchema(SecretSync.Chef, ChefSyncOptionsConfig).extend({ + destination: z.literal(SecretSync.Chef), + destinationConfig: ChefSyncDestinationConfigSchema +}); + +export const CreateChefSyncSchema = GenericCreateSecretSyncFieldsSchema(SecretSync.Chef, ChefSyncOptionsConfig).extend({ + destinationConfig: ChefSyncDestinationConfigSchema +}); + +export const UpdateChefSyncSchema = GenericUpdateSecretSyncFieldsSchema(SecretSync.Chef, ChefSyncOptionsConfig).extend({ + destinationConfig: ChefSyncDestinationConfigSchema.optional() +}); + +export const ChefSyncListItemSchema = z.object({ + name: z.literal("Chef"), + connection: z.literal(AppConnection.Chef), + destination: z.literal(SecretSync.Chef), + canImportSecrets: z.literal(true) +}); diff --git a/backend/src/services/secret-sync/chef/chef-sync-types.ts b/backend/src/services/secret-sync/chef/chef-sync-types.ts new file mode 100644 index 000000000..464e0372d --- /dev/null +++ b/backend/src/services/secret-sync/chef/chef-sync-types.ts @@ -0,0 +1,41 @@ +import z from "zod"; + +import { TChefConnection } from "@app/services/app-connection/chef"; + +import { ChefSyncListItemSchema, ChefSyncSchema, CreateChefSyncSchema } from "./chef-sync-schemas"; + +export type TChefSyncListItem = z.infer; + +export type TChefSync = z.infer; + +export type TChefSyncInput = z.infer; + +export type TChefSyncWithCredentials = TChefSync & { + connection: TChefConnection; +}; + +export type TGetChefSecrets = { + serverUrl?: string; + userName: string; + privateKey: string; + orgName: string; + dataBagName: string; + dataBagItemName: string; +}; + +export type TChefSecret = string | number | boolean | null; + +export type TChefDataBagItemContent = { + id: string; + [key: string]: TChefSecret; +}; + +export type TChefSecrets = { + id: string; + secrets: ChefSecret[]; +}; + +export type ChefSecret = { + key: string; + value: string; +}; diff --git a/backend/src/services/secret-sync/chef/index.ts b/backend/src/services/secret-sync/chef/index.ts new file mode 100644 index 000000000..c8599c867 --- /dev/null +++ b/backend/src/services/secret-sync/chef/index.ts @@ -0,0 +1,4 @@ +export * from "./chef-sync-constants"; +export * from "./chef-sync-fns"; +export * from "./chef-sync-schemas"; +export * from "./chef-sync-types"; diff --git a/backend/src/services/secret-sync/secret-sync-enums.ts b/backend/src/services/secret-sync/secret-sync-enums.ts index f04247684..835a314ca 100644 --- a/backend/src/services/secret-sync/secret-sync-enums.ts +++ b/backend/src/services/secret-sync/secret-sync-enums.ts @@ -30,7 +30,8 @@ export enum SecretSync { Netlify = "netlify", Northflank = "northflank", Bitbucket = "bitbucket", - LaravelForge = "laravel-forge" + LaravelForge = "laravel-forge", + Chef = "chef" } export enum SecretSyncInitialSyncBehavior { diff --git a/backend/src/services/secret-sync/secret-sync-fns.ts b/backend/src/services/secret-sync/secret-sync-fns.ts index 3068e803b..77845535f 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -34,6 +34,7 @@ import { BITBUCKET_SYNC_LIST_OPTION, BitbucketSyncFns } from "./bitbucket"; import { CAMUNDA_SYNC_LIST_OPTION, camundaSyncFactory } from "./camunda"; import { CHECKLY_SYNC_LIST_OPTION } from "./checkly/checkly-sync-constants"; import { ChecklySyncFns } from "./checkly/checkly-sync-fns"; +import { CHEF_SYNC_LIST_OPTION, ChefSyncFns } from "./chef"; import { CLOUDFLARE_PAGES_SYNC_LIST_OPTION } from "./cloudflare-pages/cloudflare-pages-constants"; import { CloudflarePagesSyncFns } from "./cloudflare-pages/cloudflare-pages-fns"; import { CLOUDFLARE_WORKERS_SYNC_LIST_OPTION, CloudflareWorkersSyncFns } from "./cloudflare-workers"; @@ -49,8 +50,7 @@ import { HC_VAULT_SYNC_LIST_OPTION, HCVaultSyncFns } from "./hc-vault"; import { HEROKU_SYNC_LIST_OPTION, HerokuSyncFns } from "./heroku"; import { HUMANITEC_SYNC_LIST_OPTION } from "./humanitec"; import { HumanitecSyncFns } from "./humanitec/humanitec-sync-fns"; -import { LARAVEL_FORGE_SYNC_LIST_OPTION } from "./laravel-forge"; -import { LaravelForgeSyncFns } from "./laravel-forge/laravel-forge-sync-fns"; +import { LARAVEL_FORGE_SYNC_LIST_OPTION, LaravelForgeSyncFns } from "./laravel-forge"; import { NETLIFY_SYNC_LIST_OPTION, NetlifySyncFns } from "./netlify"; import { NORTHFLANK_SYNC_LIST_OPTION, NorthflankSyncFns } from "./northflank"; import { RAILWAY_SYNC_LIST_OPTION } from "./railway/railway-sync-constants"; @@ -96,7 +96,8 @@ const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.Netlify]: NETLIFY_SYNC_LIST_OPTION, [SecretSync.Northflank]: NORTHFLANK_SYNC_LIST_OPTION, [SecretSync.Bitbucket]: BITBUCKET_SYNC_LIST_OPTION, - [SecretSync.LaravelForge]: LARAVEL_FORGE_SYNC_LIST_OPTION + [SecretSync.LaravelForge]: LARAVEL_FORGE_SYNC_LIST_OPTION, + [SecretSync.Chef]: CHEF_SYNC_LIST_OPTION }; export const listSecretSyncOptions = () => { @@ -286,6 +287,8 @@ export const SecretSyncFns = { return BitbucketSyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.LaravelForge: return LaravelForgeSyncFns.syncSecrets(secretSync, schemaSecretMap); + case SecretSync.Chef: + return ChefSyncFns.syncSecrets(secretSync, schemaSecretMap); default: throw new Error( `Unhandled sync destination for sync secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -408,6 +411,9 @@ export const SecretSyncFns = { case SecretSync.LaravelForge: secretMap = await LaravelForgeSyncFns.getSecrets(secretSync); break; + case SecretSync.Chef: + secretMap = await ChefSyncFns.getSecrets(secretSync); + break; default: throw new Error( `Unhandled sync destination for get secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -505,6 +511,8 @@ export const SecretSyncFns = { return BitbucketSyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.LaravelForge: return LaravelForgeSyncFns.removeSecrets(secretSync, schemaSecretMap); + case SecretSync.Chef: + return ChefSyncFns.removeSecrets(secretSync, schemaSecretMap); default: throw new Error( `Unhandled sync destination for remove secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` diff --git a/backend/src/services/secret-sync/secret-sync-maps.ts b/backend/src/services/secret-sync/secret-sync-maps.ts index 8110cced9..86cb189c3 100644 --- a/backend/src/services/secret-sync/secret-sync-maps.ts +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -34,7 +34,8 @@ export const SECRET_SYNC_NAME_MAP: Record = { [SecretSync.Netlify]: "Netlify", [SecretSync.Northflank]: "Northflank", [SecretSync.Bitbucket]: "Bitbucket", - [SecretSync.LaravelForge]: "Laravel Forge" + [SecretSync.LaravelForge]: "Laravel Forge", + [SecretSync.Chef]: "Chef" }; export const SECRET_SYNC_CONNECTION_MAP: Record = { @@ -69,7 +70,8 @@ export const SECRET_SYNC_CONNECTION_MAP: Record = { [SecretSync.Netlify]: AppConnection.Netlify, [SecretSync.Northflank]: AppConnection.Northflank, [SecretSync.Bitbucket]: AppConnection.Bitbucket, - [SecretSync.LaravelForge]: AppConnection.LaravelForge + [SecretSync.LaravelForge]: AppConnection.LaravelForge, + [SecretSync.Chef]: AppConnection.Chef }; export const SECRET_SYNC_PLAN_MAP: Record = { @@ -104,7 +106,8 @@ export const SECRET_SYNC_PLAN_MAP: Record = { [SecretSync.Netlify]: SecretSyncPlanType.Regular, [SecretSync.Northflank]: SecretSyncPlanType.Regular, [SecretSync.Bitbucket]: SecretSyncPlanType.Regular, - [SecretSync.LaravelForge]: SecretSyncPlanType.Regular + [SecretSync.LaravelForge]: SecretSyncPlanType.Regular, + [SecretSync.Chef]: SecretSyncPlanType.Regular }; export const SECRET_SYNC_SKIP_FIELDS_MAP: Record = { @@ -148,7 +151,8 @@ export const SECRET_SYNC_SKIP_FIELDS_MAP: Record = { [SecretSync.Netlify]: ["accountName", "siteName"], [SecretSync.Northflank]: [], [SecretSync.Bitbucket]: [], - [SecretSync.LaravelForge]: [] + [SecretSync.LaravelForge]: [], + [SecretSync.Chef]: [] }; const defaultDuplicateCheck: DestinationDuplicateCheckFn = () => true; @@ -209,5 +213,6 @@ export const DESTINATION_DUPLICATE_CHECK_MAP: Record + Check out the configuration docs for [Chef + Connections](/integrations/app-connections/chef) to learn how to obtain the + required credentials. + diff --git a/docs/api-reference/endpoints/app-connections/chef/delete.mdx b/docs/api-reference/endpoints/app-connections/chef/delete.mdx new file mode 100644 index 000000000..43a67f01c --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/chef/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/chef/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/chef/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/chef/get-by-id.mdx new file mode 100644 index 000000000..8461cc553 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/chef/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/chef/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/chef/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/chef/get-by-name.mdx new file mode 100644 index 000000000..f1042abdb --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/chef/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/chef/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/chef/list.mdx b/docs/api-reference/endpoints/app-connections/chef/list.mdx new file mode 100644 index 000000000..dc18436a7 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/chef/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/chef" +--- diff --git a/docs/api-reference/endpoints/app-connections/chef/update.mdx b/docs/api-reference/endpoints/app-connections/chef/update.mdx new file mode 100644 index 000000000..780bea960 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/chef/update.mdx @@ -0,0 +1,10 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/chef/{connectionId}" +--- + + + Check out the configuration docs for [Chef + Connections](/integrations/app-connections/chef) to learn how to obtain the + required credentials. + diff --git a/docs/api-reference/endpoints/secret-syncs/chef/create.mdx b/docs/api-reference/endpoints/secret-syncs/chef/create.mdx new file mode 100644 index 000000000..61b816d7d --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/chef/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/secret-syncs/chef" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/chef/delete.mdx b/docs/api-reference/endpoints/secret-syncs/chef/delete.mdx new file mode 100644 index 000000000..a43d83be1 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/chef/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/secret-syncs/chef/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/chef/get-by-id.mdx b/docs/api-reference/endpoints/secret-syncs/chef/get-by-id.mdx new file mode 100644 index 000000000..2efeb51e2 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/chef/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/secret-syncs/chef/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/chef/get-by-name.mdx b/docs/api-reference/endpoints/secret-syncs/chef/get-by-name.mdx new file mode 100644 index 000000000..d6ac030d8 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/chef/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/secret-syncs/chef/sync-name/{syncName}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/chef/import-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/chef/import-secrets.mdx new file mode 100644 index 000000000..734c89a95 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/chef/import-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Import Secrets" +openapi: "POST /api/v1/secret-syncs/chef/{syncId}/import-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/chef/list.mdx b/docs/api-reference/endpoints/secret-syncs/chef/list.mdx new file mode 100644 index 000000000..e38b35e43 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/chef/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/secret-syncs/chef" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/chef/remove-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/chef/remove-secrets.mdx new file mode 100644 index 000000000..e44df1c3e --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/chef/remove-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Secrets" +openapi: "POST /api/v1/secret-syncs/chef/{syncId}/remove-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/chef/sync-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/chef/sync-secrets.mdx new file mode 100644 index 000000000..8f8eefa04 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/chef/sync-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Secrets" +openapi: "POST /api/v1/secret-syncs/chef/{syncId}/sync-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/chef/update.mdx b/docs/api-reference/endpoints/secret-syncs/chef/update.mdx new file mode 100644 index 000000000..d5c39484d --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/chef/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/secret-syncs/chef/{syncId}" +--- diff --git a/docs/docs.json b/docs/docs.json index 2792e11c9..6d8eb04cc 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -114,6 +114,7 @@ "integrations/app-connections/bitbucket", "integrations/app-connections/camunda", "integrations/app-connections/checkly", + "integrations/app-connections/chef", "integrations/app-connections/cloudflare", "integrations/app-connections/databricks", "integrations/app-connections/digital-ocean", @@ -540,6 +541,7 @@ "integrations/secret-syncs/bitbucket", "integrations/secret-syncs/camunda", "integrations/secret-syncs/checkly", + "integrations/secret-syncs/chef", "integrations/secret-syncs/cloudflare-pages", "integrations/secret-syncs/cloudflare-workers", "integrations/secret-syncs/databricks", @@ -1658,6 +1660,18 @@ "api-reference/endpoints/app-connections/checkly/delete" ] }, + { + "group": "Chef", + "pages": [ + "api-reference/endpoints/app-connections/chef/list", + "api-reference/endpoints/app-connections/chef/available", + "api-reference/endpoints/app-connections/chef/get-by-id", + "api-reference/endpoints/app-connections/chef/get-by-name", + "api-reference/endpoints/app-connections/chef/create", + "api-reference/endpoints/app-connections/chef/update", + "api-reference/endpoints/app-connections/chef/delete" + ] + }, { "group": "Cloudflare", "pages": [ @@ -2149,6 +2163,20 @@ "api-reference/endpoints/secret-syncs/checkly/remove-secrets" ] }, + { + "group": "Chef", + "pages": [ + "api-reference/endpoints/secret-syncs/chef/list", + "api-reference/endpoints/secret-syncs/chef/get-by-id", + "api-reference/endpoints/secret-syncs/chef/get-by-name", + "api-reference/endpoints/secret-syncs/chef/create", + "api-reference/endpoints/secret-syncs/chef/update", + "api-reference/endpoints/secret-syncs/chef/delete", + "api-reference/endpoints/secret-syncs/chef/sync-secrets", + "api-reference/endpoints/secret-syncs/chef/import-secrets", + "api-reference/endpoints/secret-syncs/chef/remove-secrets" + ] + }, { "group": "Cloudflare Pages", "pages": [ @@ -2304,6 +2332,7 @@ "api-reference/endpoints/secret-syncs/laravel-forge/update", "api-reference/endpoints/secret-syncs/laravel-forge/delete", "api-reference/endpoints/secret-syncs/laravel-forge/sync-secrets", + "api-reference/endpoints/secret-syncs/laravel-forge/import-secrets", "api-reference/endpoints/secret-syncs/laravel-forge/remove-secrets" ] }, diff --git a/docs/documentation/platform/pki/azure-adcs.mdx b/docs/documentation/platform/pki/azure-adcs.mdx index b0cea565e..23df3ed3b 100644 --- a/docs/documentation/platform/pki/azure-adcs.mdx +++ b/docs/documentation/platform/pki/azure-adcs.mdx @@ -31,7 +31,7 @@ This section walks you through the complete end-to-end process of setting up Azu Click **Create CA** and configure: - - **Type**: Choose **Azure AD Certificate Service** + - **Type**: Choose **Active Directory Certificate Services (AD CS)** - **Name**: Friendly name for this CA (e.g., "Production ADCS CA") - **App Connection**: Choose your ADCS connection from the dropdown diff --git a/docs/images/app-connections/chef/app-connection-form.png b/docs/images/app-connections/chef/app-connection-form.png new file mode 100644 index 000000000..9ba5dc8e8 Binary files /dev/null and b/docs/images/app-connections/chef/app-connection-form.png differ diff --git a/docs/images/app-connections/chef/app-connection-generated.png b/docs/images/app-connections/chef/app-connection-generated.png new file mode 100644 index 000000000..c8ab79540 Binary files /dev/null and b/docs/images/app-connections/chef/app-connection-generated.png differ diff --git a/docs/images/app-connections/chef/app-connection-option.png b/docs/images/app-connections/chef/app-connection-option.png new file mode 100644 index 000000000..2a3e46920 Binary files /dev/null and b/docs/images/app-connections/chef/app-connection-option.png differ diff --git a/docs/images/app-connections/chef/chef-connection-details.png b/docs/images/app-connections/chef/chef-connection-details.png new file mode 100644 index 000000000..9fcb7ee0d Binary files /dev/null and b/docs/images/app-connections/chef/chef-connection-details.png differ diff --git a/docs/images/app-connections/chef/chef-dashboard.png b/docs/images/app-connections/chef/chef-dashboard.png new file mode 100644 index 000000000..65195a8d0 Binary files /dev/null and b/docs/images/app-connections/chef/chef-dashboard.png differ diff --git a/docs/images/app-connections/chef/chef-folder.png b/docs/images/app-connections/chef/chef-folder.png new file mode 100644 index 000000000..29f698080 Binary files /dev/null and b/docs/images/app-connections/chef/chef-folder.png differ diff --git a/docs/images/app-connections/chef/download-starter-kit.png b/docs/images/app-connections/chef/download-starter-kit.png new file mode 100644 index 000000000..27f9582ad Binary files /dev/null and b/docs/images/app-connections/chef/download-starter-kit.png differ diff --git a/docs/images/app-connections/chef/extract-starter-kit.png b/docs/images/app-connections/chef/extract-starter-kit.png new file mode 100644 index 000000000..8743dae9b Binary files /dev/null and b/docs/images/app-connections/chef/extract-starter-kit.png differ diff --git a/docs/images/app-connections/chef/private-key-file.png b/docs/images/app-connections/chef/private-key-file.png new file mode 100644 index 000000000..052103d0e Binary files /dev/null and b/docs/images/app-connections/chef/private-key-file.png differ diff --git a/docs/images/app-connections/chef/starter-kit.png b/docs/images/app-connections/chef/starter-kit.png new file mode 100644 index 000000000..b9b9621e4 Binary files /dev/null and b/docs/images/app-connections/chef/starter-kit.png differ diff --git a/docs/images/secret-syncs/chef/select-option.png b/docs/images/secret-syncs/chef/select-option.png new file mode 100644 index 000000000..5fba1e415 Binary files /dev/null and b/docs/images/secret-syncs/chef/select-option.png differ diff --git a/docs/images/secret-syncs/chef/sync-created.png b/docs/images/secret-syncs/chef/sync-created.png new file mode 100644 index 000000000..c6383deb3 Binary files /dev/null and b/docs/images/secret-syncs/chef/sync-created.png differ diff --git a/docs/images/secret-syncs/chef/sync-destination.png b/docs/images/secret-syncs/chef/sync-destination.png new file mode 100644 index 000000000..073631956 Binary files /dev/null and b/docs/images/secret-syncs/chef/sync-destination.png differ diff --git a/docs/images/secret-syncs/chef/sync-details.png b/docs/images/secret-syncs/chef/sync-details.png new file mode 100644 index 000000000..3e8a92008 Binary files /dev/null and b/docs/images/secret-syncs/chef/sync-details.png differ diff --git a/docs/images/secret-syncs/chef/sync-options.png b/docs/images/secret-syncs/chef/sync-options.png new file mode 100644 index 000000000..75f507310 Binary files /dev/null and b/docs/images/secret-syncs/chef/sync-options.png differ diff --git a/docs/images/secret-syncs/chef/sync-review.png b/docs/images/secret-syncs/chef/sync-review.png new file mode 100644 index 000000000..de0f10f8a Binary files /dev/null and b/docs/images/secret-syncs/chef/sync-review.png differ diff --git a/docs/images/secret-syncs/chef/sync-source.png b/docs/images/secret-syncs/chef/sync-source.png new file mode 100644 index 000000000..877b104ef Binary files /dev/null and b/docs/images/secret-syncs/chef/sync-source.png differ diff --git a/docs/integrations/app-connections/chef.mdx b/docs/integrations/app-connections/chef.mdx new file mode 100644 index 000000000..60c49fb1f --- /dev/null +++ b/docs/integrations/app-connections/chef.mdx @@ -0,0 +1,142 @@ +--- +title: "Chef Connection" +description: "Learn how to configure a Chef Connection for Infisical." +--- + +Infisical supports the use of User Private Key to connect with Chef Server. + +Please access your **starter kit** to get all the required information to create a Chef Connection. + + + + If you download a new starter kit, your previous private key/user key will + no longer be valid. Please make sure to update all the places that use the + previous private key. + + + + ![Chef Server User Keys](/images/app-connections/chef/chef-dashboard.png) + + + ![Starter Kit](/images/app-connections/chef/starter-kit.png) + + + ![Download Starter + Kit](/images/app-connections/chef/download-starter-kit.png) + + + ![Extract Starter + Kit](/images/app-connections/chef/extract-starter-kit.png) + + + + + + + Open your starter kit's folder(or `chef-repo`) and navigate to the `.chef` + folder. + + Please make sure you have hidden files visible in your file explorer. + + ![.chef folder](/images/app-connections/chef/chef-folder.png) + + + In the `.chef` folder, you will find a `[your-username].pem` file. ![Private + Key File](/images/app-connections/chef/private-key-file.png) + + **Private Key:** Copy the content of the private key file. + + + + Open the `config.rb` file and copy the content of the file. + ![Config.rb File Content](/images/app-connections/chef/chef-connection-details.png) + + **User Name(1):** The user name of the chef user. + + **Server URL(2):** The server url of the chef server. + + **Organization Name(3):** The organization name of the chef server. + + + + +## Create a Chef Connection in Infisical via UI + + + + + + In your Infisical dashboard, navigate to the **App Connections** page in the desired project. + ![App Connections Tab](/images/app-connections/general/add-connection.png) + + + Click **+ Add Connection** and choose **Chef** Connection from the list of integrations. + ![Select Chef Connection](/images/app-connections/chef/app-connection-option.png) + + + Complete the form by providing: + - A descriptive name for the connection + - An optional description + - Server URL(optional): The URL of the Chef server to connect with (defaults to https://api.chef.io) + - Organization short name + - User name + - Private key: Your Chef user's private key (.pem file) + + ![Chef Connection Modal](/images/app-connections/chef/app-connection-form.png) + + + After submitting the form, your **Chef Connection** will be successfully created and ready to use with your Infisical project. + ![Chef Connection Created](/images/app-connections/chef/app-connection-generated.png) + + + + + + + To create a Chef Connection via API, send a request to the [Create Chef Connection](/api-reference/endpoints/app-connections/chef/create) endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/chef \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-chef-connection", + "method": "user-key", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", + "credentials": { + "orgName": "my-org", + "userName": "my-user", + "privateKey": "your-private-key" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "a1b2c3d4-5678-90ab-cdef-1234567890ab", + "name": "my-chef-connection", + "description": null, + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", + "version": 1, + "orgId": "abcdef12-3456-7890-abcd-ef1234567890", + "createdAt": "2025-10-13T10:15:00.000Z", + "updatedAt": "2025-10-13T10:15:00.000Z", + "isPlatformManagedCredentials": false, + "credentialsHash": "d41d8cd98f00b204e9800998ecf8427e", + "app": "chef", + "method": "user-key", + "credentials": { + "orgName": "my-org", + "userName": "my-user", + } + } + } + ``` + + + diff --git a/docs/integrations/secret-syncs/chef.mdx b/docs/integrations/secret-syncs/chef.mdx new file mode 100644 index 000000000..13f3b3474 --- /dev/null +++ b/docs/integrations/secret-syncs/chef.mdx @@ -0,0 +1,154 @@ +--- +title: "Chef Sync" +description: "Learn how to configure a Chef Sync for Infisical." +--- + +**Prerequisites:** + +- Create a [Chef Connection](/integrations/app-connections/chef) + + + + + + Navigate to **Project** > **Integrations** and select the **Secret Syncs** tab. Click on the **Add Sync** button. + + ![Secret Syncs Tab](/images/secret-syncs/general/secret-sync-tab.png) + + + ![Select Chef](/images/secret-syncs/chef/select-option.png) + + + Configure the **Source** from where secrets should be retrieved, then click **Next**. + + ![Configure Source](/images/secret-syncs/chef/sync-source.png) + + - **Environment**: The project environment to retrieve secrets from. + - **Secret Path**: The folder path to retrieve secrets from. + + + If you need to sync secrets from multiple folder locations, check out [secret imports](/documentation/platform/secret-reference#secret-imports). + + + + Configure the **Destination** to where secrets should be deployed, then click **Next**. + + ![Configure Destination](/images/secret-syncs/chef/sync-destination.png) + + - **Chef Connection**: The Chef Connection to authenticate with. + - **Data Bag**: The Data Bag to sync secrets to. + - **Data Bag Item**: The Data Bag Item to sync secrets to. + + + Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. + + ![Configure Options](/images/secret-syncs/chef/sync-options.png) + + - **Initial Sync Behavior**: Determines how Infisical should resolve the initial sync. + - **Overwrite Destination Secrets**: Removes any secrets at the destination endpoint not present in Infisical. + - **Import Secrets (Prioritize Infisical)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Infisical over Chef when keys conflict. + - **Import Secrets (Prioritize Chef)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Chef over Infisical when keys conflict. + - **Key Schema**: Template that determines how secret names are transformed when syncing, using `{{secretKey}}` as a placeholder for the original secret name and `{{environment}}` for the environment. + + We highly recommend using a Key Schema to ensure that Infisical only manages the specific keys you intend, keeping everything else untouched. + + - **Auto-Sync Enabled**: If enabled, secrets will automatically be synced from the source location when changes occur. Disable to enforce manual syncing only. + - **Disable Secret Deletion**: If enabled, Infisical will not remove secrets from the sync destination. Enable this option if you intend to manage some secrets manually outside of Infisical. + + + Configure the **Details** of your Chef Sync, then click **Next**. + + ![Configure Details](/images/secret-syncs/chef/sync-details.png) + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + + Review your Chef Sync configuration, then click **Create Sync**. + + ![Review Configuration](/images/secret-syncs/chef/sync-review.png) + + + If enabled, your Chef Sync will begin syncing your secrets to the destination endpoint. + + ![Sync Created](/images/secret-syncs/chef/sync-created.png) + + + + + + + To create a **Chef Sync**, make an API request to the [Create Chef Sync](/api-reference/endpoints/secret-syncs/chef/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/secret-syncs/chef \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-chef-sync", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "sync to chef site", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "dev", + "secretPath": "/", + "isEnabled": true, + "isAutoSyncEnabled": true, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination", + "disableSecretDeletion": false + }, + "destinationConfig": { + "dataBagName": "my-data-bag", + "dataBagItemName": "my-data-bag-item" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "secretSync": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-chef-sync", + "description": "sync to chef site", + "folderId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "createdAt": "2025-07-19T12:00:00Z", + "updatedAt": "2025-07-19T12:00:00Z", + "syncStatus": "succeeded", + "lastSyncJobId": "job-1234", + "lastSyncMessage": null, + "lastSyncedAt": "2025-07-19T12:00:00Z", + "syncOptions": { + "initialSyncBehavior": "overwrite-destination", + "disableSecretDeletion": false + }, + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "connection": { + "app": "chef", + "name": "my-chef-connection", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "environment": { + "slug": "dev", + "name": "Development", + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a" + }, + "folder": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "path": "/" + }, + "destination": "chef", + "destinationConfig": { + "dataBagName": "my-data-bag", + "dataBagItemName": "my-data-bag-item" + } + } + } + ``` + + + diff --git a/docs/snippets/AppConnectionsBrowser.jsx b/docs/snippets/AppConnectionsBrowser.jsx index 7761d4bfc..dfe574547 100644 --- a/docs/snippets/AppConnectionsBrowser.jsx +++ b/docs/snippets/AppConnectionsBrowser.jsx @@ -47,6 +47,7 @@ export const AppConnectionsBrowser = () => { {"name": "Auth0", "slug": "auth0", "path": "/integrations/app-connections/auth0", "description": "Learn how to connect your Auth0 to pull secrets from Infisical.", "category": "Identity & Auth"}, {"name": "Okta", "slug": "okta", "path": "/integrations/app-connections/okta", "description": "Learn how to connect your Okta to pull secrets from Infisical.", "category": "Identity & Auth"}, {"name": "Laravel Forge", "slug": "laravel-forge", "path": "/integrations/app-connections/laravel-forge", "description": "Learn how to connect your Laravel Forge to pull secrets from Infisical.", "category": "Hosting"}, + {"name": "Chef", "slug": "chef", "path": "/integrations/app-connections/chef", "description": "Learn how to connect your Chef to pull secrets from Infisical.", "category": "DevOps Tools"}, {"name": "Northflank", "slug": "northflank", "path": "/integrations/app-connections/northflank", "description": "Learn how to connect your Northflank projects to pull secrets from Infisical.", "category": "Hosting"} ].sort(function(a, b) { return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); diff --git a/docs/snippets/SecretSyncsBrowser.jsx b/docs/snippets/SecretSyncsBrowser.jsx index d71ef1a80..17ff9e97b 100644 --- a/docs/snippets/SecretSyncsBrowser.jsx +++ b/docs/snippets/SecretSyncsBrowser.jsx @@ -38,6 +38,7 @@ export const SecretSyncsBrowser = () => { {"name": "OCI Vault", "slug": "oci-vault", "path": "/integrations/secret-syncs/oci-vault", "description": "Learn how to sync secrets from Infisical to OCI Vault.", "category": "Cloud Providers"}, {"name": "Zabbix", "slug": "zabbix", "path": "/integrations/secret-syncs/zabbix", "description": "Learn how to sync secrets from Infisical to Zabbix.", "category": "Monitoring"}, {"name": "Laravel Forge", "slug": "laravel-forge", "path": "/integrations/secret-syncs/laravel-forge", "description": "Learn how to sync secrets from Infisical to Laravel Forge.", "category": "Hosting"}, + {"name": "Chef", "slug": "chef", "path": "/integrations/secret-syncs/chef", "description": "Learn how to sync secrets from Infisical to Chef.", "category": "DevOps Tools"}, {"name": "Northflank", "slug": "northflank", "path": "/integrations/secret-syncs/northflank", "description": "Learn how to sync secrets from Infisical to Northflank projects.", "category": "Hosting"} ].sort(function(a, b) { return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); diff --git a/frontend/public/images/integrations/Chef.png b/frontend/public/images/integrations/Chef.png new file mode 100644 index 000000000..8d8886c21 Binary files /dev/null and b/frontend/public/images/integrations/Chef.png differ diff --git a/frontend/src/components/pki-syncs/CertificateManagementModal.tsx b/frontend/src/components/pki-syncs/CertificateManagementModal.tsx new file mode 100644 index 000000000..f5fd72309 --- /dev/null +++ b/frontend/src/components/pki-syncs/CertificateManagementModal.tsx @@ -0,0 +1,444 @@ +import React, { useEffect, useState } from "react"; +import { faSearch, faX } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + Checkbox, + EmptyState, + Input, + Modal, + ModalContent, + Pagination, + Table, + TableContainer, + TBody, + Td, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { useProject } from "@app/context"; +import { + CertStatus, + useAddCertificatesToPkiSync, + useListPkiSyncCertificates, + useRemoveCertificatesFromPkiSync +} from "@app/hooks/api"; +import { TPkiSync } from "@app/hooks/api/pkiSyncs"; +import { useListWorkspaceCertificates } from "@app/hooks/api/projects"; + +type Props = { + isOpen: boolean; + onClose: () => void; + pkiSync?: TPkiSync; + onCertificatesUpdated?: () => void; + selectedCertificateIds?: string[]; + onCertificateSelectionChange?: (certificateIds: string[]) => void; + title?: string; + subtitle?: string; + saveButtonText?: string; +}; + +export const CertificateManagementModal = ({ + isOpen, + onClose, + pkiSync, + onCertificatesUpdated, + selectedCertificateIds, + onCertificateSelectionChange, + title = "Manage Certificate Sync", + subtitle = "Select which certificates should be synced.", + saveButtonText = "Save Changes" +}: Props) => { + const { currentProject } = useProject(); + const [currentPage, setCurrentPage] = useState(1); + const [searchTerm, setSearchTerm] = useState(""); + const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); + const pageSize = 10; + + const isCreateMode = !pkiSync; + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedSearchTerm(searchTerm); + setCurrentPage(1); + }, 300); + + return () => { + clearTimeout(handler); + }; + }, [searchTerm]); + + const { data } = useListWorkspaceCertificates({ + projectId: currentProject?.id || "", + offset: (currentPage - 1) * pageSize, + limit: pageSize, + commonName: debouncedSearchTerm || undefined, + friendlyName: debouncedSearchTerm || undefined, + forPkiSync: true + }); + + const allCertificates = data?.certificates || []; + const totalCount = data?.totalCount || 0; + + const { data: syncData } = useListPkiSyncCertificates(pkiSync?.id || ""); + const syncCertificates = syncData?.certificates || []; + const addCertificatesToSync = useAddCertificatesToPkiSync(); + const removeCertificatesFromSync = useRemoveCertificatesFromPkiSync(); + + const syncedCertificateIds = isCreateMode + ? selectedCertificateIds || [] + : syncCertificates.map((sc) => sc.certificateId); + + const totalPages = Math.ceil(totalCount / pageSize); + + const [selectedIds, setSelectedIds] = useState([]); + + React.useEffect(() => { + setSelectedIds(syncedCertificateIds); + }, [JSON.stringify(syncedCertificateIds)]); + + const handleToggleSelection = (certId: string) => { + setSelectedIds((prev) => + prev.includes(certId) ? prev.filter((id) => id !== certId) : [...prev, certId] + ); + }; + + const handleSelectAll = () => { + const currentPageIds = allCertificates.map((cert) => cert.id); + const allCurrentPageSelected = currentPageIds.every((id) => selectedIds.includes(id)); + + if (allCurrentPageSelected) { + setSelectedIds((prev) => prev.filter((id) => !currentPageIds.includes(id))); + } else { + setSelectedIds((prev) => [...new Set([...prev, ...currentPageIds])]); + } + }; + + const clearSearch = () => { + setSearchTerm(""); + setCurrentPage(1); + }; + + React.useEffect(() => { + if (isOpen) { + setCurrentPage(1); + setSearchTerm(""); + } + }, [isOpen]); + + const handleSaveCertificates = async () => { + try { + if (isCreateMode) { + if (onCertificateSelectionChange) { + onCertificateSelectionChange(selectedIds); + onClose(); + } + return; + } + + if (!pkiSync) return; + + const certificatesToAdd = selectedIds.filter((id) => !syncedCertificateIds.includes(id)); + const certificatesToRemove = syncedCertificateIds.filter((id) => !selectedIds.includes(id)); + + const invalidCertificates = certificatesToAdd + .map((id) => allCertificates.find((cert) => cert.id === id)) + .filter((cert) => { + if (!cert) return false; + const isExpired = new Date(cert.notAfter) < new Date(); + const isRevoked = cert.status === CertStatus.REVOKED; + return isExpired || isRevoked; + }); + + if (invalidCertificates.length > 0) { + const invalidNames = invalidCertificates.map((cert) => cert?.commonName).join(", "); + createNotification({ + text: `Cannot add expired or revoked certificates: ${invalidNames}`, + type: "error" + }); + return; + } + + const operations = []; + + if (certificatesToAdd.length > 0) { + operations.push( + addCertificatesToSync + .mutateAsync({ + pkiSyncId: pkiSync.id, + certificateIds: certificatesToAdd + }) + .then(() => ({ + type: "add", + count: certificatesToAdd.length, + success: true + })) + .catch((error) => ({ + type: "add", + count: certificatesToAdd.length, + success: false, + error + })) + ); + } + + if (certificatesToRemove.length > 0) { + operations.push( + removeCertificatesFromSync + .mutateAsync({ + pkiSyncId: pkiSync.id, + certificateIds: certificatesToRemove + }) + .then(() => ({ + type: "remove", + count: certificatesToRemove.length, + success: true + })) + .catch((error) => ({ + type: "remove", + count: certificatesToRemove.length, + success: false, + error + })) + ); + } + + if (operations.length === 0) { + createNotification({ + text: "No changes to save", + type: "info" + }); + onClose(); + return; + } + + const results = await Promise.all(operations); + const failures = results.filter((r) => !r.success); + const successes = results.filter((r) => r.success); + + if (failures.length === 0) { + const addCount = successes.find((r) => r.type === "add")?.count || 0; + const removeCount = successes.find((r) => r.type === "remove")?.count || 0; + + let message = "Certificate selection updated successfully"; + if (addCount > 0 && removeCount > 0) { + message = `Added ${addCount} and removed ${removeCount} certificate(s)`; + } else if (addCount > 0) { + message = `Added ${addCount} certificate(s)`; + } else if (removeCount > 0) { + message = `Removed ${removeCount} certificate(s)`; + } + + createNotification({ + text: message, + type: "success" + }); + + if (onCertificatesUpdated) { + onCertificatesUpdated(); + } + onClose(); + } else { + const partialSuccess = successes.length > 0; + console.error("Certificate sync operation failures:", failures); + + createNotification({ + text: partialSuccess + ? "Some certificate changes failed. Check console for details." + : "Failed to update certificate selection", + type: partialSuccess ? "warning" : "error" + }); + + if (partialSuccess && onCertificatesUpdated) { + onCertificatesUpdated(); + } + } + } catch (error) { + console.error("Unexpected error during certificate sync operation:", error); + createNotification({ + text: "An unexpected error occurred while updating certificates", + type: "error" + }); + } + }; + + const isLoading = addCertificatesToSync.isPending || removeCertificatesFromSync.isPending; + + return ( + !open && onClose()}> + +
+
+
+ { + setSearchTerm(e.target.value); + setCurrentPage(1); + }} + className="pl-9" + /> + + {searchTerm && ( + + )} +
+
+ + + + + + + + + + + + + + {allCertificates.map((cert) => { + const isExpired = new Date(cert.notAfter) < new Date(); + const isRevoked = cert.status === CertStatus.REVOKED; + const cannotBeAdded = isExpired || isRevoked; + const isAlreadySynced = syncedCertificateIds.includes(cert.id); + + let originalDisplayName = "—"; + if (cert.altNames && cert.altNames.trim()) { + originalDisplayName = cert.altNames.trim(); + } else if (cert.commonName && cert.commonName.trim()) { + originalDisplayName = cert.commonName.trim(); + } + + let displayName = originalDisplayName; + let isTruncated = false; + if (originalDisplayName.length > 34) { + displayName = `${originalDisplayName.substring(0, 34)}...`; + isTruncated = true; + } + + const truncatedSerial = + cert.serialNumber.length > 8 + ? `${cert.serialNumber.slice(0, 4)}...${cert.serialNumber.slice(-4)}` + : cert.serialNumber; + + return ( + { + if (!cannotBeAdded || isAlreadySynced) { + handleToggleSelection(cert.id); + } + }} + > + + + + + + + ); + })} + +
+ 0 && + allCertificates.every((cert) => selectedIds.includes(cert.id)) + } + onCheckedChange={handleSelectAll} + /> + SAN / CNSerial NumberIssued AtExpires At
e.stopPropagation()}> + { + if (!cannotBeAdded || isAlreadySynced) { + handleToggleSelection(cert.id); + } + }} + isDisabled={cannotBeAdded && !isAlreadySynced} + /> + + {isTruncated ? ( + +
{displayName}
+
+ ) : ( +
{displayName}
+ )} +
+
+ {truncatedSerial} +
+
+ + {new Date(cert.notBefore).toLocaleDateString()} + + + + {new Date(cert.notAfter).toLocaleDateString()} + +
+ {allCertificates.length === 0 && ( + + {searchTerm + ? "No certificates match your search criteria." + : "No certificates available for sync."} + + )} +
+ + {totalPages > 1 && ( +
+ setCurrentPage(page)} + onChangePerPage={() => {}} + /> +
+ )} +
+ +
+ + +
+
+
+ ); +}; diff --git a/frontend/src/components/pki-syncs/CreatePkiSyncModal.tsx b/frontend/src/components/pki-syncs/CreatePkiSyncModal.tsx index 5b5e81ba3..0169b2296 100644 --- a/frontend/src/components/pki-syncs/CreatePkiSyncModal.tsx +++ b/frontend/src/components/pki-syncs/CreatePkiSyncModal.tsx @@ -11,21 +11,24 @@ type Props = { isOpen: boolean; onOpenChange: (isOpen: boolean) => void; selectSync?: PkiSync | null; + initialData?: any; }; type ContentProps = { onComplete: (pkiSync: TPkiSync) => void; selectedSync: PkiSync | null; setSelectedSync: (selectedSync: PkiSync | null) => void; + initialData?: any; }; -const Content = ({ onComplete, setSelectedSync, selectedSync }: ContentProps) => { +const Content = ({ onComplete, setSelectedSync, selectedSync, initialData }: ContentProps) => { if (selectedSync) { return ( setSelectedSync(null)} destination={selectedSync} + initialData={initialData} /> ); } @@ -33,7 +36,12 @@ const Content = ({ onComplete, setSelectedSync, selectedSync }: ContentProps) => return ; }; -export const CreatePkiSyncModal = ({ onOpenChange, selectSync = null, ...props }: Props) => { +export const CreatePkiSyncModal = ({ + onOpenChange, + selectSync = null, + initialData, + ...props +}: Props) => { const [selectedSync, setSelectedSync] = useState(selectSync); useEffect(() => { @@ -69,6 +77,7 @@ export const CreatePkiSyncModal = ({ onOpenChange, selectSync = null, ...props } }} selectedSync={selectedSync} setSelectedSync={setSelectedSync} + initialData={initialData} /> diff --git a/frontend/src/components/pki-syncs/PkiSyncSelect.tsx b/frontend/src/components/pki-syncs/PkiSyncSelect.tsx index aedfebf47..251f88ee2 100644 --- a/frontend/src/components/pki-syncs/PkiSyncSelect.tsx +++ b/frontend/src/components/pki-syncs/PkiSyncSelect.tsx @@ -71,7 +71,7 @@ export const PkiSyncSelect = ({ onSelect }: Props) => { enterprise && !subscription.enterpriseCertificateSyncs ? handlePopUpOpen("upgradePlan", { isEnterpriseFeature: true, - text: "You can use every Certificate Sync if you switch to Infisical's Enterprise plan." + text: "All Certificate Syncs can be unlocked if you switch to Infisical Enterprise plan." }) : onSelect(destination) } @@ -152,7 +152,7 @@ export const PkiSyncSelect = ({ onSelect }: Props) => { isOpen={popUp.upgradePlan.isOpen} onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)} isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature} - text="You can use every Certificate Sync if you switch to Infisical's Enterprise plan." + text={popUp.upgradePlan.data?.text} /> ); diff --git a/frontend/src/components/pki-syncs/forms/CreatePkiSyncForm.tsx b/frontend/src/components/pki-syncs/forms/CreatePkiSyncForm.tsx index 085e57882..1dee6eaa7 100644 --- a/frontend/src/components/pki-syncs/forms/CreatePkiSyncForm.tsx +++ b/frontend/src/components/pki-syncs/forms/CreatePkiSyncForm.tsx @@ -13,27 +13,28 @@ import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs"; import { PkiSync, TPkiSync, useCreatePkiSync, usePkiSyncOption } from "@app/hooks/api/pkiSyncs"; import { PkiSyncFormSchema, TPkiSyncForm } from "./schemas/pki-sync-schema"; +import { PkiSyncCertificatesFields } from "./PkiSyncCertificatesFields"; import { PkiSyncDestinationFields } from "./PkiSyncDestinationFields"; import { PkiSyncDetailsFields } from "./PkiSyncDetailsFields"; import { PkiSyncOptionsFields } from "./PkiSyncOptionsFields"; import { PkiSyncReviewFields } from "./PkiSyncReviewFields"; -import { PkiSyncSourceFields } from "./PkiSyncSourceFields"; type Props = { onComplete: (pkiSync: TPkiSync) => void; destination: PkiSync; onCancel: () => void; + initialData?: any; }; const FORM_TABS: { name: string; key: string; fields: (keyof TPkiSyncForm)[] }[] = [ - { name: "Source", key: "source", fields: ["subscriberId"] }, { name: "Destination", key: "destination", fields: ["connection", "destinationConfig"] }, { name: "Sync Options", key: "options", fields: ["syncOptions"] }, { name: "Details", key: "details", fields: ["name", "description"] }, + { name: "Certificates", key: "certificates", fields: ["certificateIds"] }, { name: "Review", key: "review", fields: [] } ]; -export const CreatePkiSyncForm = ({ destination, onComplete, onCancel }: Props) => { +export const CreatePkiSyncForm = ({ destination, onComplete, onCancel, initialData }: Props) => { const createPkiSync = useCreatePkiSync(); const { currentProject } = useProject(); const { name: destinationName } = PKI_SYNC_MAP[destination]; @@ -49,26 +50,39 @@ export const CreatePkiSyncForm = ({ destination, onComplete, onCancel }: Props) defaultValues: { destination, isAutoSyncEnabled: false, + certificateIds: [], syncOptions: { canImportCertificates: false, canRemoveCertificates: false, + preserveArn: true, certificateNameSchema: syncOption?.defaultCertificateNameSchema - } + }, + ...initialData } as Partial, reValidateMode: "onChange" }); - const onSubmit = async ({ connection, destinationConfig, ...formData }: TPkiSyncForm) => { + const onSubmit = async ({ + connection, + destinationConfig, + certificateIds, + ...formData + }: TPkiSyncForm) => { try { const pkiSync = await createPkiSync.mutateAsync({ ...formData, connectionId: connection.id, projectId: currentProject.id, - destinationConfig + destinationConfig, + certificateIds: certificateIds || [] }); createNotification({ - text: `Successfully added ${destinationName} Certificate Sync`, + text: `Successfully created ${destinationName} Certificate Sync${ + certificateIds && certificateIds.length > 0 + ? ` with ${certificateIds.length} certificate(s)` + : "" + }`, type: "success" }); onComplete(pkiSync); @@ -178,9 +192,6 @@ export const CreatePkiSyncForm = ({ destination, onComplete, onCancel }: Props) ))} - - - @@ -194,8 +205,8 @@ export const CreatePkiSyncForm = ({ destination, onComplete, onCancel }: Props) + + + diff --git a/frontend/src/components/pki-syncs/forms/EditPkiSyncForm.tsx b/frontend/src/components/pki-syncs/forms/EditPkiSyncForm.tsx index 7f8ee207f..9041d32f2 100644 --- a/frontend/src/components/pki-syncs/forms/EditPkiSyncForm.tsx +++ b/frontend/src/components/pki-syncs/forms/EditPkiSyncForm.tsx @@ -27,12 +27,16 @@ export const EditPkiSyncForm = ({ pkiSync, fields, onComplete }: Props) => { const formMethods = useForm({ resolver: zodResolver(UpdatePkiSyncFormSchema), defaultValues: { - ...pkiSync, + name: pkiSync.name, + destination: pkiSync.destination, description: pkiSync.description ?? "", connection: { id: pkiSync.connectionId, name: pkiSync.appConnectionName - } + }, + syncOptions: pkiSync.syncOptions, + destinationConfig: pkiSync.destinationConfig, + isAutoSyncEnabled: pkiSync.isAutoSyncEnabled } as Partial, reValidateMode: "onChange" }); diff --git a/frontend/src/components/pki-syncs/forms/PkiSyncCertificatesFields.tsx b/frontend/src/components/pki-syncs/forms/PkiSyncCertificatesFields.tsx new file mode 100644 index 000000000..a5abcb60b --- /dev/null +++ b/frontend/src/components/pki-syncs/forms/PkiSyncCertificatesFields.tsx @@ -0,0 +1,189 @@ +import { useMemo, useState } from "react"; +import { Controller, useFormContext } from "react-hook-form"; +import { faCertificate, faEdit, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { + Button, + EmptyState, + FormControl, + Table, + TableContainer, + TBody, + Td, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { useProject } from "@app/context"; +import { CertStatus } from "@app/hooks/api"; +import { useListWorkspaceCertificates } from "@app/hooks/api/projects"; + +import { CertificateManagementModal } from "../CertificateManagementModal"; +import { TPkiSyncForm } from "./schemas/pki-sync-schema"; + +export const PkiSyncCertificatesFields = () => { + const { control, watch, setValue } = useFormContext(); + const { currentProject } = useProject(); + const [isSelectionModalOpen, setIsSelectionModalOpen] = useState(false); + + const certificateIds = watch("certificateIds") || []; + + const { data, isLoading } = useListWorkspaceCertificates({ + projectId: currentProject?.id || "", + offset: 0, + limit: 100, + forPkiSync: true + }); + + const certificates = data?.certificates || []; + + const activeCertificates = useMemo( + () => certificates.filter((cert) => cert.status === CertStatus.ACTIVE), + [certificates] + ); + + const selectedCertificates = useMemo( + () => activeCertificates.filter((cert) => certificateIds.includes(cert.id)), + [activeCertificates, certificateIds] + ); + + if (isLoading) { + return ( +
+
Loading certificates...
+
+ ); + } + + return ( + <> +

+ Select certificates to sync with this integration. Only active certificates can be synced. + You can modify this selection after creating the sync. +

+ + ( + +
+ +
+ + + + + + + + + + + + + {selectedCertificates.map((cert) => { + let originalDisplayName = "—"; + if (cert.altNames && cert.altNames.trim()) { + originalDisplayName = cert.altNames.trim(); + } else if (cert.commonName && cert.commonName.trim()) { + originalDisplayName = cert.commonName.trim(); + } + + let displayName = originalDisplayName; + let isTruncated = false; + if (originalDisplayName.length > 34) { + displayName = `${originalDisplayName.substring(0, 34)}...`; + isTruncated = true; + } + + const truncatedSerial = + cert.serialNumber.length > 8 + ? `${cert.serialNumber.slice(0, 4)}...${cert.serialNumber.slice(-4)}` + : cert.serialNumber; + + const isExpired = new Date(cert.notAfter) < new Date(); + + return ( + + + + + + + + ); + })} + +
SAN / CNSerial NumberIssued AtExpires AtRemove
+ {isTruncated ? ( + +
{displayName}
+
+ ) : ( +
{displayName}
+ )} +
+
+ {truncatedSerial} +
+
+ + {new Date(cert.notBefore).toLocaleDateString()} + + + + {new Date(cert.notAfter).toLocaleDateString()} + + + +
+ {selectedCertificates.length === 0 && ( + + )} +
+
+
+
+ )} + /> + + setIsSelectionModalOpen(false)} + selectedCertificateIds={certificateIds} + onCertificateSelectionChange={(newCertificateIds) => { + setValue("certificateIds", newCertificateIds); + }} + title="Select Certificates for Sync" + subtitle="Choose which certificates you want to include in this sync. You can modify this selection after creating the sync." + saveButtonText="Update Selection" + /> + + ); +}; diff --git a/frontend/src/components/pki-syncs/forms/PkiSyncConnectionField.tsx b/frontend/src/components/pki-syncs/forms/PkiSyncConnectionField.tsx index 445f9169d..c8da83aac 100644 --- a/frontend/src/components/pki-syncs/forms/PkiSyncConnectionField.tsx +++ b/frontend/src/components/pki-syncs/forms/PkiSyncConnectionField.tsx @@ -1,14 +1,18 @@ import { Controller, useFormContext } from "react-hook-form"; +import { SingleValue } from "react-select"; import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Link } from "@tanstack/react-router"; +import { useRouterState } from "@tanstack/react-router"; +import { AppConnectionOption } from "@app/components/app-connections"; import { FilterableSelect, FormControl } from "@app/components/v2"; import { ProjectPermissionSub, useProject, useProjectPermission } from "@app/context"; import { ProjectPermissionAppConnectionActions } from "@app/context/ProjectPermissionContext/types"; import { APP_CONNECTION_MAP } from "@app/helpers/appConnections"; import { PKI_SYNC_CONNECTION_MAP } from "@app/helpers/pkiSyncs"; +import { usePopUp } from "@app/hooks"; import { useListAvailableAppConnections } from "@app/hooks/api/appConnections"; +import { AddAppConnectionModal } from "@app/pages/organization/AppConnections/AppConnectionsPage/components"; import { TPkiSyncForm } from "./schemas/pki-sync-schema"; @@ -18,12 +22,30 @@ type Props = { export const PkiSyncConnectionField = ({ onChange: callback }: Props) => { const { permission } = useProjectPermission(); - const { control, watch } = useFormContext(); - const { currentProject } = useProject(); + const { control, watch, setValue } = useFormContext(); + + const { popUp, handlePopUpToggle, handlePopUpOpen } = usePopUp(["addConnection"] as const); const destination = watch("destination"); const app = PKI_SYNC_CONNECTION_MAP[destination]; + const { currentProject } = useProject(); + + const { + location: { pathname } + } = useRouterState(); + + const getPkiSyncReturnUrl = () => { + if (pathname.includes("selectedTab=secret-syncs")) { + return pathname.replace("selectedTab=secret-syncs", "selectedTab=pki-syncs"); + } + if (!pathname.includes("selectedTab=")) { + const separator = pathname.includes("?") ? "&" : "?"; + return `${pathname}${separator}selectedTab=pki-syncs`; + } + return pathname; + }; + const { data: availableConnections, isPending } = useListAvailableAppConnections( app, currentProject.id @@ -47,6 +69,7 @@ export const PkiSyncConnectionField = ({ onChange: callback }: Props) => { ( { { + if ((newValue as SingleValue<{ id: string; name: string }>)?.id === "_create") { + handlePopUpOpen("addConnection"); + onChange(null); + const formData = { ...watch(), returnUrl: getPkiSyncReturnUrl() }; + localStorage.setItem("pkiSyncFormData", JSON.stringify(formData)); + if (callback) callback(); + return; + } + onChange(newValue); if (callback) callback(); }} isLoading={isPending} - options={availableConnections} + options={[ + ...(canCreateConnection ? [{ id: "_create", name: "Create Connection" }] : []), + ...(availableConnections ?? []) + ]} placeholder="Select connection..." getOptionLabel={(option) => option.name} getOptionValue={(option) => option.id} + components={{ Option: AppConnectionOption }} /> )} control={control} name="connection" /> - {availableConnections?.length === 0 && ( + {!isPending && !availableConnections?.length && !canCreateConnection && (

- {canCreateConnection ? ( - <> - You do not have access to any {appName} Connections. Create one from the{" "} - - App Connections - {" "} - page. - - ) : ( - `You do not have access to any ${appName} Connections. Contact an admin to create one.` - )} + You do not have access to any {appName} Connections. Contact an admin to create one.

)} + { + localStorage.removeItem("pkiSyncFormData"); + handlePopUpToggle("addConnection", isOpen); + }} + projectType={currentProject.type} + projectId={currentProject.id} + app={app} + onComplete={(connection) => { + if (connection) { + setValue("connection", connection); + } + }} + /> ); }; diff --git a/frontend/src/components/pki-syncs/forms/PkiSyncOptionsFields/PkiSyncOptionsFields.tsx b/frontend/src/components/pki-syncs/forms/PkiSyncOptionsFields/PkiSyncOptionsFields.tsx index ce581bb88..c1313e684 100644 --- a/frontend/src/components/pki-syncs/forms/PkiSyncOptionsFields/PkiSyncOptionsFields.tsx +++ b/frontend/src/components/pki-syncs/forms/PkiSyncOptionsFields/PkiSyncOptionsFields.tsx @@ -71,14 +71,14 @@ export const PkiSyncOptionsFields = ({ destination }: Props) => { isChecked={value} >

- Enable Certificate Removal{" "} + Enable Removal of Expired/Revoked Certificates{" "}

When enabled, Infisical will remove certificates from the destination during - a sync if they are no longer managed by Infisical. + a sync if they are no longer active in Infisical.

Disable this option if you intend to manage some certificates manually @@ -95,6 +95,94 @@ export const PkiSyncOptionsFields = ({ destination }: Props) => { )} /> + {currentDestination === PkiSync.AwsCertificateManager && ( + ( + + +

+ Preserve ARN on Renewal{" "} + +

+ When enabled, Infisical will replace the contents of existing certificates + while preserving the same ARN during certificate renewal syncs. +

+

+ This allows consuming services like load balancers to continue using the + same ARN without requiring manual updates. +

+

+ When disabled, new certificates will be created with new ARNs, and old + certificates will be removed. +

+ + } + > + + +

+ +
+ )} + /> + )} + + {currentDestination === PkiSync.AzureKeyVault && ( + ( + + +

+ Enable Versioning on Renewal{" "} + +

+ When enabled, Infisical will create a new version of the existing + certificate in Azure Key Vault during certificate renewal syncs, + preserving the original certificate name. +

+

+ This allows consuming services to continue using the same certificate name + while automatically using the latest version without requiring manual + updates. +

+

+ When disabled, new certificates will be created with new names, and old + certificates will be removed. +

+ + } + > + + +

+
+
+ )} + /> + )} + { const { watch } = useFormContext(); const { currentProject } = useProject(); - const { data: pkiSubscribers = [] } = useListWorkspacePkiSubscribers(currentProject?.id || ""); + const { data } = useListWorkspaceCertificates({ + projectId: currentProject?.id || "", + offset: 0, + limit: 100 + }); - const getSubscriberName = (subscriberId?: string) => { - const subscriber = pkiSubscribers.find((sub) => sub.id === subscriberId); - return subscriber?.name || "Unknown"; + const certificates = data?.certificates || []; + + const getSelectedCertificates = (certificateIds?: string[]) => { + if (!certificateIds || certificateIds.length === 0) return []; + return certificates.filter((cert) => certificateIds.includes(cert.id)); }; const { name, description, connection, - subscriberId, + certificateIds, syncOptions, destination, destinationConfig, @@ -31,17 +47,79 @@ export const PkiSyncReviewFields = () => { } = watch(); const destinationName = PKI_SYNC_MAP[destination].name; + const selectedCertificates = getSelectedCertificates(certificateIds); return (
- Source + Certificates
-
- - {getSubscriberName(subscriberId)} - +
+ {selectedCertificates.length === 0 ? ( + No certificates selected + ) : ( + + + + + + + + + + + {selectedCertificates.map((cert) => { + let originalDisplayName = "—"; + if (cert.altNames && cert.altNames.trim()) { + originalDisplayName = cert.altNames.trim(); + } else if (cert.commonName && cert.commonName.trim()) { + originalDisplayName = cert.commonName.trim(); + } + + let displayName = originalDisplayName; + let isTruncated = false; + if (originalDisplayName.length > 34) { + displayName = `${originalDisplayName.substring(0, 34)}...`; + isTruncated = true; + } + + const truncatedSerial = + cert.serialNumber.length > 8 + ? `${cert.serialNumber.slice(0, 4)}...${cert.serialNumber.slice(-4)}` + : cert.serialNumber; + + return ( + + + + + + ); + })} + +
SAN / CNSerial NumberExpires At
+ {isTruncated ? ( + +
{displayName}
+
+ ) : ( +
{displayName}
+ )} +
+
+ {truncatedSerial} +
+
+ + {new Date(cert.notAfter).toLocaleDateString()} + +
+
+ )}
@@ -62,11 +140,13 @@ export const PkiSyncReviewFields = () => {
Sync Options
-
+
- - {isAutoSyncEnabled ? "Enabled" : "Disabled"} - +
+ + {isAutoSyncEnabled ? "Enabled" : "Disabled"} + +
{/* Hidden for now - Import certificates functionality disabled {syncOptions?.canImportCertificates !== undefined && ( @@ -79,9 +159,11 @@ export const PkiSyncReviewFields = () => { */} {syncOptions?.canRemoveCertificates !== undefined && ( - - {syncOptions.canRemoveCertificates ? "Enabled" : "Disabled"} - +
+ + {syncOptions.canRemoveCertificates ? "Enabled" : "Disabled"} + +
)}
diff --git a/frontend/src/components/pki-syncs/forms/schemas/aws-certificate-manager-pki-sync-destination-schema.ts b/frontend/src/components/pki-syncs/forms/schemas/aws-certificate-manager-pki-sync-destination-schema.ts index aa522ef7d..4333b6187 100644 --- a/frontend/src/components/pki-syncs/forms/schemas/aws-certificate-manager-pki-sync-destination-schema.ts +++ b/frontend/src/components/pki-syncs/forms/schemas/aws-certificate-manager-pki-sync-destination-schema.ts @@ -7,6 +7,7 @@ import { BasePkiSyncSchema } from "./base-pki-sync-schema"; const AwsCertificateManagerSyncOptionsSchema = z.object({ canImportCertificates: z.boolean().default(false), canRemoveCertificates: z.boolean().default(false), + preserveArn: z.boolean().default(true), certificateNameSchema: z .string() .optional() diff --git a/frontend/src/components/pki-syncs/forms/schemas/azure-key-vault-pki-sync-destination-schema.ts b/frontend/src/components/pki-syncs/forms/schemas/azure-key-vault-pki-sync-destination-schema.ts index e87e7a52c..2a8f3ee53 100644 --- a/frontend/src/components/pki-syncs/forms/schemas/azure-key-vault-pki-sync-destination-schema.ts +++ b/frontend/src/components/pki-syncs/forms/schemas/azure-key-vault-pki-sync-destination-schema.ts @@ -4,7 +4,46 @@ import { PkiSync } from "@app/hooks/api/pkiSyncs"; import { BasePkiSyncSchema } from "./base-pki-sync-schema"; -export const AzureKeyVaultPkiSyncDestinationSchema = BasePkiSyncSchema().merge( +const AzureKeyVaultSyncOptionsSchema = z.object({ + canImportCertificates: z.boolean().default(false), + canRemoveCertificates: z.boolean().default(true), + enableVersioning: z.boolean().default(true), + certificateNameSchema: z + .string() + .optional() + .refine( + (val) => { + if (!val) return true; + + const allowedOptionalPlaceholders = ["{{environment}}"]; + + const allowedPlaceholdersRegexPart = ["{{certificateId}}", ...allowedOptionalPlaceholders] + .map((p) => p.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")) + .join("|"); + + const allowedContentRegex = new RegExp( + `^([a-zA-Z0-9_\\-/]|${allowedPlaceholdersRegexPart})*$` + ); + const contentIsValid = allowedContentRegex.test(val); + + if (val.trim()) { + const certificateIdRegex = /\{\{certificateId\}\}/; + const certificateIdIsPresent = certificateIdRegex.test(val); + return contentIsValid && certificateIdIsPresent; + } + + return contentIsValid; + }, + { + message: + "Certificate name schema must include exactly one {{certificateId}} placeholder. It can also include {{environment}} placeholders. Only alphanumeric characters (a-z, A-Z, 0-9), dashes (-), underscores (_), and slashes (/) are allowed besides the placeholders." + } + ) +}); + +export const AzureKeyVaultPkiSyncDestinationSchema = BasePkiSyncSchema( + AzureKeyVaultSyncOptionsSchema +).merge( z.object({ destination: z.literal(PkiSync.AzureKeyVault), destinationConfig: z.object({ diff --git a/frontend/src/components/pki-syncs/forms/schemas/base-pki-sync-schema.ts b/frontend/src/components/pki-syncs/forms/schemas/base-pki-sync-schema.ts index f8c9d599f..73da1f6af 100644 --- a/frontend/src/components/pki-syncs/forms/schemas/base-pki-sync-schema.ts +++ b/frontend/src/components/pki-syncs/forms/schemas/base-pki-sync-schema.ts @@ -53,7 +53,8 @@ export const BasePkiSyncSchema = { onClick={() => enterprise && !subscription.enterpriseSecretSyncs ? handlePopUpOpen("upgradePlan", { - isEnterpriseFeature: true + isEnterpriseFeature: true, + text: "All Secret Syncs can be unlocked if you switch to Infisical Enterprise plan." }) : onSelect(destination) } @@ -149,7 +150,7 @@ export const SecretSyncSelect = ({ onSelect }: Props) => { isOpen={popUp.upgradePlan.isOpen} isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature} onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)} - text="You can use every Secret Sync if you switch to Infisical's Enterprise plan." + text={popUp.upgradePlan.data?.text} />
); diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/ChefSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/ChefSyncFields.tsx new file mode 100644 index 000000000..bf5e0908c --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/ChefSyncFields.tsx @@ -0,0 +1,93 @@ +import { Controller, useFormContext, useWatch } from "react-hook-form"; +import { SingleValue } from "react-select"; + +import { SecretSyncConnectionField } from "@app/components/secret-syncs/forms/SecretSyncConnectionField"; +import { FilterableSelect, FormControl } from "@app/components/v2"; +import { + TChefDataBag, + TChefDataBagItem, + useChefConnectionListDataBagItems, + useChefConnectionListDataBags +} from "@app/hooks/api/appConnections/chef"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +import { TSecretSyncForm } from "../schemas"; + +export const ChefSyncFields = () => { + const { control, setValue } = useFormContext< + TSecretSyncForm & { destination: SecretSync.Chef } + >(); + + const connectionId = useWatch({ name: "connection.id", control }); + const dataBagName = useWatch({ name: "destinationConfig.dataBagName", control }); + + const { data: dataBags, isLoading: isDataBagsLoading } = useChefConnectionListDataBags( + connectionId, + { + enabled: Boolean(connectionId) + } + ); + + const { data: dataBagItems, isLoading: isDataBagItemsLoading } = + useChefConnectionListDataBagItems(connectionId, dataBagName, { + enabled: Boolean(connectionId && dataBagName) + }); + + const handleChangeConnection = () => { + setValue("destinationConfig.dataBagName", ""); + setValue("destinationConfig.dataBagItemName", ""); + }; + + return ( + <> + + + ( + + dataBag.name === value) ?? null} + onChange={(option) => { + const selectedDataBag = option as SingleValue; + onChange(selectedDataBag?.name ?? ""); + setValue("destinationConfig.dataBagItemName", ""); + }} + options={dataBags} + placeholder="Select a data bag..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.name} + /> + + )} + /> + + ( + + dataBagItem.name === value) ?? null} + onChange={(option) => { + const selectedDataBagItem = option as SingleValue; + onChange(selectedDataBagItem?.name ?? ""); + }} + options={dataBagItems} + placeholder="Select a data bag item..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.name} + /> + + )} + /> + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx index ffad9aa42..50fee8b9b 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx @@ -12,6 +12,7 @@ import { AzureKeyVaultSyncFields } from "./AzureKeyVaultSyncFields"; import { BitbucketSyncFields } from "./BitbucketSyncFields"; import { CamundaSyncFields } from "./CamundaSyncFields"; import { ChecklySyncFields } from "./ChecklySyncFields"; +import { ChefSyncFields } from "./ChefSyncFields"; import { CloudflarePagesSyncFields } from "./CloudflarePagesSyncFields"; import { CloudflareWorkersSyncFields } from "./CloudflareWorkersSyncFields"; import { DatabricksSyncFields } from "./DatabricksSyncFields"; @@ -104,6 +105,8 @@ export const SecretSyncDestinationFields = () => { return ; case SecretSync.LaravelForge: return ; + case SecretSync.Chef: + return ; case SecretSync.Northflank: return ; default: diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx index a7fb037de..506c81a21 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx @@ -71,6 +71,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { case SecretSync.Northflank: case SecretSync.Bitbucket: case SecretSync.LaravelForge: + case SecretSync.Chef: AdditionalSyncOptionsFieldsComponent = null; break; default: diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/ChefSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/ChefSyncReviewFields.tsx new file mode 100644 index 000000000..95f5310a2 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/ChefSyncReviewFields.tsx @@ -0,0 +1,18 @@ +import { useFormContext } from "react-hook-form"; + +import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; +import { GenericFieldLabel } from "@app/components/v2"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const ChefSyncReviewFields = () => { + const { watch } = useFormContext(); + const dataBagName = watch("destinationConfig.dataBagName"); + const dataBagItemName = watch("destinationConfig.dataBagItemName"); + + return ( + <> + {dataBagName} + {dataBagItemName} + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index 4cc9c7259..940b3c173 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -24,6 +24,7 @@ import { AzureKeyVaultSyncReviewFields } from "./AzureKeyVaultSyncReviewFields"; import { BitbucketSyncReviewFields } from "./BitbucketSyncReviewFields"; import { CamundaSyncReviewFields } from "./CamundaSyncReviewFields"; import { ChecklySyncReviewFields } from "./ChecklySyncReviewFields"; +import { ChefSyncReviewFields } from "./ChefSyncReviewFields"; import { CloudflarePagesSyncReviewFields } from "./CloudflarePagesReviewFields"; import { CloudflareWorkersSyncReviewFields } from "./CloudflareWorkersReviewFields"; import { DatabricksSyncReviewFields } from "./DatabricksSyncReviewFields"; @@ -177,6 +178,9 @@ export const SecretSyncReviewFields = () => { case SecretSync.LaravelForge: DestinationFieldsComponent = ; break; + case SecretSync.Chef: + DestinationFieldsComponent = ; + break; default: throw new Error(`Unhandled Destination Review Fields: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/schemas/chef-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/chef-sync-destination-schema.ts new file mode 100644 index 000000000..8d27b616f --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/chef-sync-destination-schema.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +import { BaseSecretSyncSchema } from "@app/components/secret-syncs/forms/schemas/base-secret-sync-schema"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +export const ChefSyncDestinationSchema = BaseSecretSyncSchema().merge( + z.object({ + destination: z.literal(SecretSync.Chef), + destinationConfig: z.object({ + dataBagName: z.string().trim().min(1, "Data Bag required"), + dataBagItemName: z.string().trim().min(1, "Data Bag Item required") + }) + }) +); diff --git a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts index 146c862dc..561191da0 100644 --- a/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts +++ b/frontend/src/components/secret-syncs/forms/schemas/secret-sync-schema.ts @@ -9,6 +9,7 @@ import { AzureKeyVaultSyncDestinationSchema } from "./azure-key-vault-sync-desti import { BitbucketSyncDestinationSchema } from "./bitbucket-sync-destination-schema"; import { CamundaSyncDestinationSchema } from "./camunda-sync-destination-schema"; import { ChecklySyncDestinationSchema } from "./checkly-sync-destination-schema"; +import { ChefSyncDestinationSchema } from "./chef-sync-destination-schema"; import { CloudflarePagesSyncDestinationSchema } from "./cloudflare-pages-sync-destination-schema"; import { CloudflareWorkersSyncDestinationSchema } from "./cloudflare-workers-sync-destination-schema"; import { DatabricksSyncDestinationSchema } from "./databricks-sync-destination-schema"; @@ -65,7 +66,8 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ NetlifySyncDestinationSchema, NorthflankSyncDestinationSchema, BitbucketSyncDestinationSchema, - LaravelForgeSyncDestinationSchema + LaravelForgeSyncDestinationSchema, + ChefSyncDestinationSchema ]); export const SecretSyncFormSchema = SecretSyncUnionSchema; diff --git a/frontend/src/components/utilities/certificateDisplayUtils.tsx b/frontend/src/components/utilities/certificateDisplayUtils.tsx new file mode 100644 index 000000000..4a2b2de26 --- /dev/null +++ b/frontend/src/components/utilities/certificateDisplayUtils.tsx @@ -0,0 +1,100 @@ +import { ReactNode } from "react"; + +import { Tooltip } from "@app/components/v2"; + +interface CertificateNameData { + altNames?: string | null; + commonName?: string | null; + certificateAltNames?: string | null; + certificateCommonName?: string | null; +} + +interface DisplayNameResult { + originalDisplayName: string; + displayName: string; + isTruncated: boolean; +} + +/** + * Extracts and formats the display name for a certificate from SAN/CN data + * @param cert - Certificate object with potential altNames/commonName fields + * @param maxLength - Maximum length before truncating (default: 64) + * @param fallback - Fallback text when no name is found (default: "—") + * @returns Object with original name, truncated name, and truncation flag + */ +export const getCertificateDisplayName = ( + cert: CertificateNameData, + maxLength: number = 64, + fallback: string = "—" +): DisplayNameResult => { + // Extract original display name - prioritize SAN over CN + let originalDisplayName = fallback; + + // Handle different property name variations + const altNames = cert.altNames || cert.certificateAltNames; + const commonName = cert.commonName || cert.certificateCommonName; + + if (altNames && altNames.trim()) { + originalDisplayName = altNames.trim(); + } else if (commonName && commonName.trim()) { + originalDisplayName = commonName.trim(); + } + + // Handle truncation + let displayName = originalDisplayName; + let isTruncated = false; + + if (originalDisplayName.length > maxLength) { + displayName = `${originalDisplayName.substring(0, maxLength)}...`; + isTruncated = true; + } + + return { + originalDisplayName, + displayName, + isTruncated + }; +}; + +/** + * Renders a certificate display name with optional tooltip for truncated names + * @param cert - Certificate object with potential altNames/commonName fields + * @param maxLength - Maximum length before truncating (default: 64) + * @param fallback - Fallback text when no name is found (default: "—") + * @param className - Optional CSS class for the display element + * @param tooltipClassName - Optional CSS class for the tooltip (default: "max-w-lg") + * @returns JSX element with certificate name and optional tooltip + */ +export const CertificateDisplayName = ({ + cert, + maxLength = 64, + fallback = "—", + className = "truncate", + tooltipClassName = "max-w-lg" +}: { + cert: CertificateNameData; + maxLength?: number; + fallback?: string; + className?: string; + tooltipClassName?: string; +}): ReactNode => { + const { originalDisplayName, displayName, isTruncated } = getCertificateDisplayName( + cert, + maxLength, + fallback + ); + + if (isTruncated) { + return ( + +
{displayName}
+
+ ); + } + + return ( +
+ {displayName} +
+ ); +}; diff --git a/frontend/src/components/v2/HighlightText/HighlightText.tsx b/frontend/src/components/v2/HighlightText/HighlightText.tsx index c81dab2df..92fdc1d6d 100644 --- a/frontend/src/components/v2/HighlightText/HighlightText.tsx +++ b/frontend/src/components/v2/HighlightText/HighlightText.tsx @@ -9,22 +9,10 @@ export const HighlightText = ({ }) => { if (!text) return null; - const renderTextWithNewlines = (input: string, baseKeyPrefix: string = ""): React.ReactNode[] => { - if (!input) return []; - const lines = input.split("\n"); - return lines.flatMap((line, index) => { - const nodes: React.ReactNode[] = [line]; - if (index < lines.length - 1) { - nodes.push(
); - } - return nodes; - }); - }; - const searchTerm = highlight.toLowerCase().trim(); if (!searchTerm) { - return {renderTextWithNewlines(text, "full-text")}; + return {text}; } const parts: React.ReactNode[] = []; @@ -36,16 +24,12 @@ export const HighlightText = ({ text.replace(regex, (match: string, offset: number) => { if (offset > lastIndex) { const preMatchText = text.substring(lastIndex, offset); - parts.push( - - {renderTextWithNewlines(preMatchText, `pre-${lastIndex}`)} - - ); + parts.push({preMatchText}); } parts.push( - {renderTextWithNewlines(match, `match-${offset}`)} + {match} ); @@ -56,11 +40,7 @@ export const HighlightText = ({ if (lastIndex < text.length) { const postMatchText = text.substring(lastIndex); - parts.push( - - {renderTextWithNewlines(postMatchText, `post-${lastIndex}`)} - - ); + parts.push({postMatchText}); } return parts; diff --git a/frontend/src/const/routes.ts b/frontend/src/const/routes.ts index c9a6b326d..5f199443d 100644 --- a/frontend/src/const/routes.ts +++ b/frontend/src/const/routes.ts @@ -301,10 +301,6 @@ export const ROUTE_PATHS = Object.freeze({ "/projects/cert-management/$projectId/subscribers", "/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/subscribers" ), - CertificatesPage: setRoute( - "/projects/cert-management/$projectId/certificates", - "/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/certificates" - ), CertificateAuthoritiesPage: setRoute( "/projects/cert-management/$projectId/certificate-authorities", "/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/certificate-authorities" diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index 351357d20..1d0d7bec3 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -46,6 +46,7 @@ import { } from "@app/hooks/api/appConnections/types"; import { BitbucketConnectionMethod } from "@app/hooks/api/appConnections/types/bitbucket-connection"; import { ChecklyConnectionMethod } from "@app/hooks/api/appConnections/types/checkly-connection"; +import { ChefConnectionMethod } from "@app/hooks/api/appConnections/types/chef-connection"; import { DigitalOceanConnectionMethod } from "@app/hooks/api/appConnections/types/digital-ocean"; import { HerokuConnectionMethod } from "@app/hooks/api/appConnections/types/heroku-connection"; import { LaravelForgeConnectionMethod } from "@app/hooks/api/appConnections/types/laravel-forge-connection"; @@ -129,7 +130,8 @@ export const APP_CONNECTION_MAP: Record< name: "Laravel Forge", image: "Laravel Forge.png", size: 65 - } + }, + [AppConnection.Chef]: { name: "Chef", image: "Chef.png" } }; export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) => { @@ -202,6 +204,8 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) case RenderConnectionMethod.ApiKey: case ChecklyConnectionMethod.ApiKey: return { name: "API Key", icon: faKey }; + case ChefConnectionMethod.UserKey: + return { name: "User Key", icon: faKey }; case AzureClientSecretsConnectionMethod.ClientSecret: case AzureAppConfigurationConnectionMethod.ClientSecret: case AzureKeyVaultConnectionMethod.ClientSecret: diff --git a/frontend/src/helpers/secretSyncs.ts b/frontend/src/helpers/secretSyncs.ts index a96af15d4..393aff644 100644 --- a/frontend/src/helpers/secretSyncs.ts +++ b/frontend/src/helpers/secretSyncs.ts @@ -121,6 +121,10 @@ export const SECRET_SYNC_MAP: Record = { [SecretSync.Netlify]: AppConnection.Netlify, [SecretSync.Northflank]: AppConnection.Northflank, [SecretSync.Bitbucket]: AppConnection.Bitbucket, - [SecretSync.LaravelForge]: AppConnection.LaravelForge + [SecretSync.LaravelForge]: AppConnection.LaravelForge, + [SecretSync.Chef]: AppConnection.Chef }; export const SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP: Record< diff --git a/frontend/src/hooks/api/appConnections/chef/index.ts b/frontend/src/hooks/api/appConnections/chef/index.ts new file mode 100644 index 000000000..2c1906d36 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/chef/index.ts @@ -0,0 +1,2 @@ +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/appConnections/chef/queries.tsx b/frontend/src/hooks/api/appConnections/chef/queries.tsx new file mode 100644 index 000000000..f24a39eed --- /dev/null +++ b/frontend/src/hooks/api/appConnections/chef/queries.tsx @@ -0,0 +1,68 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; +import { appConnectionKeys } from "@app/hooks/api/appConnections"; + +import { TChefDataBag, TChefDataBagItem } from "./types"; + +const chefConnectionKeys = { + all: [...appConnectionKeys.all, "chef"] as const, + listDataBags: (connectionId: string) => + [...chefConnectionKeys.all, "data-bags", connectionId] as const, + listDataBagItems: (connectionId: string, dataBagName: string) => + [...chefConnectionKeys.all, "data-bag-items", connectionId, dataBagName] as const +}; + +export const useChefConnectionListDataBags = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TChefDataBag[], + unknown, + TChefDataBag[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: chefConnectionKeys.listDataBags(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/app-connections/chef/${connectionId}/data-bags` + ); + + return data; + }, + ...options + }); +}; + +export const useChefConnectionListDataBagItems = ( + connectionId: string, + dataBagName: string, + options?: Omit< + UseQueryOptions< + TChefDataBagItem[], + unknown, + TChefDataBagItem[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: chefConnectionKeys.listDataBagItems(connectionId, dataBagName), + queryFn: async () => { + const params = { dataBagName }; + const { data } = await apiRequest.get( + `/api/v1/app-connections/chef/${connectionId}/data-bag-items`, + { params } + ); + + return data; + }, + enabled: Boolean(connectionId && dataBagName), + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/chef/types.ts b/frontend/src/hooks/api/appConnections/chef/types.ts new file mode 100644 index 000000000..fd87d47c7 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/chef/types.ts @@ -0,0 +1,7 @@ +export type TChefDataBag = { + name: string; +}; + +export type TChefDataBagItem = { + name: string; +}; diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index 4af1dbb27..fba0cbb4b 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -39,5 +39,6 @@ export enum AppConnection { Northflank = "northflank", Okta = "okta", Redis = "redis", - LaravelForge = "laravel-forge" + LaravelForge = "laravel-forge", + Chef = "chef" } diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts index 4d4425ef6..1f553f605 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -148,6 +148,10 @@ export type TChecklyConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Checkly; }; +export type TChefConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.Chef; +}; + export type TSupabaseConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Supabase; }; @@ -220,7 +224,8 @@ export type TAppConnectionOption = | TNorthflankConnectionOption | TOktaConnectionOption | TAzureAdCsConnectionOption - | TLaravelForgeConnectionOption; + | TLaravelForgeConnectionOption + | TChefConnectionOption; export type TAppConnectionOptionMap = { [AppConnection.AWS]: TAwsConnectionOption; @@ -264,4 +269,5 @@ export type TAppConnectionOptionMap = { [AppConnection.AzureADCS]: TAzureAdCsConnectionOption; [AppConnection.Redis]: TRedisConnectionOption; [AppConnection.LaravelForge]: TLaravelForgeConnectionOption; + [AppConnection.Chef]: TChefConnectionOption; }; diff --git a/frontend/src/hooks/api/appConnections/types/chef-connection.ts b/frontend/src/hooks/api/appConnections/types/chef-connection.ts new file mode 100644 index 000000000..371d42199 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/chef-connection.ts @@ -0,0 +1,16 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum ChefConnectionMethod { + UserKey = "user-key" +} + +export type TChefConnection = TRootAppConnection & { app: AppConnection.Chef } & { + method: ChefConnectionMethod.UserKey; + credentials: { + instanceUrl?: string; + orgName: string; + userName: string; + privateKey: string; + }; +}; diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index d82ad90ec..3e62031b3 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -11,6 +11,7 @@ import { TAzureKeyVaultConnection } from "./azure-key-vault-connection"; import { TBitbucketConnection } from "./bitbucket-connection"; import { TCamundaConnection } from "./camunda-connection"; import { TChecklyConnection } from "./checkly-connection"; +import { TChefConnection } from "./chef-connection"; import { TCloudflareConnection } from "./cloudflare-connection"; import { TDatabricksConnection } from "./databricks-connection"; import { TDigitalOceanConnection } from "./digital-ocean"; @@ -53,6 +54,7 @@ export * from "./azure-key-vault-connection"; export * from "./bitbucket-connection"; export * from "./camunda-connection"; export * from "./checkly-connection"; +export * from "./chef-connection"; export * from "./cloudflare-connection"; export * from "./databricks-connection"; export * from "./flyio-connection"; @@ -124,7 +126,8 @@ export type TAppConnection = | TNetlifyConnection | TNorthflankConnection | TOktaConnection - | TRedisConnection; + | TRedisConnection + | TChefConnection; export type TAvailableAppConnection = Pick; diff --git a/frontend/src/hooks/api/ca/mutations.tsx b/frontend/src/hooks/api/ca/mutations.tsx index fa422054c..49a53dce7 100644 --- a/frontend/src/hooks/api/ca/mutations.tsx +++ b/frontend/src/hooks/api/ca/mutations.tsx @@ -152,7 +152,7 @@ export const useCreateCertificate = () => { }); }; -export const useCreateCertificateV3 = () => { +export const useCreateCertificateV3 = (options?: { projectId?: string }) => { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (body) => { @@ -167,6 +167,12 @@ export const useCreateCertificateV3 = () => { queryKey: projectKeys.forProjectCertificates(projectSlug) }); + if (options?.projectId) { + queryClient.invalidateQueries({ + queryKey: projectKeys.forProjectCertificates(options.projectId) + }); + } + queryClient.invalidateQueries({ queryKey: ["certificate-profiles"] }); diff --git a/frontend/src/hooks/api/certificateProfiles/index.ts b/frontend/src/hooks/api/certificateProfiles/index.ts index dc5c17efa..e12e066c4 100644 --- a/frontend/src/hooks/api/certificateProfiles/index.ts +++ b/frontend/src/hooks/api/certificateProfiles/index.ts @@ -8,7 +8,6 @@ export { useGetCertificateProfileById, useGetCertificateProfileBySlug, useGetProfileCertificates, - useGetProfileMetrics, useListCertificateProfiles } from "./queries"; export type * from "./types"; diff --git a/frontend/src/hooks/api/certificateProfiles/queries.tsx b/frontend/src/hooks/api/certificateProfiles/queries.tsx index abdc93ddb..19859b02f 100644 --- a/frontend/src/hooks/api/certificateProfiles/queries.tsx +++ b/frontend/src/hooks/api/certificateProfiles/queries.tsx @@ -4,7 +4,6 @@ import { apiRequest } from "@app/config/request"; import { TCertificateProfile, - TCertificateProfileMetrics, TCertificateProfileWithDetails, TGetCertificateProfileByIdDTO, TGetCertificateProfileBySlugDTO, @@ -20,7 +19,6 @@ export const certificateProfileKeys = { limit?: number; offset?: number; search?: string; - includeMetrics?: boolean; includeConfigs?: boolean; enrollmentType?: string; expiringDays?: number; @@ -51,10 +49,8 @@ export const useListCertificateProfiles = ({ limit = 20, offset = 0, search, - includeMetrics = false, includeConfigs = false, - enrollmentType, - expiringDays = 7 + enrollmentType }: TListCertificateProfilesDTO) => { return useQuery({ queryKey: certificateProfileKeys.list({ @@ -62,10 +58,8 @@ export const useListCertificateProfiles = ({ limit, offset, search, - includeMetrics, includeConfigs, - enrollmentType, - expiringDays + enrollmentType }), queryFn: async () => { const { data } = await apiRequest.get<{ @@ -77,10 +71,8 @@ export const useListCertificateProfiles = ({ limit, offset, search, - includeMetrics, includeConfigs, - enrollmentType, - expiringDays + enrollmentType } }); return data; @@ -145,18 +137,3 @@ export const useGetProfileCertificates = ({ enabled: Boolean(profileId) }); }; - -export const useGetProfileMetrics = ({ profileId, expiringDays = 7 }: TGetProfileMetricsDTO) => { - return useQuery({ - queryKey: certificateProfileKeys.getMetrics(profileId, { expiringDays }), - queryFn: async () => { - const { data } = await apiRequest.get<{ - metrics: TCertificateProfileMetrics; - }>(`/api/v1/pki/certificate-profiles/${profileId}/metrics`, { - params: { expiringDays } - }); - return data.metrics; - }, - enabled: Boolean(profileId) - }); -}; diff --git a/frontend/src/hooks/api/certificateProfiles/types.ts b/frontend/src/hooks/api/certificateProfiles/types.ts index b5c53e11b..f3584b12d 100644 --- a/frontend/src/hooks/api/certificateProfiles/types.ts +++ b/frontend/src/hooks/api/certificateProfiles/types.ts @@ -10,7 +10,6 @@ export type TCertificateProfile = { apiConfigId?: string; createdAt: string; updatedAt: string; - metrics?: TCertificateProfileMetrics; }; export type TCertificateProfileWithDetails = TCertificateProfile & { @@ -81,10 +80,8 @@ export type TListCertificateProfilesDTO = { limit?: number; offset?: number; search?: string; - includeMetrics?: boolean; includeConfigs?: boolean; enrollmentType?: "api" | "est"; - expiringDays?: number; }; export type TGetCertificateProfileByIdDTO = { @@ -96,15 +93,6 @@ export type TGetCertificateProfileBySlugDTO = { slug: string; }; -export type TCertificateProfileMetrics = { - profileId: string; - totalCertificates: number; - activeCertificates: number; - expiredCertificates: number; - expiringCertificates: number; - revokedCertificates: number; -}; - export type TProfileCertificate = { id: string; serialNumber: string; @@ -126,5 +114,4 @@ export type TGetProfileCertificatesDTO = { export type TGetProfileMetricsDTO = { profileId: string; - expiringDays?: number; }; diff --git a/frontend/src/hooks/api/certificateTemplates/types.ts b/frontend/src/hooks/api/certificateTemplates/types.ts index 373bbef2a..a6653cbec 100644 --- a/frontend/src/hooks/api/certificateTemplates/types.ts +++ b/frontend/src/hooks/api/certificateTemplates/types.ts @@ -42,8 +42,8 @@ export type TCreateCertificateTemplateDTO = { subjectAlternativeName: string; ttl: string; projectId: string; - keyUsages: CertKeyUsage[]; - extendedKeyUsages: CertExtendedKeyUsage[]; + keyUsages: string[]; + extendedKeyUsages: string[]; }; export type TUpdateCertificateTemplateDTO = { @@ -55,8 +55,8 @@ export type TUpdateCertificateTemplateDTO = { subjectAlternativeName?: string; ttl?: string; projectId: string; - keyUsages?: CertKeyUsage[]; - extendedKeyUsages?: CertExtendedKeyUsage[]; + keyUsages?: string[]; + extendedKeyUsages?: string[]; }; export type TDeleteCertificateTemplateDTO = { @@ -71,8 +71,8 @@ export type TCreateCertificateTemplateV2DTO = { subjectAlternativeName: string; ttl: string; projectId: string; - keyUsages: CertKeyUsage[]; - extendedKeyUsages: CertExtendedKeyUsage[]; + keyUsages: string[]; + extendedKeyUsages: string[]; }; export type TUpdateCertificateTemplateV2DTO = { @@ -83,8 +83,8 @@ export type TUpdateCertificateTemplateV2DTO = { subjectAlternativeName?: string; ttl?: string; projectId: string; - keyUsages?: CertKeyUsage[]; - extendedKeyUsages?: CertExtendedKeyUsage[]; + keyUsages?: string[]; + extendedKeyUsages?: string[]; }; export type TDeleteCertificateTemplateV2DTO = { diff --git a/frontend/src/hooks/api/certificates/index.tsx b/frontend/src/hooks/api/certificates/index.tsx index a60ebf91e..7d9c5df08 100644 --- a/frontend/src/hooks/api/certificates/index.tsx +++ b/frontend/src/hooks/api/certificates/index.tsx @@ -1,3 +1,4 @@ +export { CertStatus } from "./enums"; export { useDeleteCert, useImportCertificate, diff --git a/frontend/src/hooks/api/certificates/types.ts b/frontend/src/hooks/api/certificates/types.ts index 622276e24..adfb815a6 100644 --- a/frontend/src/hooks/api/certificates/types.ts +++ b/frontend/src/hooks/api/certificates/types.ts @@ -9,6 +9,7 @@ export type TCertificate = { friendlyName: string; commonName: string; subjectAltNames: string; + altNames?: string; serialNumber: string; notBefore: string; notAfter: string; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index e9e80feec..0216ae030 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -28,6 +28,7 @@ export * from "./organization"; export * from "./pkiAlerts"; export * from "./pkiCollections"; export * from "./pkiSubscriber"; +export * from "./pkiSyncs"; export * from "./projects"; export * from "./projectUserAdditionalPrivilege"; export * from "./rateLimit"; diff --git a/frontend/src/hooks/api/pkiSubscriber/types.ts b/frontend/src/hooks/api/pkiSubscriber/types.ts index 23ca6baf3..239d56148 100644 --- a/frontend/src/hooks/api/pkiSubscriber/types.ts +++ b/frontend/src/hooks/api/pkiSubscriber/types.ts @@ -47,8 +47,8 @@ export type TCreatePkiSubscriberDTO = { commonName: string; ttl?: string; subjectAlternativeNames: string[]; - keyUsages: CertKeyUsage[]; - extendedKeyUsages: CertExtendedKeyUsage[]; + keyUsages: string[]; + extendedKeyUsages: string[]; enableAutoRenewal?: boolean; autoRenewalPeriodInDays?: number; properties?: TPkiSubscriberProperties; @@ -63,8 +63,8 @@ export type TUpdatePkiSubscriberDTO = { status?: PkiSubscriberStatus; ttl?: string; subjectAlternativeNames?: string[]; - keyUsages?: CertKeyUsage[]; - extendedKeyUsages?: CertExtendedKeyUsage[]; + keyUsages?: string[]; + extendedKeyUsages?: string[]; enableAutoRenewal?: boolean; autoRenewalPeriodInDays?: number; properties?: TPkiSubscriberProperties; diff --git a/frontend/src/hooks/api/pkiSyncs/enums.ts b/frontend/src/hooks/api/pkiSyncs/enums.ts index 507f3336c..014516230 100644 --- a/frontend/src/hooks/api/pkiSyncs/enums.ts +++ b/frontend/src/hooks/api/pkiSyncs/enums.ts @@ -9,3 +9,10 @@ export enum PkiSyncStatus { Succeeded = "succeeded", Failed = "failed" } + +export enum CertificateSyncStatus { + Pending = "pending", + Syncing = "syncing", + Succeeded = "succeeded", + Failed = "failed" +} diff --git a/frontend/src/hooks/api/pkiSyncs/mutations.tsx b/frontend/src/hooks/api/pkiSyncs/mutations.tsx index 30cf18690..9aff79dd0 100644 --- a/frontend/src/hooks/api/pkiSyncs/mutations.tsx +++ b/frontend/src/hooks/api/pkiSyncs/mutations.tsx @@ -198,3 +198,47 @@ export const useTriggerPkiSyncRemoveCertificates = () => { } }); }; + +export const useAddCertificatesToPkiSync = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + pkiSyncId, + certificateIds + }: { + pkiSyncId: string; + certificateIds: string[]; + }) => { + const { data } = await apiRequest.post(`/api/v1/pki/syncs/${pkiSyncId}/certificates`, { + certificateIds + }); + + return data; + }, + onSuccess: (_, { pkiSyncId }) => { + queryClient.invalidateQueries({ queryKey: pkiSyncKeys.certificates(pkiSyncId) }); + } + }); +}; + +export const useRemoveCertificatesFromPkiSync = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + pkiSyncId, + certificateIds + }: { + pkiSyncId: string; + certificateIds: string[]; + }) => { + const { data } = await apiRequest.delete(`/api/v1/pki/syncs/${pkiSyncId}/certificates`, { + data: { certificateIds } + }); + + return data; + }, + onSuccess: (_, { pkiSyncId }) => { + queryClient.invalidateQueries({ queryKey: pkiSyncKeys.certificates(pkiSyncId) }); + } + }); +}; diff --git a/frontend/src/hooks/api/pkiSyncs/queries.tsx b/frontend/src/hooks/api/pkiSyncs/queries.tsx index d0db913bd..6e7aabc42 100644 --- a/frontend/src/hooks/api/pkiSyncs/queries.tsx +++ b/frontend/src/hooks/api/pkiSyncs/queries.tsx @@ -2,14 +2,25 @@ import { useQuery, UseQueryOptions } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; import { PkiSync, TPkiSyncOption } from "@app/hooks/api/pkiSyncs"; -import { TListPkiSyncOptions, TListPkiSyncs, TPkiSync } from "@app/hooks/api/pkiSyncs/types"; +import { + TListPkiSyncOptions, + TListPkiSyncs, + TPkiSync, + TPkiSyncCertificate +} from "@app/hooks/api/pkiSyncs/types"; export const pkiSyncKeys = { all: ["pki-sync"] as const, options: () => [...pkiSyncKeys.all, "options"] as const, list: (projectId: string) => [...pkiSyncKeys.all, "list", projectId] as const, + listWithCertificate: (projectId: string, certificateId: string) => + [...pkiSyncKeys.all, "list", projectId, "with-certificate", certificateId] as const, byId: (syncId: string, projectId: string) => - [...pkiSyncKeys.all, "by-id", syncId, projectId] as const + [...pkiSyncKeys.all, "by-id", syncId, projectId] as const, + certificates: (syncId: string, pagination?: { offset: number; limit: number }) => + pagination + ? ([...pkiSyncKeys.all, "certificates", syncId, pagination] as const) + : ([...pkiSyncKeys.all, "certificates", syncId] as const) }; export const usePkiSyncOptions = ( @@ -41,9 +52,14 @@ export const usePkiSyncOption = (destination: PkiSync) => { return { syncOption, isPending }; }; -export const fetchPkiSyncsByProjectId = async (projectId: string) => { +export const fetchPkiSyncsByProjectId = async (projectId: string, certificateId?: string) => { + const params: { projectId: string; certificateId?: string } = { projectId }; + if (certificateId) { + params.certificateId = certificateId; + } + const { data } = await apiRequest.get("/api/v1/pki/syncs", { - params: { projectId } + params }); return data.pkiSyncs; @@ -63,6 +79,27 @@ export const useListPkiSyncs = ( }); }; +export const useListPkiSyncsWithCertificate = ( + projectId: string, + certificateId: string, + options?: Omit< + UseQueryOptions< + TPkiSync[], + unknown, + TPkiSync[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: pkiSyncKeys.listWithCertificate(projectId, certificateId), + queryFn: () => fetchPkiSyncsByProjectId(projectId, certificateId), + enabled: !!projectId && !!certificateId, + ...options + }); +}; + export const useGetPkiSync = ( { syncId, projectId }: { syncId: string; projectId: string }, options?: Omit< @@ -82,3 +119,33 @@ export const useGetPkiSync = ( ...options }); }; + +export const useListPkiSyncCertificates = ( + syncId: string, + pagination?: { offset?: number; limit?: number }, + options?: Omit< + UseQueryOptions< + { certificates: TPkiSyncCertificate[]; totalCount: number }, + unknown, + { certificates: TPkiSyncCertificate[]; totalCount: number }, + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + const { offset = 0, limit = 20 } = pagination || {}; + + return useQuery({ + queryKey: pkiSyncKeys.certificates(syncId, { offset, limit }), + queryFn: async () => { + const { data } = await apiRequest.get(`/api/v1/pki/syncs/${syncId}/certificates`, { + params: { offset, limit } + }); + return { + certificates: data.certificates || [], + totalCount: data.totalCount || 0 + }; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/pkiSyncs/types/common.ts b/frontend/src/hooks/api/pkiSyncs/types/common.ts index 8cd1aef96..77dc785ee 100644 --- a/frontend/src/hooks/api/pkiSyncs/types/common.ts +++ b/frontend/src/hooks/api/pkiSyncs/types/common.ts @@ -1,6 +1,6 @@ import { AppConnection } from "@app/hooks/api/appConnections/enums"; -import { PkiSyncStatus } from "../enums"; +import { CertificateSyncStatus, PkiSyncStatus } from "../enums"; export type RootPkiSyncOptions = { canImportCertificates: boolean; @@ -43,4 +43,26 @@ export type TRootPkiSync = { } | null; appConnectionName?: string; appConnectionApp?: string; + hasCertificate?: boolean; +}; + +export type TPkiSyncCertificate = { + id: string; + pkiSyncId: string; + certificateId: string; + syncStatus?: CertificateSyncStatus | null; + lastSyncMessage?: string | null; + lastSyncedAt?: string | null; + createdAt: string; + updatedAt: string; + certificateSerialNumber?: string; + certificateCommonName?: string; + certificateAltNames?: string; + certificateStatus?: string; + certificateNotBefore?: Date; + certificateNotAfter?: Date; + certificateRenewBeforeDays?: number; + certificateRenewalError?: string; + pkiSyncName?: string; + pkiSyncDestination?: string; }; diff --git a/frontend/src/hooks/api/pkiSyncs/types/index.ts b/frontend/src/hooks/api/pkiSyncs/types/index.ts index 899217f52..69ec3ae6f 100644 --- a/frontend/src/hooks/api/pkiSyncs/types/index.ts +++ b/frontend/src/hooks/api/pkiSyncs/types/index.ts @@ -33,7 +33,8 @@ type TCreatePkiSyncDTOBase = { certificateNameSchema?: string; }; isAutoSyncEnabled: boolean; - subscriberId?: string; + subscriberId?: string | null; + certificateIds?: string[]; projectId: string; }; diff --git a/frontend/src/hooks/api/projects/queries.tsx b/frontend/src/hooks/api/projects/queries.tsx index 0445d5984..1613e95a9 100644 --- a/frontend/src/hooks/api/projects/queries.tsx +++ b/frontend/src/hooks/api/projects/queries.tsx @@ -664,17 +664,26 @@ export const useListWorkspaceCas = ({ export const useListWorkspaceCertificates = ({ projectId, offset, - limit + limit, + friendlyName, + commonName, + forPkiSync }: { projectId: string; offset: number; limit: number; + friendlyName?: string; + commonName?: string; + forPkiSync?: boolean; }) => { return useQuery({ queryKey: projectKeys.specificProjectCertificates({ projectId, offset, - limit + limit, + friendlyName, + commonName, + forPkiSync }), queryFn: async () => { const params = new URLSearchParams({ @@ -682,6 +691,16 @@ export const useListWorkspaceCertificates = ({ limit: String(limit) }); + if (friendlyName) { + params.append("friendlyName", friendlyName); + } + if (commonName) { + params.append("commonName", commonName); + } + if (forPkiSync) { + params.append("forPkiSync", "true"); + } + const { data: { certificates, totalCount } } = await apiRequest.get<{ certificates: TCertificate[]; totalCount: number }>( @@ -693,7 +712,8 @@ export const useListWorkspaceCertificates = ({ return { certificates, totalCount }; }, - enabled: Boolean(projectId) + enabled: Boolean(projectId), + placeholderData: (previousData) => previousData }); }; diff --git a/frontend/src/hooks/api/projects/query-keys.tsx b/frontend/src/hooks/api/projects/query-keys.tsx index 51e3db71c..04f14f90d 100644 --- a/frontend/src/hooks/api/projects/query-keys.tsx +++ b/frontend/src/hooks/api/projects/query-keys.tsx @@ -39,12 +39,22 @@ export const projectKeys = { specificProjectCertificates: ({ projectId, offset, - limit + limit, + friendlyName, + commonName, + forPkiSync }: { projectId: string; offset: number; limit: number; - }) => [...projectKeys.forProjectCertificates(projectId), { offset, limit }] as const, + friendlyName?: string; + commonName?: string; + forPkiSync?: boolean; + }) => + [ + ...projectKeys.forProjectCertificates(projectId), + { offset, limit, friendlyName, commonName, forPkiSync } + ] as const, getProjectPkiAlerts: (projectId: string) => [{ projectId }, "project-pki-alerts"] as const, getProjectPkiSubscribers: (projectId: string) => [{ projectId }, "project-pki-subscribers"] as const, diff --git a/frontend/src/hooks/api/secretSyncs/enums.ts b/frontend/src/hooks/api/secretSyncs/enums.ts index 149759d33..be51805c8 100644 --- a/frontend/src/hooks/api/secretSyncs/enums.ts +++ b/frontend/src/hooks/api/secretSyncs/enums.ts @@ -30,7 +30,8 @@ export enum SecretSync { Netlify = "netlify", Northflank = "northflank", Bitbucket = "bitbucket", - LaravelForge = "laravel-forge" + LaravelForge = "laravel-forge", + Chef = "chef" } export enum SecretSyncStatus { diff --git a/frontend/src/hooks/api/secretSyncs/types/chef-sync.ts b/frontend/src/hooks/api/secretSyncs/types/chef-sync.ts new file mode 100644 index 000000000..6bd3d7ca2 --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/chef-sync.ts @@ -0,0 +1,16 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { TRootSecretSync } from "@app/hooks/api/secretSyncs/types/root-sync"; + +export type TChefSync = TRootSecretSync & { + destination: SecretSync.Chef; + destinationConfig: { + dataBagName: string; + dataBagItemName: string; + }; + connection: { + app: AppConnection.Chef; + name: string; + id: string; + }; +}; diff --git a/frontend/src/hooks/api/secretSyncs/types/index.ts b/frontend/src/hooks/api/secretSyncs/types/index.ts index bd2e5af4f..b4088728b 100644 --- a/frontend/src/hooks/api/secretSyncs/types/index.ts +++ b/frontend/src/hooks/api/secretSyncs/types/index.ts @@ -10,6 +10,7 @@ import { TAzureKeyVaultSync } from "./azure-key-vault-sync"; import { TBitbucketSync } from "./bitbucket-sync"; import { TCamundaSync } from "./camunda-sync"; import { TChecklySync } from "./checkly-sync"; +import { TChefSync } from "./chef-sync"; import { TCloudflarePagesSync } from "./cloudflare-pages-sync"; import { TCloudflareWorkersSync } from "./cloudflare-workers-sync"; import { TDatabricksSync } from "./databricks-sync"; @@ -73,7 +74,8 @@ export type TSecretSync = | TNetlifySync | TNorthflankSync | TBitbucketSync - | TLaravelForgeSync; + | TLaravelForgeSync + | TChefSync; export type TListSecretSyncs = { secretSyncs: TSecretSync[] }; diff --git a/frontend/src/layouts/PamLayout/PamLayout.tsx b/frontend/src/layouts/PamLayout/PamLayout.tsx index 5b74703dc..c6e7e067a 100644 --- a/frontend/src/layouts/PamLayout/PamLayout.tsx +++ b/frontend/src/layouts/PamLayout/PamLayout.tsx @@ -19,7 +19,8 @@ export const PamLayout = () => { useEffect(() => { if (subscription && !subscription.pam) { handlePopUpOpen("upgradePlan", { - description: "You can use PAM if you switch to Infisical's Enterprise plan.", + description: + "Your current plan does not provide access to Infisical PAM. To unlock this feature, please upgrade to Infisical Enterprise plan.", isEnterpriseFeature: true }); } diff --git a/frontend/src/layouts/PkiManagerLayout/PkiManagerLayout.tsx b/frontend/src/layouts/PkiManagerLayout/PkiManagerLayout.tsx index 2c088c293..7aeb5e1fd 100644 --- a/frontend/src/layouts/PkiManagerLayout/PkiManagerLayout.tsx +++ b/frontend/src/layouts/PkiManagerLayout/PkiManagerLayout.tsx @@ -52,25 +52,7 @@ export const PkiManagerLayout = () => { projectId: currentProject.id }} > - {({ isActive }) => Policies} - - - {({ isActive }) => ( - - Certificates - - )} + {({ isActive }) => Certificates} { handlePopUpToggle("upgradePlan", isOpen)} - text="You have exceeded the number of projects allowed on the free plan." + text="You’ve reached the maximum number of projects available on the Free plan. Upgrade to the Infisical Pro plan to create more projects." /> { handlePopUpToggle("upgradePlan", isOpen)} - text={`${popUp?.upgradePlan?.data?.message} is only available on Infisical's Pro plan and above.`} + text="Your current plan does not allow removing server admins. To unlock this feature, please upgrade to Infisical Pro plan." /> { if (!subscription.hsm) { handlePopUpOpen("upgradePlan", { isEnterpriseFeature: true, - description: "Hardware Security Module's (HSM's), are only available on Enterprise plans." + text: "Your current plan does not include access to Hardware Security Module (HSM). To unlock this feature, please upgrade to Infisical Enterprise plan." }); return; } @@ -137,7 +137,7 @@ export const EncryptionPageForm = () => { handlePopUpToggle("upgradePlan", isOpen)} - text={(popUp.upgradePlan?.data as { description: string })?.description} + text={popUp.upgradePlan?.data?.text} isEnterpriseFeature={popUp.upgradePlan?.data?.isEnterpriseFeature} /> diff --git a/frontend/src/pages/admin/ResourceOverviewPage/components/UserIdentitiesTable.tsx b/frontend/src/pages/admin/ResourceOverviewPage/components/UserIdentitiesTable.tsx index 4f44a32aa..c343a58eb 100644 --- a/frontend/src/pages/admin/ResourceOverviewPage/components/UserIdentitiesTable.tsx +++ b/frontend/src/pages/admin/ResourceOverviewPage/components/UserIdentitiesTable.tsx @@ -58,9 +58,6 @@ import { import { User } from "@app/hooks/api/users/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; -const addServerAdminUpgradePlanMessage = "Granting another user Server Admin permissions"; -const removeServerAdminUpgradePlanMessage = "Removing Server Admin permissions from user"; - const UserPanelTable = ({ handlePopUpOpen, users, @@ -84,7 +81,7 @@ const UserPanelTable = ({ data?: { username: string; id: string; - message?: string; + text?: string; } ) => void; isPending: boolean; @@ -260,7 +257,7 @@ const UserPanelTable = ({ handlePopUpOpen("upgradePlan", { username, id, - message: addServerAdminUpgradePlanMessage + text: "Your current plan does not allow setting additional server admins. To unlock this feature, please upgrade to Infisical Pro plan." }); return; } @@ -288,7 +285,7 @@ const UserPanelTable = ({ handlePopUpOpen("upgradePlan", { username, id, - message: removeServerAdminUpgradePlanMessage + text: "Your current plan does not allow removing server admins. To unlock this feature, please upgrade to Infisical Pro plan." }); return; } @@ -502,7 +499,7 @@ export const UserIdentitiesTable = () => { handlePopUpToggle("upgradePlan", isOpen)} - text={`${popUp?.upgradePlan?.data?.message} is only available on Infisical's Pro plan and above.`} + text={popUp.upgradePlan.data?.text} /> { "caCert", "installCaCert", "deleteCa", - "caStatus", // enable / disable - "upgradePlan" + "caStatus" // enable / disable ] as const); const onRemoveCaSubmit = async (caName: string) => { @@ -109,11 +107,6 @@ export const CaSection = () => { onUpdateCaStatus(popUp?.caStatus?.data as { caName: string; status: CaStatus }) } /> - handlePopUpToggle("upgradePlan", isOpen)} - text={(popUp.upgradePlan?.data as { description: string })?.description} - />
); }; diff --git a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/CaTable.tsx b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/CaTable.tsx index 09fe39af6..b98475bc9 100644 --- a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/CaTable.tsx +++ b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/CaTable.tsx @@ -34,15 +34,12 @@ import { UsePopUpState } from "@app/hooks/usePopUp"; type Props = { handlePopUpOpen: ( - popUpName: keyof UsePopUpState< - ["installCaCert", "caCert", "ca", "deleteCa", "caStatus", "upgradePlan"] - >, + popUpName: keyof UsePopUpState<["installCaCert", "caCert", "ca", "deleteCa", "caStatus"]>, data?: { caId?: string; caName?: string; dn?: string; status?: CaStatus; - description?: string; } ) => void; }; diff --git a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx index 825368d1c..be757e65b 100644 --- a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx +++ b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx @@ -124,7 +124,7 @@ type Props = { const caTypes = [ { label: "ACME", value: CaType.ACME }, - { label: "Azure AD Certificate Service", value: CaType.AZURE_AD_CS } + { label: "Active Directory Certificate Services (AD CS)", value: CaType.AZURE_AD_CS } ]; export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { diff --git a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaSection.tsx b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaSection.tsx index b40dec6f5..31894b855 100644 --- a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaSection.tsx +++ b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaSection.tsx @@ -1,7 +1,6 @@ import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; import { Button, DeleteActionModal } from "@app/components/v2"; @@ -20,8 +19,7 @@ export const ExternalCaSection = () => { const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ "ca", "deleteCa", - "caStatus", // enable / disable - "upgradePlan" + "caStatus" // enable / disable ] as const); const onRemoveCaSubmit = async (caName: string, type: CaType) => { @@ -116,11 +114,6 @@ export const ExternalCaSection = () => { ) } /> - handlePopUpToggle("upgradePlan", isOpen)} - text={(popUp.upgradePlan?.data as { description: string })?.description} - />
); }; diff --git a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaTable.tsx b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaTable.tsx index 0f3fca6cd..379a091c7 100644 --- a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaTable.tsx +++ b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaTable.tsx @@ -33,12 +33,11 @@ import { UsePopUpState } from "@app/hooks/usePopUp"; type Props = { handlePopUpOpen: ( - popUpName: keyof UsePopUpState<["ca", "deleteCa", "caStatus", "upgradePlan"]>, + popUpName: keyof UsePopUpState<["ca", "deleteCa", "caStatus"]>, data?: { name?: string; type?: CaType; status?: CaStatus; - description?: string; } ) => void; }; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/CertificatesPage.tsx b/frontend/src/pages/cert-manager/CertificatesPage/CertificatesPage.tsx deleted file mode 100644 index 984a3b398..000000000 --- a/frontend/src/pages/cert-manager/CertificatesPage/CertificatesPage.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { Helmet } from "react-helmet"; -import { useTranslation } from "react-i18next"; - -import { ProjectPermissionCan } from "@app/components/permissions"; -import { PageHeader } from "@app/components/v2"; -import { - ProjectPermissionActions, - ProjectPermissionCertificateActions, - ProjectPermissionSub, - useProjectPermission -} from "@app/context"; -import { ProjectType } from "@app/hooks/api/projects/types"; - -import { PkiCollectionSection } from "../AlertingPage/components"; -import { CertificatesSection } from "./components"; - -export const CertificatesPage = () => { - const { t } = useTranslation(); - const { permission } = useProjectPermission(); - - const canAccessPkiColl = permission.can( - ProjectPermissionActions.Read, - ProjectPermissionSub.PkiCollections - ); - const canAccessCerts = permission.can( - ProjectPermissionCertificateActions.Read, - ProjectPermissionSub.Certificates - ); - - return ( -
- - {t("common.head-title", { title: "Certificates" })} - -
- - {/* If both are false, the section does not render. This is to prevent duplicate banners. */} - {(canAccessCerts || canAccessPkiColl) && ( - - - - )} - - - -
-
- ); -}; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx index 537229714..b854bc899 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal.tsx @@ -122,11 +122,12 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId } const { data: profilesData } = useListCertificateProfiles({ projectId: currentProject?.id || "", - includeMetrics: false, enrollmentType: "api" }); - const { mutateAsync: createCertificate } = useCreateCertificateV3(); + const { mutateAsync: createCertificate } = useCreateCertificateV3({ + projectId: currentProject?.id + }); const formResolver = useMemo(() => { return zodResolver(createSchema(shouldShowSubjectSection)); diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateManagePkiSyncsModal.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateManagePkiSyncsModal.tsx new file mode 100644 index 000000000..db3e6c197 --- /dev/null +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateManagePkiSyncsModal.tsx @@ -0,0 +1,296 @@ +import { useEffect, useMemo, useState } from "react"; +import { faPlus, faSearch } from "@fortawesome/free-solid-svg-icons"; +import { useNavigate } from "@tanstack/react-router"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + Checkbox, + EmptyState, + Input, + Modal, + ModalContent, + Pagination, + Table, + TableContainer, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { ROUTE_PATHS } from "@app/const/routes"; +import { useProject } from "@app/context"; +import { + PkiSync, + useAddCertificatesToPkiSync, + useListPkiSyncsWithCertificate, + useRemoveCertificatesFromPkiSync +} from "@app/hooks/api/pkiSyncs"; +import { IntegrationsListPageTabs } from "@app/types/integrations"; + +type Props = { + popUp: { + isOpen: boolean; + data?: { + certificateId?: string; + commonName?: string; + }; + }; + handlePopUpToggle: (popUpName: "managePkiSyncs", state?: boolean) => void; +}; + +const PER_PAGE = 10; + +export const CertificateManagePkiSyncsModal = ({ popUp, handlePopUpToggle }: Props) => { + const [selectedSyncIds, setSelectedSyncIds] = useState>(new Set()); + const [initialSyncIds, setInitialSyncIds] = useState>(new Set()); + const [isSubmitting, setIsSubmitting] = useState(false); + const [currentPage, setCurrentPage] = useState(1); + const [searchTerm, setSearchTerm] = useState(""); + + const { currentProject } = useProject(); + const navigate = useNavigate(); + const { certificateId, commonName } = popUp.data || {}; + + const { data: pkiSyncs = [], isPending } = useListPkiSyncsWithCertificate( + currentProject?.id || "", + certificateId || "", + { + enabled: !!currentProject?.id && !!certificateId + } + ); + const addCertificatesToSync = useAddCertificatesToPkiSync(); + const removeCertificatesFromSync = useRemoveCertificatesFromPkiSync(); + + const filteredSyncs = useMemo(() => { + if (!searchTerm.trim()) return pkiSyncs; + + const searchLower = searchTerm.toLowerCase(); + return pkiSyncs.filter((sync) => sync.name.toLowerCase().includes(searchLower)); + }, [pkiSyncs, searchTerm]); + + const startIndex = (currentPage - 1) * PER_PAGE; + const endIndex = startIndex + PER_PAGE; + const paginatedSyncs = filteredSyncs.slice(startIndex, endIndex); + + useEffect(() => { + setCurrentPage(1); + }, [searchTerm]); + + const handleClose = () => { + handlePopUpToggle("managePkiSyncs", false); + setSelectedSyncIds(new Set()); + setInitialSyncIds(new Set()); + setSearchTerm(""); + setCurrentPage(1); + }; + + const handleNavigateToPkiSyncs = () => { + if (!currentProject?.id) return; + + navigate({ + to: ROUTE_PATHS.CertManager.IntegrationsListPage.path, + params: { + projectId: currentProject.id + }, + search: { + selectedTab: IntegrationsListPageTabs.PkiSyncs + } + }); + handleClose(); + }; + + const getDestinationDisplayName = (destination: string) => { + switch (destination) { + case PkiSync.AzureKeyVault: + return "Azure Key Vault"; + case PkiSync.AwsCertificateManager: + return "AWS Certificate Manager"; + default: + return destination; + } + }; + + useEffect(() => { + if (!certificateId || !pkiSyncs || pkiSyncs.length === 0) return; + + const currentSyncIds = new Set( + pkiSyncs.filter((sync) => sync.hasCertificate).map((sync) => sync.id) + ); + setSelectedSyncIds(currentSyncIds); + setInitialSyncIds(new Set(currentSyncIds)); + }, [certificateId, pkiSyncs]); + + const handleSyncToggle = (syncId: string) => { + setSelectedSyncIds((prev) => { + const newSet = new Set(prev); + if (newSet.has(syncId)) { + newSet.delete(syncId); + } else { + newSet.add(syncId); + } + return newSet; + }); + }; + + const handleSaveChanges = async () => { + if (!certificateId) return; + + try { + setIsSubmitting(true); + + const syncsToAdd = Array.from(selectedSyncIds).filter((id) => !initialSyncIds.has(id)); + const syncsToRemove = Array.from(initialSyncIds).filter((id) => !selectedSyncIds.has(id)); + + await Promise.all( + syncsToAdd.map((syncId) => + addCertificatesToSync.mutateAsync({ + pkiSyncId: syncId, + certificateIds: [certificateId] + }) + ) + ); + + await Promise.all( + syncsToRemove.map((syncId) => + removeCertificatesFromSync.mutateAsync({ + pkiSyncId: syncId, + certificateIds: [certificateId] + }) + ) + ); + + createNotification({ + text: `PKI sync settings updated for certificate "${commonName}"`, + type: "success" + }); + + handleClose(); + } catch (error) { + console.error(error); + createNotification({ + text: "Failed to update PKI sync settings", + type: "error" + }); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + +
+ setSearchTerm(e.target.value)} + placeholder="Search PKI syncs by name..." + /> +
+
+ {isPending && ( +
+
Loading PKI syncs...
+
+ )} + {!isPending && pkiSyncs.length === 0 && ( + +
+ Create a{" "} + {" "} + first to manage certificate syncing. +
+
+ )} + {!isPending && pkiSyncs.length > 0 && filteredSyncs.length === 0 && searchTerm && ( + +
+ No PKI syncs match your search criteria. Try a different search term. +
+
+ )} + {!isPending && filteredSyncs.length > 0 && ( + + + + + + + + + + {paginatedSyncs.map((sync) => ( + handleSyncToggle(sync.id)} + > + + + + + ))} + +
+ NameDestination
+ handleSyncToggle(sync.id)} + id={`sync-${sync.id}`} + /> + +
+ {sync.name} +
+
+
+ {getDestinationDisplayName(sync.destination)} +
+
+
+ )} + {!isPending && filteredSyncs.length > PER_PAGE && ( +
+ {}} + /> +
+ )} +
+ +
+ + +
+
+
+ ); +}; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateTemplatesSection.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateTemplatesSection.tsx index ce55c7e80..0d575d164 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateTemplatesSection.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateTemplatesSection.tsx @@ -97,7 +97,7 @@ export const CertificateTemplatesSection = ({ caId }: Props) => { isOpen={popUp.upgradePlan.isOpen} onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)} isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature} - text="Managing template enrollment options for EST is only available on Infisical's Enterprise plan." + text="Your current plan does not include access to managing template enrollment options for EST. To unlock this feature, please upgrade to Infisical Enterprise plan." />
); diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx index 0252b1238..7eb1fbaff 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificatesSection.tsx @@ -7,8 +7,7 @@ import { Button, DeleteActionModal } from "@app/components/v2"; import { ProjectPermissionCertificateActions, ProjectPermissionSub, - useProject, - useSubscription + useProject } from "@app/context"; import { useDeleteCert } from "@app/hooks/api"; import { usePopUp } from "@app/hooks/usePopUp"; @@ -16,6 +15,7 @@ import { usePopUp } from "@app/hooks/usePopUp"; import { CertificateCertModal } from "./CertificateCertModal"; import { CertificateImportModal } from "./CertificateImportModal"; import { CertificateIssuanceModal } from "./CertificateIssuanceModal"; +import { CertificateManagePkiSyncsModal } from "./CertificateManagePkiSyncsModal"; import { CertificateManageRenewalModal } from "./CertificateManageRenewalModal"; import { CertificateModal } from "./CertificateModal"; import { CertificateRenewalModal } from "./CertificateRenewalModal"; @@ -24,10 +24,10 @@ import { CertificatesTable } from "./CertificatesTable"; export const CertificatesSection = () => { const { currentProject } = useProject(); - const { subscription } = useSubscription(); const { mutateAsync: deleteCert } = useDeleteCert(); - const isLegacyTemplatesEnabled = subscription.pkiLegacyTemplates; + // TODO: Use subscription.pkiLegacyTemplates to block legacy templates creation + const isLegacyTemplatesEnabled = true; const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ "certificateIssuance", @@ -37,7 +37,8 @@ export const CertificatesSection = () => { "deleteCertificate", "revokeCertificate", "manageRenewal", - "renewCertificate" + "renewCertificate", + "managePkiSyncs" ] as const); const onRemoveCertificateSubmit = async (serialNumber: string) => { @@ -97,6 +98,10 @@ export const CertificatesSection = () => { + { return expiryDate <= oneDayFromNow; }; -const getAutoRenewalInfo = (certificate: TCertificate) => { - if (certificate.renewedByCertificateId) { - return { text: "Renewed", variant: "neutral" as const }; - } - - const isRevoked = certificate.status === CertStatus.REVOKED; - const isExpired = new Date(certificate.notAfter) < new Date(); - const hasNoProfile = !certificate.profileId; - const isExpiringWithinDay = isExpiringWithinOneDay(certificate.notAfter); - - if (isRevoked) { - return { - text: "Not Available", - variant: "neutral" as const, - tooltip: "Renewal is not available for revoked certificates" - }; - } - - if (isExpired) { - return { - text: "Not Available", - variant: "neutral" as const, - tooltip: "Renewal is not available for expired certificates" - }; - } - - if (hasNoProfile) { - return { - text: "Not Available", - variant: "neutral" as const, - tooltip: "Renewal requires a certificate profile" - }; - } - - if (certificate.hasPrivateKey === false) { - return { - text: "Not Available", - variant: "neutral" as const, - tooltip: "Renewal is not available for certificates with externally generated private keys" - }; - } - - if (isExpiringWithinDay) { - return { - text: "Not Available", - variant: "neutral" as const, - tooltip: "Auto-renewal is not available for certificates expiring within 24 hours" - }; - } - - if (certificate.renewalError) { - return { - text: "Failed", - variant: "danger" as const, - tooltip: certificate.renewalError - }; - } - - if (!certificate.renewBeforeDays) { - return { text: "Auto-Renewal Disabled", variant: "warning" as const }; - } - - const notAfterDate = new Date(certificate.notAfter); - const renewalDate = new Date( - notAfterDate.getTime() - certificate.renewBeforeDays * 24 * 60 * 60 * 1000 - ); - const now = new Date(); - - if (renewalDate <= now) { - return { text: "Due Now", variant: "danger" as const }; - } - - const daysUntilRenewal = Math.floor( - (renewalDate.getTime() - now.getTime()) / (24 * 60 * 60 * 1000) - ); - - if (daysUntilRenewal === 0) { - return { text: "Renews today", variant: "warning" as const }; - } - - if (daysUntilRenewal <= 7) { - return { text: `Renews in ${daysUntilRenewal}d`, variant: "warning" as const }; - } - - return { text: `Renews in ${daysUntilRenewal}d`, variant: "success" as const }; -}; - type Props = { handlePopUpOpen: ( popUpName: keyof UsePopUpState< @@ -152,7 +69,8 @@ type Props = { "revokeCertificate", "certificateCert", "manageRenewal", - "renewCertificate" + "renewCertificate", + "managePkiSyncs" ] >, data?: { @@ -175,7 +93,6 @@ const PER_PAGE_INIT = 25; export const CertificatesTable = ({ handlePopUpOpen }: Props) => { const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(PER_PAGE_INIT); - const { subscription } = useSubscription(); const { currentProject } = useProject(); const { data, isPending } = useListWorkspaceCertificates({ @@ -185,7 +102,8 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => { }); const { mutateAsync: updateRenewalConfig } = useUpdateRenewalConfig(); - const isLegacyTemplatesEnabled = subscription.pkiLegacyTemplates; + // TODO: Use subscription.pkiLegacyTemplates to block legacy templates creation + const isLegacyTemplatesEnabled = true; const { data: caData } = useListCasByProjectId(currentProject?.id ?? ""); @@ -225,20 +143,18 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => { - - - - - - + + + + - {isPending && } + {isPending && } {!isPending && data?.certificates.map((certificate) => { const { variant, label } = getCertValidUntilBadgeDetails(certificate.notAfter); - const autoRenewalInfo = getAutoRenewalInfo(certificate); const isRevoked = certificate.status === CertStatus.REVOKED; const isExpired = new Date(certificate.notAfter) < new Date(); @@ -247,9 +163,24 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => { const isAutoRenewalEnabled = Boolean( certificate.renewBeforeDays && certificate.renewBeforeDays > 0 ); + + const canShowAutoRenewalIcon = Boolean( + certificate.profileId && + certificate.hasPrivateKey !== false && + !certificate.renewedByCertificateId && + !isRevoked && + !isExpired && + !isExpiringWithinDay + ); + + // Still need originalDisplayName for other uses in the component + const { originalDisplayName } = getCertificateDisplayName(certificate, 64, "—"); + return ( - - + + - - - {subscriberId ? ( - - ) : ( - - )} - - -
Common NameStatusNot BeforeNot AfterRenewal Status + SAN / CNStatusNot BeforeNot After
{certificate.commonName}
+ + {certificate.status === CertStatus.REVOKED ? ( Revoked @@ -267,22 +198,64 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => { ? format(new Date(certificate.notAfter), "yyyy-MM-dd") : "-"} - {autoRenewalInfo && - (autoRenewalInfo.tooltip ? ( -
- - - {autoRenewalInfo.text} - - - -
- ) : ( - {autoRenewalInfo.text} - ))} -
+ +
{ + if (!canShowAutoRenewalIcon) return ""; + if (isAutoRenewalEnabled) return "opacity-100"; + return "opacity-0 group-hover:opacity-100"; + })()}`} + > + {canShowAutoRenewalIcon && ( + { + if (hasFailed && certificate.renewalError) { + return `Auto-renewal failed: ${certificate.renewalError}`; + } + if (isAutoRenewalEnabled) { + const expiryDate = new Date(certificate.notAfter); + const now = new Date(); + const daysUntilExpiry = Math.ceil( + (expiryDate.getTime() - now.getTime()) / (24 * 60 * 60 * 1000) + ); + const daysUntilRenewal = Math.max( + 0, + daysUntilExpiry - (certificate.renewBeforeDays || 0) + ); + return `Auto-renews in ${daysUntilRenewal}d`; + } + return "Set auto renewal"; + })()} + > + + + )} +
@@ -476,6 +449,33 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => { ); })()} + {/* PKI Sync management - only for active certificates that are not renewed */} + {certificate.status === CertStatus.ACTIVE && + !certificate.renewedByCertificateId && ( + + {(isAllowed) => ( + + handlePopUpOpen("managePkiSyncs", { + certificateId: certificate.id, + commonName: certificate.commonName + }) + } + disabled={!isAllowed} + icon={} + > + Manage PKI Syncs + + )} + + )} {/* Only show revoke button if CA supports revocation */} {(() => { const caType = caCapabilityMap[certificate.caId]; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/route.tsx b/frontend/src/pages/cert-manager/CertificatesPage/route.tsx deleted file mode 100644 index 68deb41b9..000000000 --- a/frontend/src/pages/cert-manager/CertificatesPage/route.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; - -import { CertificatesPage } from "./CertificatesPage"; - -export const Route = createFileRoute( - "/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/certificates" -)({ - component: CertificatesPage, - beforeLoad: ({ context }) => { - return { - breadcrumbs: [ - ...context.breadcrumbs, - { - label: "Certificates" - } - ] - }; - } -}); diff --git a/frontend/src/pages/cert-manager/IntegrationsListPage/components/PkiSyncsTab/PkiSyncTable/PkiSyncRow.tsx b/frontend/src/pages/cert-manager/IntegrationsListPage/components/PkiSyncsTab/PkiSyncTable/PkiSyncRow.tsx index 1c5bff98c..0b2ce65c1 100644 --- a/frontend/src/pages/cert-manager/IntegrationsListPage/components/PkiSyncsTab/PkiSyncTable/PkiSyncRow.tsx +++ b/frontend/src/pages/cert-manager/IntegrationsListPage/components/PkiSyncsTab/PkiSyncTable/PkiSyncRow.tsx @@ -12,7 +12,6 @@ import { faToggleOff, faToggleOn, faTrash, - faTriangleExclamation, faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -47,7 +46,6 @@ import { useToggle } from "@app/hooks"; import { PkiSyncStatus, TPkiSync, usePkiSyncOption } from "@app/hooks/api/pkiSyncs"; import { PkiSyncDestinationCol } from "./PkiSyncDestinationCol"; -import { PkiSyncTableCell } from "./PkiSyncTableCell"; type Props = { pkiSync: TPkiSync; @@ -163,23 +161,6 @@ export const PkiSyncRow = ({

{destinationDetails.name}

- -
- - - Source Deleted - -
-
-
diff --git a/frontend/src/pages/cert-manager/IntegrationsListPage/components/PkiSyncsTab/PkiSyncTable/PkiSyncsTable.tsx b/frontend/src/pages/cert-manager/IntegrationsListPage/components/PkiSyncsTab/PkiSyncTable/PkiSyncsTable.tsx index f997c350d..68e7cf294 100644 --- a/frontend/src/pages/cert-manager/IntegrationsListPage/components/PkiSyncsTab/PkiSyncTable/PkiSyncsTable.tsx +++ b/frontend/src/pages/cert-manager/IntegrationsListPage/components/PkiSyncsTab/PkiSyncTable/PkiSyncsTable.tsx @@ -57,7 +57,6 @@ import { PkiSyncRow } from "./PkiSyncRow"; enum PkiSyncsOrderBy { Destination = "destination", - Source = "source", Name = "name", Status = "status" } @@ -160,14 +159,6 @@ export const PkiSyncsTable = ({ pkiSyncs }: Props) => { const [syncOne, syncTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a]; switch (orderBy) { - case PkiSyncsOrderBy.Source: - return (syncOne.subscriber?.name ?? syncOne.subscriberId ?? "") - .toLowerCase() - .localeCompare( - syncTwo.subscriber?.name?.toLowerCase() ?? - syncTwo.subscriberId?.toLowerCase() ?? - "" - ); case PkiSyncsOrderBy.Destination: return getPkiSyncDestinationColValues(syncOne) .primaryText.toLowerCase() @@ -356,7 +347,7 @@ export const PkiSyncsTable = ({ pkiSyncs }: Props) => {
- +
Name {
-
- Source - handleSort(PkiSyncsOrderBy.Source)} - > - - -
-
+
Destination {
+
Status { const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["addSync"] as const); - const { addSync, ...search } = useSearch({ + const { addSync, connectionId, connectionName, ...search } = useSearch({ from: ROUTE_PATHS.CertManager.IntegrationsListPage.id }); @@ -45,6 +46,42 @@ export const PkiSyncsTab = () => { navigateToBase(); }, [addSync, handlePopUpOpen, navigateToBase]); + useEffect(() => { + const storedFormData = localStorage.getItem("pkiSyncFormData"); + if (storedFormData && !popUp.addSync.isOpen) { + try { + const parsedData = JSON.parse(storedFormData); + if (connectionId && connectionName) { + const initialData = { + ...parsedData, + connection: { id: connectionId, name: connectionName } + }; + handlePopUpOpen("addSync", { destination: parsedData.destination, initialData }); + navigate({ + to: ROUTE_PATHS.CertManager.IntegrationsListPage.path, + params: { projectId: currentProject?.id }, + search: { selectedTab: IntegrationsListPageTabs.PkiSyncs }, + replace: true + }); + } else { + handlePopUpOpen("addSync", { destination: parsedData.destination }); + } + localStorage.removeItem("pkiSyncFormData"); + } catch (error) { + console.error("Failed to parse stored PKI sync form data:", error); + localStorage.removeItem("pkiSyncFormData"); + handlePopUpOpen("addSync"); + } + } + }, [ + handlePopUpOpen, + popUp.addSync.isOpen, + connectionId, + connectionName, + navigate, + currentProject?.id + ]); + const { data: pkiSyncs = [], isPending: isPkiSyncsPending } = useListPkiSyncs( currentProject?.id || "", { @@ -94,7 +131,8 @@ export const PkiSyncsTab = () => {
handlePopUpToggle("addSync", isOpen)} /> diff --git a/frontend/src/pages/cert-manager/IntegrationsListPage/route.tsx b/frontend/src/pages/cert-manager/IntegrationsListPage/route.tsx index 0a0868a07..a996a23b5 100644 --- a/frontend/src/pages/cert-manager/IntegrationsListPage/route.tsx +++ b/frontend/src/pages/cert-manager/IntegrationsListPage/route.tsx @@ -9,7 +9,9 @@ import { IntegrationsListPage } from "./IntegrationsListPage"; const IntegrationsListPageQuerySchema = z.object({ selectedTab: z.nativeEnum(IntegrationsListPageTabs).optional(), - addSync: z.nativeEnum(PkiSync).optional() + addSync: z.nativeEnum(PkiSync).optional(), + connectionId: z.string().optional(), + connectionName: z.string().optional() }); export const Route = createFileRoute( diff --git a/frontend/src/pages/cert-manager/PkiCollectionDetailsByIDPage/PkiCollectionDetailsByIDPage.tsx b/frontend/src/pages/cert-manager/PkiCollectionDetailsByIDPage/PkiCollectionDetailsByIDPage.tsx index 064f8f7a2..319323545 100644 --- a/frontend/src/pages/cert-manager/PkiCollectionDetailsByIDPage/PkiCollectionDetailsByIDPage.tsx +++ b/frontend/src/pages/cert-manager/PkiCollectionDetailsByIDPage/PkiCollectionDetailsByIDPage.tsx @@ -58,9 +58,9 @@ export const PkiCollectionPage = () => { }); handlePopUpClose("deletePkiCollection"); navigate({ - to: "/projects/cert-management/$projectId/certificates", + to: "/projects/cert-management/$projectId/policies", params: { - projectId + projectId: params.projectId } }); }; @@ -70,9 +70,9 @@ export const PkiCollectionPage = () => { {data && (
diff --git a/frontend/src/pages/cert-manager/PkiCollectionDetailsByIDPage/components/AddPkiCollectionItemModal.tsx b/frontend/src/pages/cert-manager/PkiCollectionDetailsByIDPage/components/AddPkiCollectionItemModal.tsx index 3bcc3dc31..1fd4d7d99 100644 --- a/frontend/src/pages/cert-manager/PkiCollectionDetailsByIDPage/components/AddPkiCollectionItemModal.tsx +++ b/frontend/src/pages/cert-manager/PkiCollectionDetailsByIDPage/components/AddPkiCollectionItemModal.tsx @@ -5,13 +5,9 @@ import { z } from "zod"; import { createNotification } from "@app/components/notifications"; import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2"; import { useProject } from "@app/context"; -import { - CaStatus, - useAddItemToPkiCollection, - useListWorkspaceCas, - useListWorkspaceCertificates -} from "@app/hooks/api"; +import { CaStatus, useAddItemToPkiCollection, useListWorkspaceCas } from "@app/hooks/api"; import { PkiItemType, pkiItemTypeToNameMap } from "@app/hooks/api/pkiCollections/constants"; +import { useListWorkspaceCertificates } from "@app/hooks/api/projects"; import { UsePopUpState } from "@app/hooks/usePopUp"; const schema = z diff --git a/frontend/src/pages/cert-manager/PkiCollectionDetailsByIDPage/routes.tsx b/frontend/src/pages/cert-manager/PkiCollectionDetailsByIDPage/routes.tsx index 7fef38221..712f3dedf 100644 --- a/frontend/src/pages/cert-manager/PkiCollectionDetailsByIDPage/routes.tsx +++ b/frontend/src/pages/cert-manager/PkiCollectionDetailsByIDPage/routes.tsx @@ -13,7 +13,7 @@ export const Route = createFileRoute( { label: "Certificate Collections", link: linkOptions({ - to: "/projects/cert-management/$projectId/certificates", + to: "/projects/cert-management/$projectId/policies", params: { projectId: params.projectId } diff --git a/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberModal.tsx b/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberModal.tsx index 15c016587..27c91851c 100644 --- a/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberModal.tsx +++ b/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberModal.tsx @@ -350,8 +350,14 @@ export const PkiSubscriberModal = ({ popUp, handlePopUpToggle }: Props) => { commonName, subjectAlternativeNames: subjectAlternativeNamesList, ttl, - keyUsages: keyUsagesList, - extendedKeyUsages: extendedKeyUsagesList, + keyUsages: keyUsagesList.map((key) => + key === CertKeyUsage.CRL_SIGN + ? "cRLSign" + : key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()) + ), + extendedKeyUsages: extendedKeyUsagesList.map((key) => + key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()) + ), enableAutoRenewal, autoRenewalPeriodInDays, properties: Object.keys(properties).length > 0 ? properties : undefined @@ -364,8 +370,14 @@ export const PkiSubscriberModal = ({ popUp, handlePopUpToggle }: Props) => { commonName, subjectAlternativeNames: subjectAlternativeNamesList, ttl, - keyUsages: keyUsagesList, - extendedKeyUsages: extendedKeyUsagesList, + keyUsages: keyUsagesList.map((key) => + key === CertKeyUsage.CRL_SIGN + ? "cRLSign" + : key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()) + ), + extendedKeyUsages: extendedKeyUsagesList.map((key) => + key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()) + ), enableAutoRenewal, autoRenewalPeriodInDays, properties: Object.keys(properties).length > 0 ? properties : undefined diff --git a/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberSection.tsx b/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberSection.tsx index e902711ce..ae09cde2e 100644 --- a/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberSection.tsx +++ b/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberSection.tsx @@ -7,8 +7,7 @@ import { Button, DeleteActionModal } from "@app/components/v2"; import { ProjectPermissionPkiSubscriberActions, ProjectPermissionSub, - useProject, - useSubscription + useProject } from "@app/context"; import { useDeletePkiSubscriber, useUpdatePkiSubscriber } from "@app/hooks/api"; import { PkiSubscriberStatus } from "@app/hooks/api/pkiSubscriber/types"; @@ -19,10 +18,10 @@ import { PkiSubscribersTable } from "./PkiSubscribersTable"; export const PkiSubscriberSection = () => { const { currentProject } = useProject(); - const { subscription } = useSubscription(); const projectId = currentProject.id; - const canCreateLegacySubscribers = subscription.pkiLegacyTemplates; + // TODO: Use subscription.pkiLegacyTemplates to block legacy templates creation + const canCreateLegacySubscribers = true; const { mutateAsync: deletePkiSubscriber } = useDeletePkiSubscriber(); const { mutateAsync: updatePkiSubscriber } = useUpdatePkiSubscriber(); diff --git a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/PkiSyncDetailsByIDPage.tsx b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/PkiSyncDetailsByIDPage.tsx index 76cc0ec7b..b1b1b2836 100644 --- a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/PkiSyncDetailsByIDPage.tsx +++ b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/PkiSyncDetailsByIDPage.tsx @@ -18,10 +18,10 @@ import { IntegrationsListPageTabs } from "@app/types/integrations"; import { PkiSyncActionTriggers, PkiSyncAuditLogsSection, + PkiSyncCertificatesSection, PkiSyncDestinationSection, PkiSyncDetailsSection, - PkiSyncOptionsSection, - PkiSyncSourceSection + PkiSyncOptionsSection } from "./components"; const PageContent = () => { @@ -62,7 +62,6 @@ const PageContent = () => { const destinationDetails = PKI_SYNC_MAP[pkiSync.destination]; const handleEditDetails = () => handlePopUpOpen("editSync", PkiSyncEditFields.Details); - const handleEditSource = () => handlePopUpOpen("editSync", PkiSyncEditFields.Source); const handleEditOptions = () => handlePopUpOpen("editSync", PkiSyncEditFields.Options); const handleEditDestination = () => handlePopUpOpen("editSync", PkiSyncEditFields.Destination); @@ -103,7 +102,6 @@ const PageContent = () => {
-
@@ -111,6 +109,7 @@ const PageContent = () => { pkiSync={pkiSync} onEditDestination={handleEditDestination} /> +
diff --git a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncAuditLogsSection.tsx b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncAuditLogsSection.tsx index 0f9f4a90c..6ef633091 100644 --- a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncAuditLogsSection.tsx +++ b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncAuditLogsSection.tsx @@ -26,7 +26,7 @@ export const PkiSyncAuditLogsSection = ({ pkiSync }: Props) => { return (
-

Sync Logs

+

Sync Logs

{subscription.auditLogs && (

Displaying audit logs from the last {Math.min(auditLogsRetentionDays, 60)} days diff --git a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncCertificatesSection.tsx b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncCertificatesSection.tsx new file mode 100644 index 000000000..aafff85cd --- /dev/null +++ b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncCertificatesSection.tsx @@ -0,0 +1,328 @@ +import { useState } from "react"; +import { subject } from "@casl/ability"; +import { + faCertificate, + faClockRotateLeft, + faEdit, + faTrash +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { ProjectPermissionCan } from "@app/components/permissions"; +import { CertificateManagementModal } from "@app/components/pki-syncs/CertificateManagementModal"; +import { + CertificateDisplayName, + getCertificateDisplayName +} from "@app/components/utilities/certificateDisplayUtils"; +import { + DeleteActionModal, + EmptyState, + IconButton, + Pagination, + Table, + TableContainer, + TBody, + Td, + Th, + THead, + Tooltip, + Tr +} from "@app/components/v2"; +import { Badge } from "@app/components/v3"; +import { ProjectPermissionSub } from "@app/context"; +import { ProjectPermissionPkiSyncActions } from "@app/context/ProjectPermissionContext/types"; +import { useListPkiSyncCertificates, useRemoveCertificatesFromPkiSync } from "@app/hooks/api"; +import { CertificateSyncStatus, TPkiSync } from "@app/hooks/api/pkiSyncs"; + +type Props = { + pkiSync: TPkiSync; +}; + +const getSyncStatusVariant = (status?: CertificateSyncStatus | null) => { + if (status === CertificateSyncStatus.Succeeded) return "success"; + if (status === CertificateSyncStatus.Failed) return "danger"; + if (status === CertificateSyncStatus.Syncing) return "neutral"; + return "project"; +}; + +const getSyncStatusText = (status?: CertificateSyncStatus | null) => { + if (status === CertificateSyncStatus.Succeeded) return "Synced"; + if (status === CertificateSyncStatus.Failed) return "Failed"; + if (status === CertificateSyncStatus.Syncing) return "Syncing"; + if (status === CertificateSyncStatus.Pending) return "Pending"; + return "Unknown"; +}; + +const getCertificateStatusVariant = (isExpired: boolean, isRevoked: boolean) => { + if (isRevoked) return "danger"; + if (isExpired) return "danger"; + return "success"; +}; + +const getCertificateStatusText = (isExpired: boolean, isRevoked: boolean) => { + if (isRevoked) return "Revoked"; + if (isExpired) return "Expired"; + return "Active"; +}; + +export const PkiSyncCertificatesSection = ({ pkiSync }: Props) => { + const [isManageModalOpen, setIsManageModalOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [certificateToDelete, setCertificateToDelete] = useState<{ + id: string; + displayName: string; + } | null>(null); + const [currentPage, setCurrentPage] = useState(1); + const pageSize = 10; + + const { data, refetch: refetchSyncCertificates } = useListPkiSyncCertificates(pkiSync.id, { + offset: (currentPage - 1) * pageSize, + limit: pageSize + }); + const syncCertificates = data?.certificates || []; + const totalCount = data?.totalCount || 0; + const removeCertificatesFromSync = useRemoveCertificatesFromPkiSync(); + + const permissionSubject = subject(ProjectPermissionSub.PkiSyncs, { + subscriberId: pkiSync.subscriberId || "" + }); + + const handleRemoveCertificate = async (certificateId: string) => { + try { + await removeCertificatesFromSync.mutateAsync({ + pkiSyncId: pkiSync.id, + certificateIds: [certificateId] + }); + + await refetchSyncCertificates(); + + createNotification({ + text: "Certificate removed from sync", + type: "success" + }); + + setIsDeleteModalOpen(false); + setCertificateToDelete(null); + } catch { + createNotification({ + text: "Failed to remove certificate from sync", + type: "error" + }); + } + }; + + const handleDeleteClick = (certificateId: string, displayName: string) => { + setCertificateToDelete({ id: certificateId, displayName }); + setIsDeleteModalOpen(true); + }; + + const totalPages = Math.ceil(totalCount / pageSize); + + return ( +

+
+
+

Certificates

+ + {(isAllowed) => ( + setIsManageModalOpen(true)} + > + + + )} + +
+ +
+
+ + + + + + + + + + + + + {syncCertificates.map((syncCert) => { + const isExpired = syncCert.certificateNotAfter + ? new Date(syncCert.certificateNotAfter) < new Date() + : false; + const isRevoked = syncCert.certificateStatus === "revoked"; + + // Calculate auto-renewal timeline + const hasAutoRenewal = Boolean( + syncCert.certificateRenewBeforeDays && + syncCert.certificateRenewBeforeDays > 0 && + !syncCert.certificateRenewalError && + syncCert.certificateNotAfter + ); + + const daysUntilRenewal = + hasAutoRenewal && syncCert.certificateNotAfter + ? (() => { + const expiryDate = new Date(syncCert.certificateNotAfter); + const renewalDate = new Date( + expiryDate.getTime() - + syncCert.certificateRenewBeforeDays! * 24 * 60 * 60 * 1000 + ); + const now = new Date(); + const diffInMs = renewalDate.getTime() - now.getTime(); + return Math.max(0, Math.ceil(diffInMs / (24 * 60 * 60 * 1000))); + })() + : null; + + const { originalDisplayName } = getCertificateDisplayName( + { + altNames: syncCert.certificateAltNames, + commonName: syncCert.certificateCommonName + }, + 34, + "Unknown" + ); + + return ( + + + + + + + + + ); + })} + +
SAN / CNCertificate StatusSerial NumberSync StatusExpires At +
+ + + + {getCertificateStatusText(isExpired, isRevoked)} + + +
+ {(() => { + const serial = syncCert.certificateSerialNumber; + if (!serial || serial === "Unknown") return "Unknown"; + if (serial.length <= 8) return serial; + return `${serial.substring(0, 4)}...${serial.substring(serial.length - 4)}`; + })()} +
+
+ {syncCert.lastSyncMessage && + syncCert.syncStatus === CertificateSyncStatus.Failed ? ( + + Failed + + ) : ( + + {getSyncStatusText(syncCert.syncStatus)} + + )} + + + {syncCert.certificateNotAfter + ? new Date(syncCert.certificateNotAfter).toLocaleDateString() + : "Unknown"} + + + {hasAutoRenewal && daysUntilRenewal !== null && ( + +
+ +
+
+ )} + + {(isAllowed) => ( + + handleDeleteClick(syncCert.certificateId, originalDisplayName) + } + > + + + )} + +
+ {syncCertificates.length === 0 && ( + + )} +
+ {/* Pagination */} + {totalPages > 1 && ( +
+ setCurrentPage(page)} + onChangePerPage={() => {}} + /> +
+ )} +
+
+
+ + setIsManageModalOpen(false)} + onCertificatesUpdated={() => { + refetchSyncCertificates(); + }} + /> + + { + setIsDeleteModalOpen(false); + setCertificateToDelete(null); + }} + title="Remove Certificate from Sync" + subTitle={`Are you sure you want to remove "${certificateToDelete?.displayName}" from this PKI sync?`} + deleteKey="confirm" + onDeleteApproved={async () => { + if (certificateToDelete) { + await handleRemoveCertificate(certificateToDelete.id); + } + }} + buttonText="Remove Certificate" + /> +
+ ); +}; diff --git a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncDestinationSection.tsx b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncDestinationSection.tsx index af16fbffd..1c6bb8a04 100644 --- a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncDestinationSection.tsx +++ b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncDestinationSection.tsx @@ -11,12 +11,15 @@ import { ProjectPermissionPkiSyncActions } from "@app/context/ProjectPermissionC import { PKI_SYNC_MAP } from "@app/helpers/pkiSyncs"; import { PkiSync, TPkiSync } from "@app/hooks/api/pkiSyncs"; -import { AzureKeyVaultPkiSyncDestinationSection } from "./PkiSyncDestinationSection/index"; +import { + AwsCertificateManagerPkiSyncDestinationSection, + AzureKeyVaultPkiSyncDestinationSection +} from "./PkiSyncDestinationSection/index"; const GenericFieldLabel = ({ label, children }: { label: string; children: React.ReactNode }) => ( -
- -
{children}
+
+

{label}

+
{children}
); @@ -32,6 +35,9 @@ export const PkiSyncDestinationSection = ({ pkiSync, onEditDestination }: Props) let DestinationComponents: ReactNode; switch (destination) { + case PkiSync.AwsCertificateManager: + DestinationComponents = ; + break; case PkiSync.AzureKeyVault: DestinationComponents = ; break; @@ -47,7 +53,7 @@ export const PkiSyncDestinationSection = ({ pkiSync, onEditDestination }: Props) return (
-

Destination Configuration

+

Destination Configuration

{(isAllowed) => (
-
+
{pkiSync.appConnectionName || "Default Connection"} diff --git a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncDestinationSection/AwsCertificateManagerPkiSyncDestinationSection.tsx b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncDestinationSection/AwsCertificateManagerPkiSyncDestinationSection.tsx new file mode 100644 index 000000000..88ffbbb93 --- /dev/null +++ b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncDestinationSection/AwsCertificateManagerPkiSyncDestinationSection.tsx @@ -0,0 +1,21 @@ +import { TPkiSync } from "@app/hooks/api/pkiSyncs"; + +const GenericFieldLabel = ({ label, children }: { label: string; children: React.ReactNode }) => ( +
+

{label}

+
{children}
+
+); + +type Props = { + pkiSync: TPkiSync; +}; + +export const AwsCertificateManagerPkiSyncDestinationSection = ({ pkiSync }: Props) => { + const region = + pkiSync.destinationConfig && "region" in pkiSync.destinationConfig + ? pkiSync.destinationConfig.region + : undefined; + + return {region || "Not specified"}; +}; diff --git a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncDestinationSection/AzureKeyVaultPkiSyncDestinationSection.tsx b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncDestinationSection/AzureKeyVaultPkiSyncDestinationSection.tsx index 7c1cc8b14..b9bc7462c 100644 --- a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncDestinationSection/AzureKeyVaultPkiSyncDestinationSection.tsx +++ b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncDestinationSection/AzureKeyVaultPkiSyncDestinationSection.tsx @@ -2,9 +2,9 @@ import { TAzureKeyVaultPkiSync } from "@app/hooks/api/pkiSyncs/types/azure-key-vault-sync"; const GenericFieldLabel = ({ label, children }: { label: string; children: React.ReactNode }) => ( -
- -
{children}
+
+

{label}

+
{children}
); diff --git a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncDestinationSection/index.ts b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncDestinationSection/index.ts index 5c8823388..4a1728f42 100644 --- a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncDestinationSection/index.ts +++ b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncDestinationSection/index.ts @@ -1 +1,2 @@ +export { AwsCertificateManagerPkiSyncDestinationSection } from "./AwsCertificateManagerPkiSyncDestinationSection"; export { AzureKeyVaultPkiSyncDestinationSection } from "./AzureKeyVaultPkiSyncDestinationSection"; diff --git a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncDetailsSection.tsx b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncDetailsSection.tsx index 7514bab50..a3d95d5c3 100644 --- a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncDetailsSection.tsx +++ b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncDetailsSection.tsx @@ -21,9 +21,9 @@ const GenericFieldLabel = ({ children: React.ReactNode; labelClassName?: string; }) => ( -
- -
{children}
+
+

{label}

+
{children}
); @@ -57,7 +57,7 @@ export const PkiSyncDetailsSection = ({ pkiSync, onEditDetails }: Props) => { return (
-

Details

+

Details

{(isAllowed) => ( { )}
-
-
- {name} - {description || "None"} - - {subscriber ? subscriber.name : "Subscriber deleted"} +
+ {name} + {description || "None"} + {subscriber && ( + {subscriber.name} + )} + {syncStatus && ( + + - {syncStatus && ( - - - - )} - {lastSyncedAt && ( - - {format(new Date(lastSyncedAt), "yyyy-MM-dd, h:mm aaa")} - - )} - {syncStatus === PkiSyncStatus.Failed && failureMessage && ( - -

- {failureMessage} -

-
- )} -
+ )} + {lastSyncedAt && ( + + {format(new Date(lastSyncedAt), "yyyy-MM-dd, h:mm aaa")} + + )} + {syncStatus === PkiSyncStatus.Failed && failureMessage && ( + +

{failureMessage}

+
+ )}
); diff --git a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncOptionsSection/PkiSyncOptionsSection.tsx b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncOptionsSection/PkiSyncOptionsSection.tsx index b6365f1cd..e9a509ebb 100644 --- a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncOptionsSection/PkiSyncOptionsSection.tsx +++ b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/PkiSyncOptionsSection/PkiSyncOptionsSection.tsx @@ -3,13 +3,27 @@ import { faEdit } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { ProjectPermissionCan } from "@app/components/permissions"; -import { GenericFieldLabel } from "@app/components/secret-syncs"; import { IconButton } from "@app/components/v2"; import { Badge } from "@app/components/v3"; import { ProjectPermissionSub } from "@app/context"; import { ProjectPermissionPkiSyncActions } from "@app/context/ProjectPermissionContext/types"; import { TPkiSync } from "@app/hooks/api/pkiSyncs"; +const GenericFieldLabel = ({ + label, + children, + labelClassName +}: { + label: string; + children: React.ReactNode; + labelClassName?: string; +}) => ( +
+

{label}

+
{children}
+
+); + type Props = { pkiSync: TPkiSync; onEditOptions: VoidFunction; @@ -28,7 +42,7 @@ export const PkiSyncOptionsSection = ({ pkiSync, onEditOptions }: Props) => {
-

Sync Options

+

Sync Options

{(isAllowed) => ( { )}
-
-
- {/* Hidden for now - Import certificates functionality disabled +
+ {/* Hidden for now - Import certificates functionality disabled {canImportCertificates ? "Enabled" : "Disabled"} */} - - - {canRemoveCertificates ? "Enabled" : "Disabled"} - - -
+ + + {canRemoveCertificates ? "Enabled" : "Disabled"} + +
diff --git a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/index.ts b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/index.ts index 06fe5181e..55a877bff 100644 --- a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/index.ts +++ b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/components/index.ts @@ -1,5 +1,6 @@ export { PkiSyncActionTriggers } from "./PkiSyncActionTriggers"; export { PkiSyncAuditLogsSection } from "./PkiSyncAuditLogsSection"; +export { PkiSyncCertificatesSection } from "./PkiSyncCertificatesSection"; export { PkiSyncDestinationSection } from "./PkiSyncDestinationSection"; export { PkiSyncDetailsSection } from "./PkiSyncDetailsSection"; export { PkiSyncOptionsSection } from "./PkiSyncOptionsSection"; diff --git a/frontend/src/pages/cert-manager/PkiTemplateListPage/PkiTemplateListPage.tsx b/frontend/src/pages/cert-manager/PkiTemplateListPage/PkiTemplateListPage.tsx index 9ad290062..41574ce5e 100644 --- a/frontend/src/pages/cert-manager/PkiTemplateListPage/PkiTemplateListPage.tsx +++ b/frontend/src/pages/cert-manager/PkiTemplateListPage/PkiTemplateListPage.tsx @@ -106,30 +106,29 @@ export const PkiTemplateListPage = () => { />
- {subscription?.pkiLegacyTemplates && ( -
-

Templates

-
- - {(isAllowed) => ( - - )} - -
+ {/* TODO: Use subscription.pkiLegacyTemplates to block legacy templates creation */} +
+

Templates

+
+ + {(isAllowed) => ( + + )} +
- )} +
@@ -289,7 +288,7 @@ export const PkiTemplateListPage = () => { handlePopUpToggle("estUpgradePlan", isOpen)} - text="You can only configure template enrollment methods if you switch to Infisical's Enterprise plan." + text="Your current plan does not include access to configuring template enrollment methods. To unlock this feature, please upgrade to Infisical Enterprise plan." isEnterpriseFeature={popUp.estUpgradePlan.data?.isEnterpriseFeature} /> diff --git a/frontend/src/pages/cert-manager/PkiTemplateListPage/components/PkiTemplateForm.tsx b/frontend/src/pages/cert-manager/PkiTemplateListPage/components/PkiTemplateForm.tsx index 00aa192a6..d0decf3f6 100644 --- a/frontend/src/pages/cert-manager/PkiTemplateListPage/components/PkiTemplateForm.tsx +++ b/frontend/src/pages/cert-manager/PkiTemplateListPage/components/PkiTemplateForm.tsx @@ -139,10 +139,14 @@ export const PkiTemplateForm = ({ certTemplate, handlePopUpToggle }: Props) => { ttl, keyUsages: Object.entries(keyUsages) .filter(([, value]) => value) - .map(([key]) => key as CertKeyUsage), + .map(([key]) => + key === CertKeyUsage.CRL_SIGN + ? "cRLSign" + : key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()) + ), extendedKeyUsages: Object.entries(extendedKeyUsages) .filter(([, value]) => value) - .map(([key]) => key as CertExtendedKeyUsage) + .map(([key]) => key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase())) }); createNotification({ @@ -159,10 +163,14 @@ export const PkiTemplateForm = ({ certTemplate, handlePopUpToggle }: Props) => { ttl, keyUsages: Object.entries(keyUsages) .filter(([, value]) => value) - .map(([key]) => key as CertKeyUsage), + .map(([key]) => + key === CertKeyUsage.CRL_SIGN + ? "cRLSign" + : key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()) + ), extendedKeyUsages: Object.entries(extendedKeyUsages) .filter(([, value]) => value) - .map(([key]) => key as CertExtendedKeyUsage) + .map(([key]) => key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase())) }); createNotification({ diff --git a/frontend/src/pages/cert-manager/PoliciesPage/PoliciesPage.tsx b/frontend/src/pages/cert-manager/PoliciesPage/PoliciesPage.tsx index f6bc792fe..5b2988775 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/PoliciesPage.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/PoliciesPage.tsx @@ -2,17 +2,20 @@ import { useState } from "react"; import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; -import { ProjectPermissionCan } from "@app/components/permissions"; import { ContentLoader, PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub, useProject } from "@app/context"; +import { useProject } from "@app/context"; import { ProjectType } from "@app/hooks/api/projects/types"; import { CertificateProfilesTab } from "./components/CertificateProfilesTab"; +import { CertificatesTab } from "./components/CertificatesTab"; import { CertificateTemplatesV2Tab } from "./components/CertificateTemplatesV2Tab"; +import { PkiCollectionsTab } from "./components/PkiCollectionsTab"; enum TabSections { CertificateProfiles = "profiles", - CertificateTemplatesV2 = "templates-v2" + CertificateTemplatesV2 = "templates-v2", + Certificates = "certificates", + PkiCollections = "pki-collections" } export const PoliciesPage = () => { @@ -25,59 +28,54 @@ export const PoliciesPage = () => { } return ( - - {(isAllowed) => { - if (!isAllowed) { - return ( -
-
-

You don't have permission to access certificate policies.

-
-
- ); - } +
+ + {t("common.head-title", { title: "Certificate Management" })} + +
+ - return ( -
- - {t("common.head-title", { title: "Certificate Policies" })} - -
- + setActiveTab(value as TabSections)} + > + + + Certificate Profiles + + + Certificate Templates + + + Certificates + + + Certificate Collections + + - setActiveTab(value as TabSections)} - > - - - Certificate Profiles - - - Certificate Templates - - + + + - - - + + + - - - - -
-
- ); - }} - + + + + + + + + +
+
); }; diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx index 56e72ca2b..9f13acb63 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx @@ -410,7 +410,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" } name="enrollmentType" render={({ field: { onChange, ...field }, fieldState: { error } }) => ( { projectId: currentProject?.id || "", limit: 100, offset: 0, - includeConfigs: true, - includeMetrics: true + includeConfigs: true }); const profiles = data?.certificateProfiles || []; @@ -42,10 +41,9 @@ export const ProfileList = ({ onEditProfile, onDeleteProfile }: Props) => {
- + - @@ -67,10 +65,9 @@ export const ProfileList = ({ onEditProfile, onDeleteProfile }: Props) => { - + - diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx index ab7eb549e..e3bdea3c4 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx @@ -33,43 +33,6 @@ import { TCertificateProfile } from "@app/hooks/api/certificateProfiles"; import { useGetCertificateTemplateV2ById } from "@app/hooks/api/certificateTemplates/queries"; import { CertificateIssuanceModal } from "@app/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal"; -const MetricsBadges = ({ - metrics -}: { - metrics?: { - totalCertificates: number; - activeCertificates: number; - expiringCertificates: number; - expiredCertificates: number; - revokedCertificates: number; - }; -}) => { - if (!metrics) { - return No metrics; - } - - if (metrics.totalCertificates === 0) { - return No certificates; - } - - return ( - <> - {metrics.activeCertificates > 0 && ( - {metrics.activeCertificates} active - )} - {metrics.expiringCertificates > 0 && ( - {metrics.expiringCertificates} expiring - )} - {metrics.expiredCertificates > 0 && ( - {metrics.expiredCertificates} expired - )} - {metrics.revokedCertificates > 0 && ( - {metrics.revokedCertificates} revoked - )} - - ); -}; - interface Props { profile: TCertificateProfile; onEditProfile: (profile: TCertificateProfile) => void; @@ -118,8 +81,8 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) = const getEnrollmentTypeBadge = (enrollmentType: string) => { const config = { - api: { variant: "success" as const, label: "API" }, - est: { variant: "warning" as const, label: "EST" } + api: { variant: "ghost" as const, label: "API" }, + est: { variant: "ghost" as const, label: "EST" } } as const; const configKey = Object.keys(config).includes(enrollmentType) @@ -153,11 +116,6 @@ export const ProfileRow = ({ profile, onEditProfile, onDeleteProfile }: Props) = {templateData?.name || profile.certificateTemplateId} -
NameEnrollment TypeEnrollment Method Issuing CA Certificate TemplateCertificates
NameEnrollment TypeEnrollment Method Issuing CA Certificate TemplateCertificates
-
- -
-
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificatesTab/CertificatesTab.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificatesTab/CertificatesTab.tsx new file mode 100644 index 000000000..caf9cdb2f --- /dev/null +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificatesTab/CertificatesTab.tsx @@ -0,0 +1,5 @@ +import { CertificatesSection } from "../../../CertificatesPage/components/CertificatesSection"; + +export const CertificatesTab = () => { + return ; +}; diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificatesTab/index.ts b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificatesTab/index.ts new file mode 100644 index 000000000..277134d56 --- /dev/null +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificatesTab/index.ts @@ -0,0 +1 @@ +export { CertificatesTab } from "./CertificatesTab"; diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/PkiCollectionsTab/PkiCollectionsTab.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/PkiCollectionsTab/PkiCollectionsTab.tsx new file mode 100644 index 000000000..29bd82559 --- /dev/null +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/PkiCollectionsTab/PkiCollectionsTab.tsx @@ -0,0 +1,5 @@ +import { PkiCollectionSection } from "../../../AlertingPage/components/PkiCollectionSection"; + +export const PkiCollectionsTab = () => { + return ; +}; diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/PkiCollectionsTab/index.ts b/frontend/src/pages/cert-manager/PoliciesPage/components/PkiCollectionsTab/index.ts new file mode 100644 index 000000000..4297b3d5e --- /dev/null +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/PkiCollectionsTab/index.ts @@ -0,0 +1 @@ +export { PkiCollectionsTab } from "./PkiCollectionsTab"; diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/index.ts b/frontend/src/pages/cert-manager/PoliciesPage/components/index.ts index 62809f571..a9b24b1db 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/index.ts +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/index.ts @@ -1,2 +1,4 @@ export { CertificateProfilesTab } from "./CertificateProfilesTab"; +export { CertificatesTab } from "./CertificatesTab"; export { CertificateTemplatesV2Tab } from "./CertificateTemplatesV2Tab"; +export { PkiCollectionsTab } from "./PkiCollectionsTab"; diff --git a/frontend/src/pages/kms/KmipPage/components/KmipClientTable.tsx b/frontend/src/pages/kms/KmipPage/components/KmipClientTable.tsx index 474ad158a..e3cdafbc2 100644 --- a/frontend/src/pages/kms/KmipPage/components/KmipClientTable.tsx +++ b/frontend/src/pages/kms/KmipPage/components/KmipClientTable.tsx @@ -344,7 +344,7 @@ export const KmipClientTable = () => { handlePopUpToggle("upgradePlan", isOpen)} - text="KMIP requires an enterprise plan." + text="Your current plan does not include access to KMIP. To unlock this feature, please upgrade to Infisical Enterprise plan." isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature} /> diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsSection.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsSection.tsx index 1cf5407d1..c9a0a23f4 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsSection.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsSection.tsx @@ -27,8 +27,7 @@ export const OrgGroupsSection = () => { const handleAddGroupModal = () => { if (!subscription?.groups) { handlePopUpOpen("upgradePlan", { - description: - "You can manage users more efficiently with groups if you upgrade your Infisical plan to an Enterprise license.", + text: "Your current plan does not allow adding groups. To unlock this feature, please upgrade to Infisical Enterprise plan.", isEnterpriseFeature: true }); } else { @@ -90,7 +89,7 @@ export const OrgGroupsSection = () => { isOpen={popUp.upgradePlan.isOpen} onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)} isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature} - text={(popUp.upgradePlan?.data as { description: string })?.description} + text={popUp.upgradePlan?.data?.text} /> ); diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAliCloudAuthForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAliCloudAuthForm.tsx index 7dc5d7f70..94c9c49a3 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAliCloudAuthForm.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAliCloudAuthForm.tsx @@ -55,7 +55,10 @@ const schema = z export type FormData = z.infer; type Props = { - handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["upgradePlan"]>, + data?: { featureName?: string } + ) => void; handlePopUpToggle: ( popUpName: keyof UsePopUpState<["identityAuthMethod"]>, state?: boolean @@ -270,7 +273,9 @@ export const IdentityAliCloudAuthForm = ({ return; } - handlePopUpOpen("upgradePlan"); + handlePopUpOpen("upgradePlan", { + featureName: "IP allowlisting" + }); }} placeholder="123.456.789.0" /> @@ -285,7 +290,9 @@ export const IdentityAliCloudAuthForm = ({ return; } - handlePopUpOpen("upgradePlan"); + handlePopUpOpen("upgradePlan", { + featureName: "IP allowlisting" + }); }} size="lg" colorSchema="danger" @@ -308,7 +315,9 @@ export const IdentityAliCloudAuthForm = ({ return; } - handlePopUpOpen("upgradePlan"); + handlePopUpOpen("upgradePlan", { + featureName: "IP allowlisting" + }); }} leftIcon={} size="xs" diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModalContent.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModalContent.tsx index 752ab1a34..1bfb904a5 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModalContent.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAuthMethodModalContent.tsx @@ -292,7 +292,7 @@ export const IdentityAuthMethodModalContent = ({ handlePopUpToggle("upgradePlan", isOpen)} - text={`You can use ${popUp.upgradePlan.data?.featureName ?? "IP allowlisting"} if you switch to Infisical's ${popUp.upgradePlan.data?.isEnterpriseFeature ? "Enterprise" : "Pro"} plan.`} + text={`Your current plan does not include access to ${popUp.upgradePlan.data?.featureName}. To unlock this feature, please upgrade to Infisical ${popUp.upgradePlan.data?.isEnterpriseFeature ? "Enterprise" : "Pro"} plan.`} isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature} /> diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAwsAuthForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAwsAuthForm.tsx index db1066731..a05dcf8df 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAwsAuthForm.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAwsAuthForm.tsx @@ -57,7 +57,10 @@ const schema = z export type FormData = z.infer; type Props = { - handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["upgradePlan"]>, + data?: { featureName?: string } + ) => void; handlePopUpToggle: ( popUpName: keyof UsePopUpState<["identityAuthMethod"]>, state?: boolean @@ -308,7 +311,9 @@ export const IdentityAwsAuthForm = ({ return; } - handlePopUpOpen("upgradePlan"); + handlePopUpOpen("upgradePlan", { + featureName: "IP allowlisting" + }); }} placeholder="123.456.789.0" /> @@ -323,7 +328,9 @@ export const IdentityAwsAuthForm = ({ return; } - handlePopUpOpen("upgradePlan"); + handlePopUpOpen("upgradePlan", { + featureName: "IP allowlisting" + }); }} size="lg" colorSchema="danger" @@ -346,7 +353,9 @@ export const IdentityAwsAuthForm = ({ return; } - handlePopUpOpen("upgradePlan"); + handlePopUpOpen("upgradePlan", { + featureName: "IP allowlisting" + }); }} leftIcon={} size="xs" diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAzureAuthForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAzureAuthForm.tsx index 86f22c27e..ada799d13 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAzureAuthForm.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityAzureAuthForm.tsx @@ -52,7 +52,10 @@ const schema = z export type FormData = z.infer; type Props = { - handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["upgradePlan"]>, + data?: { featureName?: string } + ) => void; handlePopUpToggle: ( popUpName: keyof UsePopUpState<["identityAuthMethod"]>, state?: boolean @@ -304,7 +307,9 @@ export const IdentityAzureAuthForm = ({ return; } - handlePopUpOpen("upgradePlan"); + handlePopUpOpen("upgradePlan", { + featureName: "IP allowlisting" + }); }} placeholder="123.456.789.0" /> @@ -319,7 +324,9 @@ export const IdentityAzureAuthForm = ({ return; } - handlePopUpOpen("upgradePlan"); + handlePopUpOpen("upgradePlan", { + featureName: "IP allowlisting" + }); }} size="lg" colorSchema="danger" @@ -342,7 +349,9 @@ export const IdentityAzureAuthForm = ({ return; } - handlePopUpOpen("upgradePlan"); + handlePopUpOpen("upgradePlan", { + featureName: "IP allowlisting" + }); }} leftIcon={} size="xs" diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityGcpAuthForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityGcpAuthForm.tsx index 4d6ea63b2..960d4b561 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityGcpAuthForm.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityGcpAuthForm.tsx @@ -55,7 +55,10 @@ const schema = z export type FormData = z.infer; type Props = { - handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["upgradePlan"]>, + data?: { featureName?: string } + ) => void; handlePopUpToggle: ( popUpName: keyof UsePopUpState<["identityAuthMethod"]>, state?: boolean @@ -343,7 +346,9 @@ export const IdentityGcpAuthForm = ({ return; } - handlePopUpOpen("upgradePlan"); + handlePopUpOpen("upgradePlan", { + featureName: "IP allowlisting" + }); }} placeholder="123.456.789.0" /> @@ -358,7 +363,9 @@ export const IdentityGcpAuthForm = ({ return; } - handlePopUpOpen("upgradePlan"); + handlePopUpOpen("upgradePlan", { + featureName: "IP allowlisting" + }); }} size="lg" colorSchema="danger" @@ -381,7 +388,9 @@ export const IdentityGcpAuthForm = ({ return; } - handlePopUpOpen("upgradePlan"); + handlePopUpOpen("upgradePlan", { + featureName: "IP allowlisting" + }); }} leftIcon={} size="xs" diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityJwtAuthForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityJwtAuthForm.tsx index dfc079790..10eab486d 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityJwtAuthForm.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityJwtAuthForm.tsx @@ -88,7 +88,10 @@ const schema = z.discriminatedUnion("configurationType", [ export type FormData = z.infer; type Props = { - handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["upgradePlan"]>, + data?: { featureName?: string } + ) => void; handlePopUpToggle: ( popUpName: keyof UsePopUpState<["identityAuthMethod"]>, state?: boolean @@ -612,7 +615,9 @@ export const IdentityJwtAuthForm = ({ return; } - handlePopUpOpen("upgradePlan"); + handlePopUpOpen("upgradePlan", { + featureName: "IP allowlisting" + }); }} placeholder="123.456.789.0" /> @@ -627,7 +632,9 @@ export const IdentityJwtAuthForm = ({ return; } - handlePopUpOpen("upgradePlan"); + handlePopUpOpen("upgradePlan", { + featureName: "IP allowlisting" + }); }} size="lg" colorSchema="danger" @@ -650,7 +657,9 @@ export const IdentityJwtAuthForm = ({ return; } - handlePopUpOpen("upgradePlan"); + handlePopUpOpen("upgradePlan", { + featureName: "IP allowlisting" + }); }} leftIcon={} size="xs" diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityKubernetesAuthForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityKubernetesAuthForm.tsx index 7fedf4a99..c4ae8bac0 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityKubernetesAuthForm.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityKubernetesAuthForm.tsx @@ -96,7 +96,10 @@ const schema = z export type FormData = z.infer; type Props = { - handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["upgradePlan"]>, + data?: { featureName?: string } + ) => void; handlePopUpToggle: ( popUpName: keyof UsePopUpState<["identityAuthMethod"]>, state?: boolean @@ -700,7 +703,9 @@ export const IdentityKubernetesAuthForm = ({ return; } - handlePopUpOpen("upgradePlan"); + handlePopUpOpen("upgradePlan", { + featureName: "IP allowlisting" + }); }} placeholder="123.456.789.0" /> @@ -715,7 +720,9 @@ export const IdentityKubernetesAuthForm = ({ return; } - handlePopUpOpen("upgradePlan"); + handlePopUpOpen("upgradePlan", { + featureName: "IP allowlisting" + }); }} size="lg" colorSchema="danger" @@ -738,7 +745,9 @@ export const IdentityKubernetesAuthForm = ({ return; } - handlePopUpOpen("upgradePlan"); + handlePopUpOpen("upgradePlan", { + featureName: "IP allowlisting" + }); }} leftIcon={} size="xs" diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityLdapAuthForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityLdapAuthForm.tsx index 44b3a32de..a3ab70de0 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityLdapAuthForm.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityLdapAuthForm.tsx @@ -830,7 +830,9 @@ export const IdentityLdapAuthForm = ({ return; } - handlePopUpOpen("upgradePlan"); + handlePopUpOpen("upgradePlan", { + featureName: "IP allowlisting" + }); }} placeholder="123.456.789.0" /> @@ -845,7 +847,9 @@ export const IdentityLdapAuthForm = ({ return; } - handlePopUpOpen("upgradePlan"); + handlePopUpOpen("upgradePlan", { + featureName: "IP allowlisting" + }); }} size="lg" colorSchema="danger" @@ -868,7 +872,9 @@ export const IdentityLdapAuthForm = ({ return; } - handlePopUpOpen("upgradePlan"); + handlePopUpOpen("upgradePlan", { + featureName: "IP allowlisting" + }); }} leftIcon={} size="xs" diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityOciAuthForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityOciAuthForm.tsx index ffbde6f7a..3ebfceb4a 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityOciAuthForm.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityOciAuthForm.tsx @@ -63,7 +63,10 @@ const schema = z export type FormData = z.infer; type Props = { - handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["upgradePlan"]>, + data?: { featureName?: string } + ) => void; handlePopUpToggle: ( popUpName: keyof UsePopUpState<["identityAuthMethod"]>, state?: boolean @@ -290,7 +293,9 @@ export const IdentityOciAuthForm = ({ return; } - handlePopUpOpen("upgradePlan"); + handlePopUpOpen("upgradePlan", { + featureName: "IP allowlisting" + }); }} placeholder="123.456.789.0" /> @@ -305,7 +310,9 @@ export const IdentityOciAuthForm = ({ return; } - handlePopUpOpen("upgradePlan"); + handlePopUpOpen("upgradePlan", { + featureName: "IP allowlisting" + }); }} size="lg" colorSchema="danger" @@ -328,7 +335,9 @@ export const IdentityOciAuthForm = ({ return; } - handlePopUpOpen("upgradePlan"); + handlePopUpOpen("upgradePlan", { + featureName: "IP allowlisting" + }); }} leftIcon={} size="xs" diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityOidcAuthForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityOidcAuthForm.tsx index 0ada2e663..7503e6f45 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityOidcAuthForm.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityOidcAuthForm.tsx @@ -75,7 +75,10 @@ const schema = z.object({ export type FormData = z.infer; type Props = { - handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["upgradePlan"]>, + data?: { featureName?: string } + ) => void; handlePopUpToggle: ( popUpName: keyof UsePopUpState<["identityAuthMethod"]>, state?: boolean @@ -599,7 +602,9 @@ export const IdentityOidcAuthForm = ({ return; } - handlePopUpOpen("upgradePlan"); + handlePopUpOpen("upgradePlan", { + featureName: "IP allowlisting" + }); }} placeholder="123.456.789.0" /> @@ -614,7 +619,9 @@ export const IdentityOidcAuthForm = ({ return; } - handlePopUpOpen("upgradePlan"); + handlePopUpOpen("upgradePlan", { + featureName: "IP allowlisting" + }); }} size="lg" colorSchema="danger" @@ -637,7 +644,9 @@ export const IdentityOidcAuthForm = ({ return; } - handlePopUpOpen("upgradePlan"); + handlePopUpOpen("upgradePlan", { + featureName: "IP allowlisting" + }); }} leftIcon={} size="xs" diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentitySection.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentitySection.tsx index b7ebfcdc4..8380836cb 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentitySection.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentitySection.tsx @@ -123,8 +123,7 @@ export const IdentitySection = withPermission( onClick={() => { if (!isMoreIdentitiesAllowed && !isEnterprise) { handlePopUpOpen("upgradePlan", { - description: - "You can add more identities if you upgrade your Infisical Pro plan." + text: "You have reached the maximum number of identities allowed on your current plan. Upgrade to Infisical Pro plan to add more identities." }); return; } @@ -159,8 +158,7 @@ export const IdentitySection = withPermission( if (subscription && !subscription.machineIdentityAuthTemplates) { handlePopUpOpen("upgradePlan", { isEnterpriseFeature: true, - description: - "You can use Identity Auth Templates if you switch to Infisical's Enterprise plan." + text: "Your current plan does not include access to creating Identity Auth Templates. To unlock this feature, please upgrade to Infisical Enterprise plan." }); return; } @@ -231,7 +229,7 @@ export const IdentitySection = withPermission( handlePopUpToggle("upgradePlan", isOpen)} - text={popUp.upgradePlan.data?.description} + text={popUp.upgradePlan.data?.text} isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature} /> diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTlsCertAuthForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTlsCertAuthForm.tsx index 2a2ca45c3..5666be39f 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTlsCertAuthForm.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTlsCertAuthForm.tsx @@ -50,7 +50,10 @@ const schema = z.object({ export type FormData = z.infer; type Props = { - handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["upgradePlan"]>, + data?: { featureName?: string } + ) => void; handlePopUpToggle: ( popUpName: keyof UsePopUpState<["identityAuthMethod"]>, state?: boolean @@ -279,7 +282,9 @@ export const IdentityTlsCertAuthForm = ({ return; } - handlePopUpOpen("upgradePlan"); + handlePopUpOpen("upgradePlan", { + featureName: "IP allowlisting" + }); }} placeholder="123.456.789.0" /> @@ -294,7 +299,9 @@ export const IdentityTlsCertAuthForm = ({ return; } - handlePopUpOpen("upgradePlan"); + handlePopUpOpen("upgradePlan", { + featureName: "IP allowlisting" + }); }} size="lg" colorSchema="danger" @@ -317,7 +324,9 @@ export const IdentityTlsCertAuthForm = ({ return; } - handlePopUpOpen("upgradePlan"); + handlePopUpOpen("upgradePlan", { + featureName: "IP allowlisting" + }); }} leftIcon={} size="xs" diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTokenAuthForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTokenAuthForm.tsx index 8da4bad17..af2404c5c 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTokenAuthForm.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTokenAuthForm.tsx @@ -49,7 +49,10 @@ const schema = z export type FormData = z.infer; type Props = { - handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["upgradePlan"]>, + data?: { featureName?: string } + ) => void; handlePopUpToggle: ( popUpName: keyof UsePopUpState<["identityAuthMethod"]>, state?: boolean @@ -241,7 +244,9 @@ export const IdentityTokenAuthForm = ({ return; } - handlePopUpOpen("upgradePlan"); + handlePopUpOpen("upgradePlan", { + featureName: "IP allowlisting" + }); }} placeholder="123.456.789.0" /> @@ -256,7 +261,9 @@ export const IdentityTokenAuthForm = ({ return; } - handlePopUpOpen("upgradePlan"); + handlePopUpOpen("upgradePlan", { + featureName: "IP allowlisting" + }); }} size="lg" colorSchema="danger" @@ -279,7 +286,9 @@ export const IdentityTokenAuthForm = ({ return; } - handlePopUpOpen("upgradePlan"); + handlePopUpOpen("upgradePlan", { + featureName: "IP allowlisting" + }); }} leftIcon={} size="xs" diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityUniversalAuthForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityUniversalAuthForm.tsx index dd85f5342..d4de4bd0e 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityUniversalAuthForm.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityUniversalAuthForm.tsx @@ -87,7 +87,10 @@ const schema = z export type FormData = z.infer; type Props = { - handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void; + handlePopUpOpen: ( + popUpName: keyof UsePopUpState<["upgradePlan"]>, + data?: { featureName?: string } + ) => void; handlePopUpToggle: ( popUpName: keyof UsePopUpState<["identityAuthMethod"]>, state?: boolean @@ -400,7 +403,9 @@ export const IdentityUniversalAuthForm = ({ return; } - handlePopUpOpen("upgradePlan"); + handlePopUpOpen("upgradePlan", { + featureName: "IP allowlisting" + }); }} placeholder="123.456.789.0" /> @@ -415,7 +420,9 @@ export const IdentityUniversalAuthForm = ({ return; } - handlePopUpOpen("upgradePlan"); + handlePopUpOpen("upgradePlan", { + featureName: "IP allowlisting" + }); }} size="lg" colorSchema="danger" @@ -438,7 +445,9 @@ export const IdentityUniversalAuthForm = ({ return; } - handlePopUpOpen("upgradePlan"); + handlePopUpOpen("upgradePlan", { + featureName: "IP allowlisting" + }); }} leftIcon={} size="xs" @@ -468,7 +477,9 @@ export const IdentityUniversalAuthForm = ({ return; } - handlePopUpOpen("upgradePlan"); + handlePopUpOpen("upgradePlan", { + featureName: "IP allowlisting" + }); }} placeholder="123.456.789.0" /> @@ -483,7 +494,9 @@ export const IdentityUniversalAuthForm = ({ return; } - handlePopUpOpen("upgradePlan"); + handlePopUpOpen("upgradePlan", { + featureName: "IP allowlisting" + }); }} size="lg" colorSchema="danger" @@ -506,7 +519,9 @@ export const IdentityUniversalAuthForm = ({ return; } - handlePopUpOpen("upgradePlan"); + handlePopUpOpen("upgradePlan", { + featureName: "IP allowlisting" + }); }} leftIcon={} size="xs" diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx index dc033f3fa..0b71dea25 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx @@ -76,7 +76,7 @@ export const OrgMembersSection = () => { if (!isMoreIdentitiesAllowed && !isEnterprise) { handlePopUpOpen("upgradePlan", { - description: "You can add more members if you switch to Infisical's Pro plan." + text: "You have reached the maximum number of members allowed on your current plan. Upgrade to Infisical Pro plan to add more members." }); return; } @@ -300,7 +300,7 @@ export const OrgMembersSection = () => { handlePopUpToggle("upgradePlan", isOpen)} - text={(popUp.upgradePlan?.data as { description: string })?.description} + text={popUp.upgradePlan?.data?.text} /> void; @@ -132,7 +132,7 @@ export const OrgMembersTable = ({ if (isCustomRole && subscription && !subscription?.rbac) { handlePopUpOpen("upgradePlan", { - description: "You can assign custom roles to members if you switch to Infisical's Pro plan." + text: "Your current plan does not include access to assigning custom roles to members. To unlock this feature, please upgrade to Infisical Pro plan." }); return; } diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgRoleTabSection/OrgRoleTable.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgRoleTabSection/OrgRoleTable.tsx index 36249967a..aa14ac130 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgRoleTabSection/OrgRoleTable.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgRoleTabSection/OrgRoleTable.tsx @@ -99,8 +99,7 @@ export const OrgRoleTable = () => { if (isCustomRole && subscription && !subscription?.rbac) { handlePopUpOpen("upgradePlan", { - description: - "You can set the default org role to a custom role if you switch to Infisical's Pro plan." + text: "Your current plan does not include access to set a custom default organization role. To unlock this feature, please upgrade to Infisical Pro plan." }); return; } @@ -461,7 +460,7 @@ export const OrgRoleTable = () => { handlePopUpToggle("upgradePlan", isOpen)} - text={(popUp.upgradePlan?.data as { description: string })?.description} + text={popUp.upgradePlan?.data?.text} /> { return ; case AppConnection.Checkly: return ; + case AppConnection.Chef: + return ; case AppConnection.Supabase: return ; case AppConnection.DigitalOcean: @@ -311,6 +314,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { return ; case AppConnection.Checkly: return ; + case AppConnection.Chef: + return ; case AppConnection.Supabase: return ; case AppConnection.DigitalOcean: diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/ChefConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/ChefConnectionForm.tsx new file mode 100644 index 000000000..862b4e2bd --- /dev/null +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/ChefConnectionForm.tsx @@ -0,0 +1,195 @@ +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { + Button, + FormControl, + Input, + ModalClose, + SecretInput, + Select, + SelectItem +} from "@app/components/v2"; +import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; +import { ChefConnectionMethod, TChefConnection } from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; + +import { + genericAppConnectionFieldsSchema, + GenericAppConnectionsFields +} from "./GenericAppConnectionFields"; + +type Props = { + appConnection?: TChefConnection; + onSubmit: (formData: FormData) => Promise; +}; + +const rootSchema = genericAppConnectionFieldsSchema.extend({ + app: z.literal(AppConnection.Chef) +}); + +const formSchema = z.discriminatedUnion("method", [ + rootSchema.extend({ + method: z.literal(ChefConnectionMethod.UserKey), + credentials: z.object({ + serverUrl: z.string().trim().url("Valid Chef Server URL required").optional(), + orgName: z.string().trim().min(1, "Organization name required"), + userName: z.string().trim().min(1, "User name required"), + privateKey: z.string().trim().min(1, "Private key required") + }) + }) +]); + +type FormData = z.infer; + +export const ChefConnectionForm = ({ appConnection, onSubmit }: Props) => { + const isUpdate = Boolean(appConnection); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: appConnection ?? { + app: AppConnection.Chef, + method: ChefConnectionMethod.UserKey + } + }); + + const { + handleSubmit, + control, + formState: { isSubmitting, isDirty } + } = form; + + return ( + +
+ {!isUpdate && } + ( + + onChange(e.target.value)} + /> + + )} + /> + ( + + + + )} + /> + + ( + + onChange(e.target.value)} + /> + + )} + /> + ( + + onChange(e.target.value)} + /> + + )} + /> + ( + + onChange(e.target.value)} + /> + + )} + /> +
+ + + + +
+ +
+ ); +}; diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionList.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionList.tsx index 9b8fdddf1..bdea10d7a 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionList.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionList.tsx @@ -169,7 +169,7 @@ export const AppConnectionsSelect = ({ onSelect, projectType }: Props) => { handlePopUpToggle("upgradePlan", isOpen)} - text="You can use every App Connection if you switch to Infisical's Enterprise plan." + text="All App Connections can be unlocked if you switch to Infisical Enterprise plan." isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature} /> diff --git a/frontend/src/pages/organization/AppConnections/OauthCallbackPage/OauthCallbackPage.tsx b/frontend/src/pages/organization/AppConnections/OauthCallbackPage/OauthCallbackPage.tsx index 96a474714..6575a0780 100644 --- a/frontend/src/pages/organization/AppConnections/OauthCallbackPage/OauthCallbackPage.tsx +++ b/frontend/src/pages/organization/AppConnections/OauthCallbackPage/OauthCallbackPage.tsx @@ -593,7 +593,9 @@ export const OAuthCallbackPage = () => { connectionName: data.connection.name, ...(data.returnUrl.includes("integrations") ? { - selectedTab: IntegrationsListPageTabs.SecretSyncs + selectedTab: localStorage.getItem("pkiSyncFormData") + ? IntegrationsListPageTabs.PkiSyncs + : IntegrationsListPageTabs.SecretSyncs } : {}) } diff --git a/frontend/src/pages/organization/AuditLogsPage/components/LogsFilter.tsx b/frontend/src/pages/organization/AuditLogsPage/components/LogsFilter.tsx index 72561b90d..8e0124fb5 100644 --- a/frontend/src/pages/organization/AuditLogsPage/components/LogsFilter.tsx +++ b/frontend/src/pages/organization/AuditLogsPage/components/LogsFilter.tsx @@ -165,7 +165,7 @@ export const LogsFilter = ({ presets, setFilter, filter, project }: Props) => { { - resetField("eventType"); + setValue("eventType", [], { shouldDirty: true }); }} > { { - resetField("userAgentType"); + setValue("userAgentType", undefined, { shouldDirty: true }); }} > { { - resetField("project"); - resetField("environment"); - setValue("secretPath", ""); - setValue("secretKey", ""); + setValue("project", null, { shouldDirty: true }); + setValue("environment", undefined, { shouldDirty: true }); + setValue("secretPath", "", { shouldDirty: true }); + setValue("secretKey", "", { shouldDirty: true }); }} > { } className={twMerge(!selectedProject && "opacity-50")} onClear={() => { - resetField("environment"); + setValue("environment", undefined, { shouldDirty: true }); }} > { } className={twMerge(!selectedProject && "opacity-50")} onClear={() => { - setValue("secretPath", ""); + setValue("secretPath", "", { shouldDirty: true }); }} > { className={twMerge(!selectedProject && "opacity-50")} label="Secret Key" onClear={() => { - setValue("secretKey", ""); + setValue("secretKey", "", { shouldDirty: true }); }} > { handlePopUpToggle("upgradePlan", isOpen); }} - text="You can use audit logs if you switch to Infisical's Pro plan." + text="Your current plan does not include access to audit logs. To unlock this feature, please upgrade to Infisical Pro plan." /> @@ -167,7 +167,7 @@ const LogsSectionComponent = ({ onOpenChange={(isOpen) => { handlePopUpToggle("upgradePlan", isOpen); }} - text="You can use audit logs if you switch to Infisical's Pro plan." + text="Your current plan does not include access to audit logs. To unlock this feature, please upgrade to Infisical Pro plan." /> ); diff --git a/frontend/src/pages/organization/GroupDetailsByIDPage/GroupDetailsByIDPage.tsx b/frontend/src/pages/organization/GroupDetailsByIDPage/GroupDetailsByIDPage.tsx index 57c7b5a7d..e6d3736fb 100644 --- a/frontend/src/pages/organization/GroupDetailsByIDPage/GroupDetailsByIDPage.tsx +++ b/frontend/src/pages/organization/GroupDetailsByIDPage/GroupDetailsByIDPage.tsx @@ -5,7 +5,6 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { Link, useNavigate, useParams } from "@tanstack/react-router"; import { twMerge } from "tailwind-merge"; -import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { createNotification } from "@app/components/notifications"; import { OrgPermissionCan } from "@app/components/permissions"; import { @@ -51,8 +50,7 @@ const Page = () => { const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ "groupCreateUpdate", - "deleteGroup", - "upgradePlan" + "deleteGroup" ] as const); const onDeleteGroupSubmit = async ({ name, id }: { name: string; id: string }) => { @@ -176,11 +174,6 @@ const Page = () => { onDeleteGroupSubmit(popUp?.deleteGroup?.data as { name: string; id: string }) } /> - handlePopUpToggle("upgradePlan", isOpen)} - text={(popUp.upgradePlan?.data as { description: string })?.description} - /> ); }; diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx index c2e7bbe57..33753da39 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/IdentityDetailsByIDPage.tsx @@ -109,7 +109,7 @@ const Page = () => { handlePopUpToggle("upgradePlan", isOpen)} - text={(popUp.upgradePlan?.data as { description: string })?.description} + text={`Your current plan does not include access to ${popUp.upgradePlan.data?.featureName}. To unlock this feature, please upgrade to Infisical ${popUp.upgradePlan.data?.isEnterpriseFeature ? "Enterprise" : "Pro"} plan.`} isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature} /> handlePopUpToggle("upgradePlan", isOpen)} - text={(popUp.upgradePlan?.data as { description: string })?.description} + text={`Your current plan does not include access to ${popUp.upgradePlan.data?.featureName}. To unlock this feature, please upgrade to Infisical ${popUp.upgradePlan.data?.isEnterpriseFeature ? "Enterprise" : "Pro"} plan.`} /> ); diff --git a/frontend/src/pages/organization/ProjectsPage/ProjectsPage.tsx b/frontend/src/pages/organization/ProjectsPage/ProjectsPage.tsx index b916c7867..aba2e752d 100644 --- a/frontend/src/pages/organization/ProjectsPage/ProjectsPage.tsx +++ b/frontend/src/pages/organization/ProjectsPage/ProjectsPage.tsx @@ -92,7 +92,7 @@ export const ProjectsPage = () => { handlePopUpToggle("upgradePlan", isOpen)} - text="You have exceeded the number of projects allowed on the free plan. You can upgrade to Infisical's Pro plan to add more projects." + text="You have reached the maximum number of projects allowed on your current plan. Upgrade to Infisical Pro plan to add more projects." /> ); diff --git a/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamTab.tsx b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamTab.tsx index 2ccdfad61..d8dca7401 100644 --- a/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamTab.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/AuditLogStreamTab.tsx @@ -57,7 +57,7 @@ export const AuditLogStreamsTab = withPermission( handlePopUpToggle("upgradePlan", isOpen)} - text="You can add audit log streams if you switch to Infisical's Enterprise plan." + text="Your current plan does not include access to audit log streams. To unlock this feature, please upgrade to Infisical Enterprise plan." isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature} /> diff --git a/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/components/LogStreamProviderSelect.tsx b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/components/LogStreamProviderSelect.tsx index df57dab88..6d1be6531 100644 --- a/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/components/LogStreamProviderSelect.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/AuditLogStreamTab/components/LogStreamProviderSelect.tsx @@ -111,7 +111,7 @@ export const LogStreamProviderSelect = ({ onSelect }: Props) => { handlePopUpToggle("upgradePlan", isOpen)} - text="This audit log stream provider requires an enterprise license." + text="Your current plan does not include access to this audit log stream provider. To unlock this feature, please upgrade to Infisical Enterprise plan." isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature} /> diff --git a/frontend/src/pages/organization/SettingsPage/components/KmipTab/OrgKmipTab.tsx b/frontend/src/pages/organization/SettingsPage/components/KmipTab/OrgKmipTab.tsx index 7203cb9ef..1ab529ea3 100644 --- a/frontend/src/pages/organization/SettingsPage/components/KmipTab/OrgKmipTab.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/KmipTab/OrgKmipTab.tsx @@ -252,7 +252,7 @@ const OrgConfigSection = ({ handlePopUpToggle("upgradePlan", isOpen)} - text="KMIP requires an enterprise plan." + text="Your current plan does not include access to KMIP. To unlock this feature, please upgrade to Infisical Enterprise plan." isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature} /> diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgEncryptionTab/OrgEncryptionTab.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgEncryptionTab/OrgEncryptionTab.tsx index 3fcf71e62..81649b604 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgEncryptionTab/OrgEncryptionTab.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgEncryptionTab/OrgEncryptionTab.tsx @@ -121,7 +121,7 @@ export const OrgEncryptionTab = withPermission( handlePopUpToggle("upgradePlan", isOpen)} - text="You can configure external KMS if you switch to Infisical's Enterprise plan." + text="Your current plan does not include access to external KMS. To unlock this feature, please upgrade to Infisical Enterprise plan." isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature} /> { handlePopUpToggle("upgradePlan", isOpen)} - text="You can use GitHub Organization Plan if you switch to Infisical's Enterprise plan." + text="Your current plan does not include access to GitHub Organization Sync. To unlock this feature, please upgrade to Infisical Enterprise plan." isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature} /> diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgProvisioningTab/OrgSCIMSection.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgProvisioningTab/OrgSCIMSection.tsx index 3d079ccef..f7551d1e4 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgProvisioningTab/OrgSCIMSection.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgProvisioningTab/OrgSCIMSection.tsx @@ -110,7 +110,7 @@ export const OrgScimSection = () => { handlePopUpToggle("upgradePlan", isOpen)} - text="You can use SCIM Provisioning if you switch to Infisical's Enterprise plan." + text="Your current plan does not include access to SCIM Provisioning. To unlock this feature, please upgrade to Infisical Enterprise plan." isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature} /> diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgSecurityTab/OrgGenericAuthSection.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgSecurityTab/OrgGenericAuthSection.tsx index 7fe5fc219..7e6922318 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgSecurityTab/OrgGenericAuthSection.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgSecurityTab/OrgGenericAuthSection.tsx @@ -94,7 +94,7 @@ export const OrgGenericAuthSection = () => { handlePopUpToggle("upgradePlan", isOpen)} - text="You can enforce user MFA if you switch to Infisical's Pro plan." + text="Your current plan does not include access to enforce user MFA. To unlock this feature, please upgrade to Infisical Pro plan." /> ); diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgGeneralAuthSection.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgGeneralAuthSection.tsx index f8d5b3200..d35a04323 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgGeneralAuthSection.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgGeneralAuthSection.tsx @@ -304,7 +304,7 @@ export const OrgGeneralAuthSection = ({ handlePopUpToggle("upgradePlan", isOpen)} - text="You can enforce SAML SSO if you switch to Infisical's Pro plan." + text="Your current plan does not include access to enforce SAML SSO. To unlock this feature, please upgrade to Infisical Pro plan." /> ); diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgLDAPSection.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgLDAPSection.tsx index e4c4fec56..311a7ea4b 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgLDAPSection.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgLDAPSection.tsx @@ -183,7 +183,7 @@ export const OrgLDAPSection = (): JSX.Element => { handlePopUpToggle("upgradePlan", isOpen)} - text="You can use LDAP authentication if you switch to Infisical's Enterprise plan." + text="Your current plan does not include access to LDAP authentication. To unlock this feature, please upgrade to Infisical Enterprise plan." isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature} /> diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgOIDCSection.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgOIDCSection.tsx index 35962349b..b722762e6 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgOIDCSection.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgOIDCSection.tsx @@ -199,7 +199,7 @@ export const OrgOIDCSection = (): JSX.Element => { handlePopUpToggle("upgradePlan", isOpen)} - text="You can use OIDC SSO if you switch to Infisical's Pro plan." + text="Your current plan does not include access to OIDC SSO. To unlock this feature, please upgrade to Infisical Pro plan." /> ); diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSSOSection.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSSOSection.tsx index 80ed79551..ba2d23abd 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSSOSection.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSSOSection.tsx @@ -27,7 +27,6 @@ export const OrgSSOSection = (): JSX.Element => { const { mutateAsync } = useUpdateSSOConfig(); const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ "upgradePlan", - // "upgradeEnterprisePlan", "addSSO" ] as const); @@ -38,7 +37,7 @@ export const OrgSSOSection = (): JSX.Element => { if (!subscription?.samlSSO) { handlePopUpOpen("upgradePlan", { - description: "You can use SAML SSO if you switch to Infisical's Pro plan." + text: "Your current plan does not include access to SAML SSO. To unlock this feature, please upgrade to Infisical Pro plan." }); return; } @@ -60,7 +59,7 @@ export const OrgSSOSection = (): JSX.Element => { if (!subscription?.samlSSO || !subscription?.groups) { handlePopUpOpen("upgradePlan", { isEnterpriseFeature: true, - description: "You can use SAML group mapping if you switch to Infisical's Enterprise plan." + text: "Your current plan does not include access to SAML group mapping. To unlock this feature, please upgrade to Infisical Enterprise plan." }); return; } @@ -95,7 +94,7 @@ export const OrgSSOSection = (): JSX.Element => { handlePopUpOpen("addSSO"); } else { handlePopUpOpen("upgradePlan", { - description: "You can use SAML SSO if you switch to Infisical's Pro plan." + text: "Your current plan does not include access to SAML SSO. To unlock this feature, please upgrade to Infisical Pro plan." }); } } catch (err) { @@ -225,7 +224,7 @@ export const OrgSSOSection = (): JSX.Element => { handlePopUpToggle("upgradePlan", isOpen)} - text={popUp.upgradePlan.data?.description} + text={popUp.upgradePlan.data?.text} isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature} /> diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSsoTab.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSsoTab.tsx index 7700d5c86..9469242b1 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSsoTab.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgSsoTab/OrgSsoTab.tsx @@ -93,7 +93,7 @@ export const OrgSsoTab = withPermission( colorSchema="secondary" onClick={() => { if (!subscription?.samlSSO) { - handlePopUpOpen("upgradePlan", { feature: "SAML SSO" }); + handlePopUpOpen("upgradePlan", { featureName: "SAML SSO" }); return; } @@ -116,7 +116,7 @@ export const OrgSsoTab = withPermission( colorSchema="secondary" onClick={() => { if (!subscription?.oidcSSO) { - handlePopUpOpen("upgradePlan", { feature: "OIDC SSO" }); + handlePopUpOpen("upgradePlan", { featureName: "OIDC SSO" }); return; } @@ -135,7 +135,7 @@ export const OrgSsoTab = withPermission( onClick={() => { if (!subscription?.ldap) { handlePopUpOpen("upgradePlan", { - feature: "LDAP", + featureName: "LDAP", isEnterpriseFeature: true }); return; @@ -208,16 +208,8 @@ export const OrgSsoTab = withPermission( handlePopUpToggle("upgradePlan", isOpen)} - text={`You can use ${ - (popUp.upgradePlan.data as { feature: string })?.feature - } if you switch to Infisical's ${ - (popUp.upgradePlan.data as { isEnterpriseFeature: boolean })?.isEnterpriseFeature - ? "Enterprise" - : "Pro" - } plan.`} - isEnterpriseFeature={ - (popUp.upgradePlan.data as { isEnterpriseFeature: boolean })?.isEnterpriseFeature - } + text={`Your current plan does not include access to ${popUp.upgradePlan.data?.featureName}. To unlock this feature, please upgrade to Infisical ${popUp.upgradePlan.data?.isEnterpriseFeature ? "Enterprise" : "Pro"} plan.`} + isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature} /> ); diff --git a/frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/ProjectTemplatesSection.tsx b/frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/ProjectTemplatesSection.tsx index e76fea0a3..4fe5225c4 100644 --- a/frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/ProjectTemplatesSection.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/ProjectTemplatesTab/components/ProjectTemplatesSection.tsx @@ -73,7 +73,7 @@ export const ProjectTemplatesSection = () => { handlePopUpToggle("upgradePlan", isOpen)} - text="You can create project templates if you switch to Infisical's Enterprise plan." + text="Your current plan does not include access to project templates. To unlock this feature, please upgrade to Infisical Enterprise plan." isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature} /> diff --git a/frontend/src/pages/organization/UserDetailsByIDPage/UserDetailsByIDPage.tsx b/frontend/src/pages/organization/UserDetailsByIDPage/UserDetailsByIDPage.tsx index 8ddc27763..d607bc100 100644 --- a/frontend/src/pages/organization/UserDetailsByIDPage/UserDetailsByIDPage.tsx +++ b/frontend/src/pages/organization/UserDetailsByIDPage/UserDetailsByIDPage.tsx @@ -287,7 +287,7 @@ const Page = withPermission( handlePopUpToggle("upgradePlan", isOpen)} - text={(popUp.upgradePlan?.data as { description: string })?.description} + text={popUp.upgradePlan?.data?.text} /> { const handleResourceSelect = (resource: PamResourceType) => { if (!subscription.pam) { handlePopUpOpen("upgradePlan", { - description: "PAM (Privileged Access Management) requires an enterprise plan.", + text: "Your current plan does not include access to Infisical PAM. To unlock this feature, please upgrade to Infisical Enterprise plan.", isEnterpriseFeature: true }); return; @@ -68,8 +68,7 @@ export const ResourceTypeSelect = ({ onSelect }: Props) => { resource === PamResourceType.Kubernetes ) { handlePopUpOpen("upgradePlan", { - description: - "This resource type requires a special license add-on to be enabled in your enterprise plan.", + text: "Your current plan does not include access to this resource type. To unlock this feature, please upgrade to Infisical Enterprise plan.", isEnterpriseFeature: true }); return; @@ -180,7 +179,7 @@ export const ResourceTypeSelect = ({ onSelect }: Props) => { handlePopUpToggle("upgradePlan", isOpen)} - text={popUp.upgradePlan.data?.description} + text={popUp.upgradePlan.data?.text} isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature} /> diff --git a/frontend/src/pages/pam/PamSessionsByIDPage/components/PamSessionLogOutput.tsx b/frontend/src/pages/pam/PamSessionsByIDPage/components/PamSessionLogOutput.tsx new file mode 100644 index 000000000..99e30627d --- /dev/null +++ b/frontend/src/pages/pam/PamSessionsByIDPage/components/PamSessionLogOutput.tsx @@ -0,0 +1,116 @@ +import { useState } from "react"; + +import { HighlightText } from "@app/components/v2/HighlightText"; +import { PamResourceType } from "@app/hooks/api/pam"; + +type TableLog = { + command?: string; + data_rows: Record[]; + total_rows?: number; +}; + +export const PamSessionLogOutput = ({ + content, + resourceType, + search +}: { + content: string; + resourceType: PamResourceType; + search: string; +}) => { + const [isRawView, setIsRawView] = useState(false); + + let parsedContent: TableLog | null = null; + + if (resourceType === PamResourceType.Postgres || resourceType === PamResourceType.MySQL) { + try { + const parsed = JSON.parse(content); + + if ( + parsed && + typeof parsed === "object" && + !Array.isArray(parsed) && + parsed.data_rows && + Array.isArray(parsed.data_rows) && + parsed.data_rows.length > 0 && + typeof parsed.data_rows[0] === "object" && + parsed.data_rows[0] !== null + ) { + parsedContent = parsed; + } + } catch { + // Not a valid JSON or doesn't match structure, will render as plain text + } + } + + if (parsedContent) { + const headers = Object.keys(parsedContent.data_rows[0]); + return ( +
+ {isRawView ? ( +
+ +
+ ) : ( + <> + {parsedContent.command && ( +
{`> ${parsedContent.command}`}
+ )} +
+ + + + {headers.map((header) => ( + + ))} + + + + {parsedContent.data_rows.map((row, rowIndex) => ( + + {headers.map((header) => ( + + ))} + + ))} + +
+ +
+ +
+
+ + )} +
+ + + {parsedContent.total_rows !== undefined && ( +
+ Total rows: {parsedContent.total_rows} +
+ )} +
+
+ ); + } + + return ( +
+ +
+ ); +}; diff --git a/frontend/src/pages/pam/PamSessionsByIDPage/components/PamSessionLogsSection.tsx b/frontend/src/pages/pam/PamSessionsByIDPage/components/PamSessionLogsSection.tsx index 5f06a64e2..0fbccbc9b 100644 --- a/frontend/src/pages/pam/PamSessionsByIDPage/components/PamSessionLogsSection.tsx +++ b/frontend/src/pages/pam/PamSessionsByIDPage/components/PamSessionLogsSection.tsx @@ -1,42 +1,76 @@ -import { useState } from "react"; -import { faChevronDown, faChevronRight } from "@fortawesome/free-solid-svg-icons"; +import { useMemo, useState } from "react"; +import { faChevronRight, faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { twMerge } from "tailwind-merge"; +import { Input } from "@app/components/v2"; +import { HighlightText } from "@app/components/v2/HighlightText"; import { TPamSession } from "@app/hooks/api/pam"; +import { PamSessionLogOutput } from "./PamSessionLogOutput"; +import { formatLogContent } from "./PamSessionLogsSection.utils"; + type Props = { session: TPamSession; }; export const PamSessionLogsSection = ({ session }: Props) => { const [expandedLogTimestamps, setExpandedLogTimestamps] = useState>(new Set()); + const [search, setSearch] = useState(""); const toggleExpand = (timestamp: string) => { setExpandedLogTimestamps((prev) => { - const newSet = new Set(prev); - if (newSet.has(timestamp)) { - newSet.delete(timestamp); - } else { - newSet.add(timestamp); + if (prev.has(timestamp)) { + return new Set(); } - return newSet; + return new Set([timestamp]); }); }; + const filteredLogs = useMemo( + () => + session.commandLogs.filter((log) => { + const { input, output } = log; + + const searchValue = search.trim().toLowerCase(); + + return ( + input.toLowerCase().includes(searchValue) || output.toLowerCase().includes(searchValue) + ); + }), + [session.commandLogs, search] + ); + return (

Session Logs

-
- {session.commandLogs.length > 0 ? ( - session.commandLogs.map((log) => { - const isExpanded = expandedLogTimestamps.has(log.timestamp); + +
+ { + const newSearch = e.target.value; + setSearch(newSearch); + }} + leftIcon={} + placeholder="Search logs..." + className="flex-1 bg-mineshaft-800" + containerClassName="bg-transparent" + /> +
+
+ {filteredLogs.length > 0 ? ( + filteredLogs.map((log) => { + const isExpanded = search.length || expandedLogTimestamps.has(log.timestamp); + const formattedInput = formatLogContent(log.input); + return ( ); }) ) : ( -
- {session.startedAt && session.endedAt ? ( +
+ {search.length ? ( +
+
No logs match search criteria
+
+ ) : (
Session logs are not yet available
@@ -78,8 +139,6 @@ export const PamSessionLogsSection = ({ session }: Props) => { If logs do not appear after some time, please contact your Gateway administrators.
- ) : ( - "No session logs" )}
)} diff --git a/frontend/src/pages/pam/PamSessionsByIDPage/components/PamSessionLogsSection.utils.ts b/frontend/src/pages/pam/PamSessionsByIDPage/components/PamSessionLogsSection.utils.ts new file mode 100644 index 000000000..d76145655 --- /dev/null +++ b/frontend/src/pages/pam/PamSessionsByIDPage/components/PamSessionLogsSection.utils.ts @@ -0,0 +1,46 @@ +// This function trims top and bottom empty padding, as well as moves all relative text to the left while still respecting indentation +export const formatLogContent = (text: string | null | undefined): string => { + if (!text) return ""; + + let lines = text.split("\n"); + + // Find the first and last non-empty lines to trim vertical padding + let firstLineIndex = -1; + for (let i = 0; i < lines.length; i += 1) { + if (lines[i].trim() !== "") { + firstLineIndex = i; + break; + } + } + + if (firstLineIndex === -1) { + return ""; + } + + let lastLineIndex = -1; + for (let i = lines.length - 1; i >= 0; i -= 1) { + if (lines[i].trim() !== "") { + lastLineIndex = i; + break; + } + } + + lines = lines.slice(firstLineIndex, lastLineIndex + 1); + + // Determine the minimum indentation of non-empty lines + const indentations = lines + .filter((line) => line.trim() !== "") + .map((line) => { + const match = line.match(/^\s*/); + return match ? match[0].length : 0; + }); + + const minIndentation = indentations.length > 0 ? Math.min(...indentations) : 0; + + // Remove the common indentation from all lines + if (minIndentation > 0) { + lines = lines.map((line) => line.substring(minIndentation)); + } + + return lines.join("\n"); +}; diff --git a/frontend/src/pages/pam/PamSessionsPage/components/PamSessionRow.tsx b/frontend/src/pages/pam/PamSessionsPage/components/PamSessionRow.tsx index f730e331d..8721560c4 100644 --- a/frontend/src/pages/pam/PamSessionsPage/components/PamSessionRow.tsx +++ b/frontend/src/pages/pam/PamSessionsPage/components/PamSessionRow.tsx @@ -27,6 +27,7 @@ import { HighlightText } from "@app/components/v2/HighlightText"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { PAM_RESOURCE_TYPE_MAP, TPamSession } from "@app/hooks/api/pam"; +import { formatLogContent } from "../../PamSessionsByIDPage/components/PamSessionLogsSection.utils"; import { PamSessionStatusBadge } from "./PamSessionStatusBadge"; type Props = { @@ -159,21 +160,28 @@ export const PamSessionRow = ({ session, search, filteredCommandLogs }: Props) = {filteredCommandLogs.length > 0 && (
- {logsToShow.map((log) => ( -
-
- - {new Date(log.timestamp).toLocaleString()} -
+ {logsToShow.map((log) => { + const formattedInput = formatLogContent(log.input); -
- + return ( +
+
+ + {new Date(log.timestamp).toLocaleString()} +
+ +
+ +
+
+ +
-
- -
-
- ))} + ); + })} {filteredCommandLogs.length > LOGS_TO_SHOW && (
); diff --git a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MembersSection.tsx b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MembersSection.tsx index 7ec7bd4bc..38dc5e47e 100644 --- a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MembersSection.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MembersSection.tsx @@ -1,7 +1,6 @@ import { faPlus } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; import { Button, DeleteActionModal } from "@app/components/v2"; @@ -26,8 +25,7 @@ export const MembersSection = () => { const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ "addMember", - "removeMember", - "upgradePlan" + "removeMember" ] as const); const handleRemoveUser = async () => { @@ -77,11 +75,6 @@ export const MembersSection = () => { onChange={(isOpen) => handlePopUpToggle("removeMember", isOpen)} onDeleteApproved={handleRemoveUser} /> - handlePopUpToggle("upgradePlan", isOpen)} - text={(popUp.upgradePlan?.data as { description: string })?.description} - />
); }; diff --git a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MembersTable.tsx b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MembersTable.tsx index e8cb43db9..d554f12fb 100644 --- a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MembersTable.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MembersTable.tsx @@ -60,10 +60,7 @@ import { UsePopUpState } from "@app/hooks/usePopUp"; const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2; type Props = { - handlePopUpOpen: ( - popUpName: keyof UsePopUpState<["removeMember", "upgradePlan"]>, - data?: object - ) => void; + handlePopUpOpen: (popUpName: keyof UsePopUpState<["removeMember"]>, data?: object) => void; }; enum MembersOrderBy { diff --git a/frontend/src/pages/project/MemberDetailsByIDPage/MemberDetailsByIDPage.tsx b/frontend/src/pages/project/MemberDetailsByIDPage/MemberDetailsByIDPage.tsx index b4b83bfb4..5d8ae812c 100644 --- a/frontend/src/pages/project/MemberDetailsByIDPage/MemberDetailsByIDPage.tsx +++ b/frontend/src/pages/project/MemberDetailsByIDPage/MemberDetailsByIDPage.tsx @@ -182,8 +182,7 @@ export const Page = () => { isMembershipDetailsLoading={isMembershipDetailsLoading} onOpenUpgradeModal={() => handlePopUpOpen("upgradePlan", { - description: - "You can assign custom roles to members if you switch to Infisical's Pro plan." + text: "Assigning custom roles to members can be unlocked if you upgrade to Infisical Pro plan." }) } /> @@ -207,7 +206,7 @@ export const Page = () => { handlePopUpToggle("upgradePlan", isOpen)} - text={(popUp.upgradePlan?.data as { description: string })?.description} + text={popUp.upgradePlan?.data?.text} /> ) : ( diff --git a/frontend/src/pages/project/SettingsPage/components/AuditLogsRetentionSection/AuditLogsRetentionSection.tsx b/frontend/src/pages/project/SettingsPage/components/AuditLogsRetentionSection/AuditLogsRetentionSection.tsx index 1618c73aa..cf093c21a 100644 --- a/frontend/src/pages/project/SettingsPage/components/AuditLogsRetentionSection/AuditLogsRetentionSection.tsx +++ b/frontend/src/pages/project/SettingsPage/components/AuditLogsRetentionSection/AuditLogsRetentionSection.tsx @@ -41,8 +41,7 @@ export const AuditLogsRetentionSection = () => { const handleAuditLogsRetentionSubmit = async ({ auditLogsRetentionDays }: TForm) => { if (!subscription?.auditLogs) { handlePopUpOpen("upgradePlan", { - description: - "You can only configure audit logs retention if you switch to Infisical's Pro plan." + text: "Configuring audit logs retention can be unlocked if you upgrade to Infisical Pro plan." }); return; @@ -50,8 +49,7 @@ export const AuditLogsRetentionSection = () => { if (subscription && auditLogsRetentionDays > subscription?.auditLogsRetentionDays) { handlePopUpOpen("upgradePlan", { - description: - "To update your audit logs retention period to a higher value, switch to Infisical's Pro plan." + text: "Updating audit logs retention period to a higher value can be unlocked if you upgrade to Infisical Pro plan." }); return; @@ -116,7 +114,7 @@ export const AuditLogsRetentionSection = () => { handlePopUpToggle("upgradePlan", isOpen)} - text={(popUp.upgradePlan?.data as { description: string })?.description} + text={popUp.upgradePlan?.data?.text} /> ); diff --git a/frontend/src/pages/secret-manager/IPAllowlistPage/components/IPAllowlistSection.tsx b/frontend/src/pages/secret-manager/IPAllowlistPage/components/IPAllowlistSection.tsx index b076618b0..11d6ca8d9 100644 --- a/frontend/src/pages/secret-manager/IPAllowlistPage/components/IPAllowlistSection.tsx +++ b/frontend/src/pages/secret-manager/IPAllowlistPage/components/IPAllowlistSection.tsx @@ -97,7 +97,7 @@ export const IPAllowlistSection = () => { handlePopUpToggle("upgradePlan", isOpen)} - text="You can use IP allowlisting if you switch to Infisical's Pro plan." + text="Your current plan does not include access to IP allowlisting. To unlock this feature, please upgrade to Infisical Pro plan." /> ); diff --git a/frontend/src/pages/secret-manager/IPAllowlistPage/components/IPAllowlistTable.tsx b/frontend/src/pages/secret-manager/IPAllowlistPage/components/IPAllowlistTable.tsx index 8f3336be8..7fc696be3 100644 --- a/frontend/src/pages/secret-manager/IPAllowlistPage/components/IPAllowlistTable.tsx +++ b/frontend/src/pages/secret-manager/IPAllowlistPage/components/IPAllowlistTable.tsx @@ -159,7 +159,7 @@ export const IPAllowlistTable = ({ popUp, handlePopUpOpen, handlePopUpToggle }: handlePopUpToggle("upgradePlan", isOpen)} - text="You can use IP allowlisting if you switch to Infisical's Pro plan." + text="Your current plan does not include access to IP allowlisting. To unlock this feature, please upgrade to Infisical Pro plan." /> ); diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/ChefSyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/ChefSyncDestinationCol.tsx new file mode 100644 index 000000000..cd1b2742e --- /dev/null +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/ChefSyncDestinationCol.tsx @@ -0,0 +1,14 @@ +import { TChefSync } from "@app/hooks/api/secretSyncs/types/chef-sync"; + +import { getSecretSyncDestinationColValues } from "../helpers"; +import { SecretSyncTableCell } from "../SecretSyncTableCell"; + +type Props = { + secretSync: TChefSync; +}; + +export const ChefSyncDestinationCol = ({ secretSync }: Props) => { + const { primaryText, secondaryText } = getSecretSyncDestinationColValues(secretSync); + + return ; +}; diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx index 4ec6d15ff..985794144 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/SecretSyncDestinationCol/SecretSyncDestinationCol.tsx @@ -9,6 +9,7 @@ import { AzureKeyVaultDestinationSyncCol } from "./AzureKeyVaultDestinationSyncC import { BitbucketSyncDestinationCol } from "./BitbucketSyncDestinationCol"; import { CamundaSyncDestinationCol } from "./CamundaSyncDestinationCol"; import { ChecklySyncDestinationCol } from "./ChecklySyncDestinationCol"; +import { ChefSyncDestinationCol } from "./ChefSyncDestinationCol"; import { CloudflarePagesSyncDestinationCol } from "./CloudflarePagesSyncDestinationCol"; import { CloudflareWorkersSyncDestinationCol } from "./CloudflareWorkersSyncDestinationCol"; import { DatabricksSyncDestinationCol } from "./DatabricksSyncDestinationCol"; @@ -103,6 +104,8 @@ export const SecretSyncDestinationCol = ({ secretSync }: Props) => { return ; case SecretSync.LaravelForge: return ; + case SecretSync.Chef: + return ; default: throw new Error( `Unhandled Secret Sync Destination Col: ${(secretSync as TSecretSync).destination}` diff --git a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts index 48422c41e..de0aad100 100644 --- a/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts +++ b/frontend/src/pages/secret-manager/IntegrationsListPage/components/SecretSyncsTab/SecretSyncTable/helpers/index.ts @@ -202,6 +202,10 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => { primaryText = destinationConfig.siteName || destinationConfig.siteId; secondaryText = destinationConfig.orgName || destinationConfig.orgSlug; break; + case SecretSync.Chef: + primaryText = destinationConfig.dataBagName; + secondaryText = destinationConfig.dataBagItemName; + break; default: throw new Error(`Unhandled Destination Col Values ${destination}`); } diff --git a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx index 09fa1044d..591b7cf3d 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx @@ -1156,8 +1156,7 @@ export const OverviewPage = () => { } handlePopUpOpen("upgradePlan", { isEnterpriseFeature: true, - description: - "You can add dynamic secrets if you switch to Infisical's Enterprise plan." + text: "Adding dynamic secrets can be unlocked if you upgrade to Infisical Enterprise plan." }); }} isDisabled={userAvailableDynamicSecretEnvs.length === 0} @@ -1182,8 +1181,7 @@ export const OverviewPage = () => { return; } handlePopUpOpen("upgradePlan", { - description: - "You can add secret rotations if you switch to Infisical's Pro plan." + text: "Adding secret rotations can be unlocked if you upgrade to Infisical Pro plan." }); }} isDisabled={userAvailableSecretRotationEnvs.length === 0} @@ -1669,7 +1667,7 @@ export const OverviewPage = () => { isOpen={popUp.upgradePlan.isOpen} onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)} isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature} - text={popUp.upgradePlan.data?.description} + text={popUp.upgradePlan.data?.text} /> )} { if (subscription && !subscription?.secretApproval) { handlePopUpOpen("upgradePlan", { - description: - "You can use request access feature if you switch to Infisical's Pro plan." + text: "Access requests feature can be unlocked if you upgrade to Infisical Pro plan." }); return; } @@ -573,7 +572,7 @@ export const AccessApprovalRequest = ({ )} handlePopUpClose("upgradePlan")} /> diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx index a73786582..c7090eee9 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx @@ -491,7 +491,7 @@ export const ApprovalPolicyList = ({ projectId }: IProps) => { handlePopUpToggle("upgradePlan", isOpen)} - text="You can add secret approval policy if you switch to Infisical's Pro plan." + text="Adding secret approval policies can be unlocked if you upgrade to Infisical Pro plan." /> ); diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx index a3f6f3e43..727903999 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx @@ -884,7 +884,7 @@ export const ActionBar = ({ } handlePopUpOpen("upgradePlan", { - feature: "PIT Recovery" + featureName: "PIT Recovery" }); }} leftIcon={} @@ -1000,7 +1000,7 @@ export const ActionBar = ({ return; } handlePopUpOpen("upgradePlan", { - feature: "Dynamic Secrets", + featureName: "Dynamic Secrets", isEnterpriseFeature: true }); }} @@ -1030,7 +1030,7 @@ export const ActionBar = ({ return; } handlePopUpOpen("upgradePlan", { - feature: "Secret Rotation" + featureName: "Secret Rotation" }); }} variant="outline_bg" @@ -1207,7 +1207,7 @@ export const ActionBar = ({ projectId={projectId} onUpgradePlan={() => handlePopUpOpen("upgradePlan", { - feature: "Secret Imports" + featureName: "Secret Imports" }) } isOpen={popUp.addSecretImport.isOpen} @@ -1272,7 +1272,7 @@ export const ActionBar = ({ isOpen={popUp.upgradePlan.isOpen} onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)} isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature} - text={`You can use ${popUp.upgradePlan.data?.feature} if you switch to Infisical's ${popUp.upgradePlan.data?.isEnterpriseFeature ? "Enterprise" : "Pro"} plan.`} + text={`Your current plan does not include access to ${popUp.upgradePlan.data?.featureName}. To unlock this feature, please upgrade to Infisical ${popUp.upgradePlan.data?.isEnterpriseFeature ? "Enterprise" : "Pro"} plan.`} /> )} { handlePopUpToggle("upgradePlan", isOpen)} - text="You can add custom environments if you switch to Infisical's Pro plan." + text="Your current plan does not include access to adding custom environments. To unlock this feature, please upgrade to Infisical Pro plan." /> handlePopUpToggle("secretAccessUpgradePlan", isUpgradeModalOpen) } - text="You can access secret access analysis if you switch to Infisical's Pro plan." + text="Secret access analysis feature can be unlocked if you upgrade to Infisical Pro plan." /> { handlePopUpToggle("upgradePlan", isOpen)} - text="You can add secret rotation if you switch to Infisical's Pro plan." + text="Adding secret rotations can be unlocked if you upgrade to Infisical Pro plan." /> { + const { destinationConfig } = secretSync; + + return ( + <> + {destinationConfig.dataBagName} + + {destinationConfig.dataBagItemName} + + + ); +}; diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx index 31bc6d4fd..de527d6c8 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncDestinationSection/SecretSyncDestinatonSection.tsx @@ -20,6 +20,7 @@ import { AzureKeyVaultSyncDestinationSection } from "./AzureKeyVaultSyncDestinat import { BitbucketSyncDestinationSection } from "./BitbucketSyncDestinationSection"; import { CamundaSyncDestinationSection } from "./CamundaSyncDestinationSection"; import { ChecklySyncDestinationSection } from "./ChecklySyncDestinationSection"; +import { ChefSyncDestinationSection } from "./ChefSyncDestinationSection"; import { CloudflarePagesSyncDestinationSection } from "./CloudflarePagesSyncDestinationSection"; import { CloudflareWorkersSyncDestinationSection } from "./CloudflareWorkersSyncDestinationSection"; import { DatabricksSyncDestinationSection } from "./DatabricksSyncDestinationSection"; @@ -156,6 +157,9 @@ export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }: case SecretSync.LaravelForge: DestinationComponents = ; break; + case SecretSync.Chef: + DestinationComponents = ; + break; default: throw new Error(`Unhandled Destination Section components: ${destination}`); } diff --git a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx index 7e6ac8459..a8de5b379 100644 --- a/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx +++ b/frontend/src/pages/secret-manager/SecretSyncDetailsByIDPage/components/SecretSyncOptionsSection/SecretSyncOptionsSection.tsx @@ -74,6 +74,7 @@ export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) = case SecretSync.Northflank: case SecretSync.Bitbucket: case SecretSync.LaravelForge: + case SecretSync.Chef: AdditionalSyncOptionsComponent = null; break; default: diff --git a/frontend/src/pages/secret-manager/SettingsPage/components/EnvironmentSection/EnvironmentSection.tsx b/frontend/src/pages/secret-manager/SettingsPage/components/EnvironmentSection/EnvironmentSection.tsx index e1c1cdeda..b6276a8c0 100644 --- a/frontend/src/pages/secret-manager/SettingsPage/components/EnvironmentSection/EnvironmentSection.tsx +++ b/frontend/src/pages/secret-manager/SettingsPage/components/EnvironmentSection/EnvironmentSection.tsx @@ -115,7 +115,7 @@ export const EnvironmentSection = () => { handlePopUpToggle("upgradePlan", isOpen)} - text="You can add custom environments if you switch to Infisical's Pro plan." + text="You have reached the maximum number of environments allowed on the free plan. Upgrade to Infisical Pro plan to add more environments." /> ); diff --git a/frontend/src/pages/secret-scanning/SecretScanningDataSourcesPage/components/SecretScanningDataSourcesSection.tsx b/frontend/src/pages/secret-scanning/SecretScanningDataSourcesPage/components/SecretScanningDataSourcesSection.tsx index a786d79bc..3ba6009e1 100644 --- a/frontend/src/pages/secret-scanning/SecretScanningDataSourcesPage/components/SecretScanningDataSourcesSection.tsx +++ b/frontend/src/pages/secret-scanning/SecretScanningDataSourcesPage/components/SecretScanningDataSourcesSection.tsx @@ -84,7 +84,7 @@ export const SecretScanningDataSourcesSection = () => { handlePopUpToggle("upgradePlan", isOpen)} - text="You can create Data Sources by upgrading to Infisical's Enterprise plan." + text="Creating data sources can be unlocked if you upgrade to Infisical Enterprise plan." isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature} /> diff --git a/frontend/src/pages/ssh/SshCasPage/components/SshCaSection.tsx b/frontend/src/pages/ssh/SshCasPage/components/SshCaSection.tsx index 02eb37a38..11942795c 100644 --- a/frontend/src/pages/ssh/SshCasPage/components/SshCaSection.tsx +++ b/frontend/src/pages/ssh/SshCasPage/components/SshCaSection.tsx @@ -18,8 +18,7 @@ export const SshCaSection = () => { const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ "sshCa", "deleteSshCa", - "sshCaStatus", // enable / disable - "upgradePlan" + "sshCaStatus" // enable / disable ] as const); const onRemoveSshCaSubmit = async (caId: string) => { @@ -94,11 +93,6 @@ export const SshCaSection = () => { onUpdateSshCaStatus(popUp?.sshCaStatus?.data as { caId: string; status: SshCaStatus }) } /> - {/* handlePopUpToggle("upgradePlan", isOpen)} - text={(popUp.upgradePlan?.data as { description: string })?.description} - /> */} ); }; diff --git a/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupHostsSection.tsx b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupHostsSection.tsx index 9b482538c..189ec6e56 100644 --- a/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupHostsSection.tsx +++ b/frontend/src/pages/ssh/SshHostGroupDetailsByIDPage/components/SshHostGroupHostsSection.tsx @@ -29,8 +29,7 @@ export const SshHostGroupHostsSection = ({ sshHostGroupId }: Props) => { const handleAddSshHostModal = () => { if (!subscription?.sshHostGroups) { handlePopUpOpen("upgradePlan", { - description: - "You can manage hosts more efficiently with SSH host groups if you upgrade your Infisical plan to an Enterprise license.", + text: "Managing SSH host groups can be unlocked if you upgrade to Infisical Enterprise plan.", isEnterpriseFeature: true }); } else { @@ -99,10 +98,8 @@ export const SshHostGroupHostsSection = ({ sshHostGroupId }: Props) => { handlePopUpToggle("upgradePlan", isOpen)} - text={(popUp.upgradePlan?.data as { description: string })?.description} - isEnterpriseFeature={ - (popUp.upgradePlan?.data as { isEnterpriseFeature: boolean })?.isEnterpriseFeature - } + text={popUp.upgradePlan?.data?.text} + isEnterpriseFeature={popUp.upgradePlan?.data?.isEnterpriseFeature} /> ); diff --git a/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsSection.tsx b/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsSection.tsx index e690480e1..5febe22b7 100644 --- a/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsSection.tsx +++ b/frontend/src/pages/ssh/SshHostsPage/components/SshHostGroupsSection.tsx @@ -25,8 +25,7 @@ export const SshHostGroupsSection = () => { const handleAddSshHostGroupModal = () => { if (!subscription?.sshHostGroups) { handlePopUpOpen("upgradePlan", { - description: - "You can manage hosts more efficiently with SSH host groups if you upgrade your Infisical plan to an Enterprise license.", + text: "Managing SSH host groups can be unlocked if you upgrade to Infisical Enterprise plan.", isEnterpriseFeature: true }); } else { @@ -98,10 +97,8 @@ export const SshHostGroupsSection = () => { handlePopUpToggle("upgradePlan", isOpen)} - text={(popUp.upgradePlan?.data as { description: string })?.description} - isEnterpriseFeature={ - (popUp.upgradePlan?.data as { isEnterpriseFeature: boolean })?.isEnterpriseFeature - } + text={popUp.upgradePlan?.data?.text} + isEnterpriseFeature={popUp.upgradePlan?.data?.isEnterpriseFeature} /> ); diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 34c4153d6..4c6558db9 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -114,7 +114,6 @@ import { Route as kmsOverviewPageRouteImport } from './pages/kms/OverviewPage/ro import { Route as kmsKmipPageRouteImport } from './pages/kms/KmipPage/route' import { Route as certManagerSettingsPageRouteImport } from './pages/cert-manager/SettingsPage/route' import { Route as certManagerPoliciesPageRouteImport } from './pages/cert-manager/PoliciesPage/route' -import { Route as certManagerCertificatesPageRouteImport } from './pages/cert-manager/CertificatesPage/route' import { Route as certManagerCertificateAuthoritiesPageRouteImport } from './pages/cert-manager/CertificateAuthoritiesPage/route' import { Route as certManagerAlertingPageRouteImport } from './pages/cert-manager/AlertingPage/route' import { Route as organizationAppConnectionsOauthCallbackPageRouteImport } from './pages/organization/AppConnections/OauthCallbackPage/route' @@ -1203,13 +1202,6 @@ const certManagerPoliciesPageRouteRoute = getParentRoute: () => certManagerLayoutRoute, } as any) -const certManagerCertificatesPageRouteRoute = - certManagerCertificatesPageRouteImport.update({ - id: '/certificates', - path: '/certificates', - getParentRoute: () => certManagerLayoutRoute, - } as any) - const certManagerCertificateAuthoritiesPageRouteRoute = certManagerCertificateAuthoritiesPageRouteImport.update({ id: '/certificate-authorities', @@ -2786,13 +2778,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof certManagerCertificateAuthoritiesPageRouteImport parentRoute: typeof certManagerLayoutImport } - '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/certificates': { - id: '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/certificates' - path: '/certificates' - fullPath: '/projects/cert-management/$projectId/certificates' - preLoaderRoute: typeof certManagerCertificatesPageRouteImport - parentRoute: typeof certManagerLayoutImport - } '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/policies': { id: '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/policies' path: '/policies' @@ -4126,7 +4111,6 @@ const AuthenticateInjectOrgDetailsOrgLayoutProjectsCertManagementProjectIdCertMa interface certManagerLayoutRouteChildren { certManagerAlertingPageRouteRoute: typeof certManagerAlertingPageRouteRoute certManagerCertificateAuthoritiesPageRouteRoute: typeof certManagerCertificateAuthoritiesPageRouteRoute - certManagerCertificatesPageRouteRoute: typeof certManagerCertificatesPageRouteRoute certManagerPoliciesPageRouteRoute: typeof certManagerPoliciesPageRouteRoute certManagerSettingsPageRouteRoute: typeof certManagerSettingsPageRouteRoute projectAccessControlPageRouteCertManagerRoute: typeof projectAccessControlPageRouteCertManagerRoute @@ -4147,7 +4131,6 @@ const certManagerLayoutRouteChildren: certManagerLayoutRouteChildren = { certManagerAlertingPageRouteRoute: certManagerAlertingPageRouteRoute, certManagerCertificateAuthoritiesPageRouteRoute: certManagerCertificateAuthoritiesPageRouteRoute, - certManagerCertificatesPageRouteRoute: certManagerCertificatesPageRouteRoute, certManagerPoliciesPageRouteRoute: certManagerPoliciesPageRouteRoute, certManagerSettingsPageRouteRoute: certManagerSettingsPageRouteRoute, projectAccessControlPageRouteCertManagerRoute: @@ -5061,7 +5044,6 @@ export interface FileRoutesByFullPath { '/organization/app-connections/$appConnection/oauth/callback': typeof organizationAppConnectionsOauthCallbackPageRouteRoute '/projects/cert-management/$projectId/alerting': typeof certManagerAlertingPageRouteRoute '/projects/cert-management/$projectId/certificate-authorities': typeof certManagerCertificateAuthoritiesPageRouteRoute - '/projects/cert-management/$projectId/certificates': typeof certManagerCertificatesPageRouteRoute '/projects/cert-management/$projectId/policies': typeof certManagerPoliciesPageRouteRoute '/projects/cert-management/$projectId/settings': typeof certManagerSettingsPageRouteRoute '/projects/kms/$projectId/kmip': typeof kmsKmipPageRouteRoute @@ -5294,7 +5276,6 @@ export interface FileRoutesByTo { '/organization/app-connections/$appConnection/oauth/callback': typeof organizationAppConnectionsOauthCallbackPageRouteRoute '/projects/cert-management/$projectId/alerting': typeof certManagerAlertingPageRouteRoute '/projects/cert-management/$projectId/certificate-authorities': typeof certManagerCertificateAuthoritiesPageRouteRoute - '/projects/cert-management/$projectId/certificates': typeof certManagerCertificatesPageRouteRoute '/projects/cert-management/$projectId/policies': typeof certManagerPoliciesPageRouteRoute '/projects/cert-management/$projectId/settings': typeof certManagerSettingsPageRouteRoute '/projects/kms/$projectId/kmip': typeof kmsKmipPageRouteRoute @@ -5538,7 +5519,6 @@ export interface FileRoutesById { '/_authenticate/_inject-org-details/_org-layout/organization/app-connections/$appConnection/oauth/callback': typeof organizationAppConnectionsOauthCallbackPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/alerting': typeof certManagerAlertingPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/certificate-authorities': typeof certManagerCertificateAuthoritiesPageRouteRoute - '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/certificates': typeof certManagerCertificatesPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/policies': typeof certManagerPoliciesPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/settings': typeof certManagerSettingsPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/projects/kms/$projectId/_kms-layout/kmip': typeof kmsKmipPageRouteRoute @@ -5780,7 +5760,6 @@ export interface FileRouteTypes { | '/organization/app-connections/$appConnection/oauth/callback' | '/projects/cert-management/$projectId/alerting' | '/projects/cert-management/$projectId/certificate-authorities' - | '/projects/cert-management/$projectId/certificates' | '/projects/cert-management/$projectId/policies' | '/projects/cert-management/$projectId/settings' | '/projects/kms/$projectId/kmip' @@ -6012,7 +5991,6 @@ export interface FileRouteTypes { | '/organization/app-connections/$appConnection/oauth/callback' | '/projects/cert-management/$projectId/alerting' | '/projects/cert-management/$projectId/certificate-authorities' - | '/projects/cert-management/$projectId/certificates' | '/projects/cert-management/$projectId/policies' | '/projects/cert-management/$projectId/settings' | '/projects/kms/$projectId/kmip' @@ -6254,7 +6232,6 @@ export interface FileRouteTypes { | '/_authenticate/_inject-org-details/_org-layout/organization/app-connections/$appConnection/oauth/callback' | '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/alerting' | '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/certificate-authorities' - | '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/certificates' | '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/policies' | '/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/settings' | '/_authenticate/_inject-org-details/_org-layout/projects/kms/$projectId/_kms-layout/kmip' @@ -6890,7 +6867,6 @@ export const routeTree = rootRoute "children": [ "/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/alerting", "/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/certificate-authorities", - "/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/certificates", "/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/policies", "/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/settings", "/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/access-management", @@ -7005,10 +6981,6 @@ export const routeTree = rootRoute "filePath": "cert-manager/CertificateAuthoritiesPage/route.tsx", "parent": "/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout" }, - "/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/certificates": { - "filePath": "cert-manager/CertificatesPage/route.tsx", - "parent": "/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout" - }, "/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout/policies": { "filePath": "cert-manager/PoliciesPage/route.tsx", "parent": "/_authenticate/_inject-org-details/_org-layout/projects/cert-management/$projectId/_cert-manager-layout" diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index 16bdaf10f..cc37f3031 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -304,7 +304,6 @@ const certManagerRoutes = route("/projects/cert-management/$projectId", [ route("/$subscriberName", "cert-manager/PkiSubscriberDetailsByIDPage/route.tsx") ]), route("/certificate-templates", [index("cert-manager/PkiTemplateListPage/route.tsx")]), - route("/certificates", "cert-manager/CertificatesPage/route.tsx"), route("/certificate-authorities", "cert-manager/CertificateAuthoritiesPage/route.tsx"), route("/alerting", "cert-manager/AlertingPage/route.tsx"), route("/ca/$caName", "cert-manager/CertAuthDetailsByIDPage/route.tsx"),