diff --git a/backend/e2e-test/routes/v1/secret-folder.spec.ts b/backend/e2e-test/routes/v1/secret-folder.spec.ts index 4d4bd7ab4..e954179a7 100644 --- a/backend/e2e-test/routes/v1/secret-folder.spec.ts +++ b/backend/e2e-test/routes/v1/secret-folder.spec.ts @@ -40,7 +40,7 @@ describe("Secret Folder Router", async () => { { name: "folder1", path: "/" }, // one in root { name: "folder1", path: "/level1/level2" }, // then create a deep one creating intermediate ones { name: "folder2", path: "/" }, - { name: "folder1", path: "/level1/level2" } // this should not create folder return same thing + { name: "folder3", path: "/level1/level2" } ])("Create folder $name in $path", async ({ name, path }) => { const createdFolder = await createFolder({ path, name }); // check for default environments @@ -57,7 +57,7 @@ describe("Secret Folder Router", async () => { { path: "/", expected: { - folders: [{ name: "folder1" }, { name: "level1" }, { name: "folder2" }], + folders: [{ name: "folder4" }, { name: "level2" }, { name: "folder5" }], length: 3 } }, @@ -162,4 +162,25 @@ describe("Secret Folder Router", async () => { expect(updatedFolderList).toHaveProperty("folders"); expect(updatedFolderList.folders.length).toEqual(0); }); + test("Creating a duplicate folder should return a 400 error", async () => { + const newFolder = await createFolder({ name: "folder-duplicate", path: "/level1/level2" }); + + const res = await testServer.inject({ + method: "POST", + url: `/api/v1/folders`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: { + workspaceId: seedData1.project.id, + environment: seedData1.environment.slug, + name: "folder-duplicate", + path: "/level1/level2" + } + }); + expect(res.statusCode).toBe(400); + const payload = JSON.parse(res.payload); + expect(payload).toHaveProperty("error"); + await deleteFolder({ path: "/level1/level2", id: newFolder.id }); + }); }); diff --git a/backend/e2e-test/routes/v2/secret-folder.spec.ts b/backend/e2e-test/routes/v2/secret-folder.spec.ts index a2bed759a..92bcc92e5 100644 --- a/backend/e2e-test/routes/v2/secret-folder.spec.ts +++ b/backend/e2e-test/routes/v2/secret-folder.spec.ts @@ -18,7 +18,7 @@ const createFolder = async (dto: { path: string; name: string }) => { return res.json().folder; }; -const deleteFolder = async (dto: { path: string; id: string }) => { +const deleteFolder = async (dto: { path: string; id: string; forceDelete?: boolean }) => { const res = await testServer.inject({ method: "DELETE", url: `/api/v2/folders/${dto.id}`, @@ -28,7 +28,8 @@ const deleteFolder = async (dto: { path: string; id: string }) => { body: { projectId: seedData1.project.id, environment: seedData1.environment.slug, - path: dto.path + path: dto.path, + forceDelete: dto.forceDelete ?? false } }); expect(res.statusCode).toBe(200); @@ -40,7 +41,7 @@ describe("Secret Folder Router", async () => { { name: "folder1", path: "/" }, // one in root { name: "folder1", path: "/level1/level2" }, // then create a deep one creating intermediate ones { name: "folder2", path: "/" }, - { name: "folder1", path: "/level1/level2" } // this should not create folder return same thing + { name: "folder3", path: "/level1/level2" } ])("Create folder $name in $path", async ({ name, path }) => { const createdFolder = await createFolder({ path, name }); // check for default environments @@ -57,7 +58,7 @@ describe("Secret Folder Router", async () => { { path: "/", expected: { - folders: [{ name: "folder1" }, { name: "level1" }, { name: "folder2" }], + folders: [{ name: "folder4" }, { name: "level2" }, { name: "folder5" }], length: 3 } }, @@ -86,7 +87,7 @@ describe("Secret Folder Router", async () => { folders: expect.arrayContaining(expected.folders.map((el) => expect.objectContaining(el))) }); - await Promise.all(newFolders.map(({ id }) => deleteFolder({ path, id }))); + await Promise.all(newFolders.map(({ id }) => deleteFolder({ path, id, forceDelete: true }))); }); test("Update a deep folder", async () => { @@ -162,4 +163,26 @@ describe("Secret Folder Router", async () => { expect(updatedFolderList).toHaveProperty("folders"); expect(updatedFolderList.folders.length).toEqual(0); }); + + test("Creating a duplicate folder should return a 400 error", async () => { + const newFolder = await createFolder({ name: "folder-duplicate", path: "/level1/level2" }); + + const res = await testServer.inject({ + method: "POST", + url: `/api/v2/folders`, + headers: { + authorization: `Bearer ${jwtAuthToken}` + }, + body: { + projectId: seedData1.project.id, + environment: seedData1.environment.slug, + name: "folder-duplicate", + path: "/level1/level2" + } + }); + expect(res.statusCode).toBe(400); + const payload = JSON.parse(res.payload); + expect(payload).toHaveProperty("error"); + await deleteFolder({ path: "/level1/level2", id: newFolder.id }); + }); }); diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index e3e8733f0..f4951d8f8 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -61,7 +61,11 @@ import { TAuthTokenServiceFactory } from "@app/services/auth-token/auth-token-se import { TCertificateServiceFactory } from "@app/services/certificate/certificate-service"; import { TCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/certificate-authority-service"; import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; +import { TCertificateEstV3ServiceFactory } from "@app/services/certificate-est-v3/certificate-est-v3-service"; +import { TCertificateProfileServiceFactory } from "@app/services/certificate-profile/certificate-profile-service"; import { TCertificateTemplateServiceFactory } from "@app/services/certificate-template/certificate-template-service"; +import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; +import { TCertificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service"; import { TCmekServiceFactory } from "@app/services/cmek/cmek-service"; import { TConvertorServiceFactory } from "@app/services/convertor/convertor-service"; import { TExternalGroupOrgRoleMappingServiceFactory } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-service"; @@ -262,7 +266,10 @@ declare module "fastify" { auditLog: TAuditLogServiceFactory; auditLogStream: TAuditLogStreamServiceFactory; certificate: TCertificateServiceFactory; + certificateV3: TCertificateV3ServiceFactory; certificateTemplate: TCertificateTemplateServiceFactory; + certificateTemplateV2: TCertificateTemplateV2ServiceFactory; + certificateProfile: TCertificateProfileServiceFactory; sshCertificateAuthority: TSshCertificateAuthorityServiceFactory; sshCertificateTemplate: TSshCertificateTemplateServiceFactory; sshHost: TSshHostServiceFactory; @@ -270,6 +277,7 @@ declare module "fastify" { certificateAuthority: TCertificateAuthorityServiceFactory; certificateAuthorityCrl: TCertificateAuthorityCrlServiceFactory; certificateEst: TCertificateEstServiceFactory; + certificateEstV3: TCertificateEstV3ServiceFactory; pkiCollection: TPkiCollectionServiceFactory; pkiSubscriber: TPkiSubscriberServiceFactory; pkiSync: TPkiSyncServiceFactory; diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 07fe2c97d..bbc27ebc1 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -266,12 +266,24 @@ import { TPkiAlerts, TPkiAlertsInsert, TPkiAlertsUpdate, + TPkiApiEnrollmentConfigs, + TPkiApiEnrollmentConfigsInsert, + TPkiApiEnrollmentConfigsUpdate, + TPkiCertificateProfiles, + TPkiCertificateProfilesInsert, + TPkiCertificateProfilesUpdate, + TPkiCertificateTemplatesV2, + TPkiCertificateTemplatesV2Insert, + TPkiCertificateTemplatesV2Update, TPkiCollectionItems, TPkiCollectionItemsInsert, TPkiCollectionItemsUpdate, TPkiCollections, TPkiCollectionsInsert, TPkiCollectionsUpdate, + TPkiEstEnrollmentConfigs, + TPkiEstEnrollmentConfigsInsert, + TPkiEstEnrollmentConfigsUpdate, TPkiSubscribers, TPkiSubscribersInsert, TPkiSubscribersUpdate, @@ -518,6 +530,9 @@ import { TUsers, TUsersInsert, TUsersUpdate, + TVaultExternalMigrationConfigs, + TVaultExternalMigrationConfigsInsert, + TVaultExternalMigrationConfigsUpdate, TWebhooks, TWebhooksInsert, TWebhooksUpdate, @@ -671,6 +686,26 @@ declare module "knex/types/tables" { TCertificateTemplatesInsert, TCertificateTemplatesUpdate >; + [TableName.PkiCertificateTemplateV2]: KnexOriginal.CompositeTableType< + TPkiCertificateTemplatesV2, + TPkiCertificateTemplatesV2Insert, + TPkiCertificateTemplatesV2Update + >; + [TableName.PkiCertificateProfile]: KnexOriginal.CompositeTableType< + TPkiCertificateProfiles, + TPkiCertificateProfilesInsert, + TPkiCertificateProfilesUpdate + >; + [TableName.PkiEstEnrollmentConfig]: KnexOriginal.CompositeTableType< + TPkiEstEnrollmentConfigs, + TPkiEstEnrollmentConfigsInsert, + TPkiEstEnrollmentConfigsUpdate + >; + [TableName.PkiApiEnrollmentConfig]: KnexOriginal.CompositeTableType< + TPkiApiEnrollmentConfigs, + TPkiApiEnrollmentConfigsInsert, + TPkiApiEnrollmentConfigsUpdate + >; [TableName.CertificateTemplateEstConfig]: KnexOriginal.CompositeTableType< TCertificateTemplateEstConfigs, TCertificateTemplateEstConfigsInsert, @@ -1345,5 +1380,10 @@ declare module "knex/types/tables" { TAdditionalPrivilegesInsert, TAdditionalPrivilegesUpdate >; + [TableName.VaultExternalMigrationConfig]: KnexOriginal.CompositeTableType< + TVaultExternalMigrationConfigs, + TVaultExternalMigrationConfigsInsert, + TVaultExternalMigrationConfigsUpdate + >; } } diff --git a/backend/src/db/migrations/20251007133321_pki-v3-tables.ts b/backend/src/db/migrations/20251007133321_pki-v3-tables.ts new file mode 100644 index 000000000..713953f14 --- /dev/null +++ b/backend/src/db/migrations/20251007133321_pki-v3-tables.ts @@ -0,0 +1,117 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.PkiCertificateTemplateV2))) { + await knex.schema.createTable(TableName.PkiCertificateTemplateV2, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project); + + t.string("name").notNullable(); + t.string("description"); + + t.jsonb("subject"); + t.jsonb("sans"); + t.jsonb("keyUsages"); + t.jsonb("extendedKeyUsages"); + t.jsonb("algorithms"); + t.jsonb("validity"); + + t.timestamps(true, true, true); + + t.unique(["name", "projectId"]); + }); + + await createOnUpdateTrigger(knex, TableName.PkiCertificateTemplateV2); + } + + if (!(await knex.schema.hasTable(TableName.PkiEstEnrollmentConfig))) { + await knex.schema.createTable(TableName.PkiEstEnrollmentConfig, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + t.boolean("disableBootstrapCaValidation").defaultTo(false); + t.text("hashedPassphrase").notNullable(); + t.binary("encryptedCaChain"); + + t.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.PkiEstEnrollmentConfig); + } + + if (!(await knex.schema.hasTable(TableName.PkiApiEnrollmentConfig))) { + await knex.schema.createTable(TableName.PkiApiEnrollmentConfig, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + t.boolean("autoRenew").defaultTo(false); + t.integer("autoRenewDays"); + + t.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.PkiApiEnrollmentConfig); + } + + if (!(await knex.schema.hasTable(TableName.PkiCertificateProfile))) { + await knex.schema.createTable(TableName.PkiCertificateProfile, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("projectId").notNullable(); + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + + t.uuid("caId").notNullable(); + t.foreign("caId").references("id").inTable(TableName.CertificateAuthority); + + t.uuid("certificateTemplateId").notNullable(); + t.foreign("certificateTemplateId").references("id").inTable(TableName.PkiCertificateTemplateV2); + + t.string("slug").notNullable(); + t.string("description"); + t.string("enrollmentType").notNullable().checkIn(["api", "est"]); + + t.uuid("estConfigId"); + t.foreign("estConfigId").references("id").inTable(TableName.PkiEstEnrollmentConfig).onDelete("SET NULL"); + + t.uuid("apiConfigId"); + t.foreign("apiConfigId").references("id").inTable(TableName.PkiApiEnrollmentConfig).onDelete("SET NULL"); + + t.timestamps(true, true, true); + + t.unique(["slug", "projectId"]); + }); + + await createOnUpdateTrigger(knex, TableName.PkiCertificateProfile); + } + + if (!(await knex.schema.hasColumn(TableName.Certificate, "profileId"))) { + await knex.schema.alterTable(TableName.Certificate, (t) => { + t.uuid("profileId"); + t.foreign("profileId").references("id").inTable(TableName.PkiCertificateProfile).onDelete("SET NULL"); + t.index("profileId"); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.Certificate, "profileId")) { + await knex.schema.alterTable(TableName.Certificate, (t) => { + t.dropForeign(["profileId"]); + t.dropIndex("profileId"); + t.dropColumn("profileId"); + }); + } + + await knex.schema.dropTableIfExists(TableName.PkiCertificateProfile); + await dropOnUpdateTrigger(knex, TableName.PkiCertificateProfile); + + await knex.schema.dropTableIfExists(TableName.PkiApiEnrollmentConfig); + await dropOnUpdateTrigger(knex, TableName.PkiApiEnrollmentConfig); + + await knex.schema.dropTableIfExists(TableName.PkiEstEnrollmentConfig); + await dropOnUpdateTrigger(knex, TableName.PkiEstEnrollmentConfig); + + await knex.schema.dropTableIfExists(TableName.PkiCertificateTemplateV2); + await dropOnUpdateTrigger(knex, TableName.PkiCertificateTemplateV2); +} diff --git a/backend/src/db/migrations/20251013104547_add-in-platform-external-migration.ts b/backend/src/db/migrations/20251013104547_add-in-platform-external-migration.ts new file mode 100644 index 000000000..e5f74a0e0 --- /dev/null +++ b/backend/src/db/migrations/20251013104547_add-in-platform-external-migration.ts @@ -0,0 +1,29 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.VaultExternalMigrationConfig))) { + await knex.schema.createTable(TableName.VaultExternalMigrationConfig, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.uuid("orgId").notNullable(); + t.foreign("orgId").references("id").inTable(TableName.Organization).onDelete("CASCADE"); + + t.string("namespace").notNullable(); + + t.uuid("connectionId"); + t.foreign("connectionId").references("id").inTable(TableName.AppConnection); + + t.timestamps(true, true, true); + t.unique(["orgId", "namespace"]); + }); + + await createOnUpdateTrigger(knex, TableName.VaultExternalMigrationConfig); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.VaultExternalMigrationConfig); + await dropOnUpdateTrigger(knex, TableName.VaultExternalMigrationConfig); +} diff --git a/backend/src/db/schemas/certificates.ts b/backend/src/db/schemas/certificates.ts index 6bedf01ad..63122f662 100644 --- a/backend/src/db/schemas/certificates.ts +++ b/backend/src/db/schemas/certificates.ts @@ -26,7 +26,8 @@ export const CertificatesSchema = z.object({ keyUsages: z.string().array().nullable().optional(), extendedKeyUsages: z.string().array().nullable().optional(), projectId: z.string(), - pkiSubscriberId: z.string().uuid().nullable().optional() + pkiSubscriberId: z.string().uuid().nullable().optional(), + profileId: z.string().uuid().nullable().optional() }); export type TCertificates = z.infer; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index 2a10e0f1b..4f0f221ff 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -92,8 +92,12 @@ export * from "./pam-folders"; export * from "./pam-resources"; export * from "./pam-sessions"; export * from "./pki-alerts"; +export * from "./pki-api-enrollment-configs"; +export * from "./pki-certificate-profiles"; +export * from "./pki-certificate-templates-v2"; export * from "./pki-collection-items"; export * from "./pki-collections"; +export * from "./pki-est-enrollment-configs"; export * from "./pki-subscribers"; export * from "./pki-syncs"; export * from "./project-bots"; @@ -176,5 +180,6 @@ export * from "./user-aliases"; export * from "./user-encryption-keys"; export * from "./user-group-membership"; export * from "./users"; +export * from "./vault-external-migration-configs"; export * from "./webhooks"; export * from "./workflow-integrations"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index d3537b028..28e9471ad 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -23,6 +23,10 @@ export enum TableName { CertificateBody = "certificate_bodies", CertificateSecret = "certificate_secrets", CertificateTemplate = "certificate_templates", + PkiCertificateTemplateV2 = "pki_certificate_templates_v2", + PkiCertificateProfile = "pki_certificate_profiles", + PkiEstEnrollmentConfig = "pki_est_enrollment_configs", + PkiApiEnrollmentConfig = "pki_api_enrollment_configs", PkiSubscriber = "pki_subscribers", PkiAlert = "pki_alerts", PkiCollection = "pki_collections", @@ -203,7 +207,9 @@ export enum TableName { PamFolder = "pam_folders", PamResource = "pam_resources", PamAccount = "pam_accounts", - PamSession = "pam_sessions" + PamSession = "pam_sessions", + + VaultExternalMigrationConfig = "vault_external_migration_configs" } export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt" | "commitId"; diff --git a/backend/src/db/schemas/pki-api-enrollment-configs.ts b/backend/src/db/schemas/pki-api-enrollment-configs.ts new file mode 100644 index 000000000..710b0dee4 --- /dev/null +++ b/backend/src/db/schemas/pki-api-enrollment-configs.ts @@ -0,0 +1,22 @@ +// 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 PkiApiEnrollmentConfigsSchema = z.object({ + id: z.string().uuid(), + autoRenew: z.boolean().default(false).nullable().optional(), + autoRenewDays: z.number().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TPkiApiEnrollmentConfigs = z.infer; +export type TPkiApiEnrollmentConfigsInsert = Omit, TImmutableDBKeys>; +export type TPkiApiEnrollmentConfigsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/pki-certificate-profiles.ts b/backend/src/db/schemas/pki-certificate-profiles.ts new file mode 100644 index 000000000..368770c3e --- /dev/null +++ b/backend/src/db/schemas/pki-certificate-profiles.ts @@ -0,0 +1,28 @@ +// 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 PkiCertificateProfilesSchema = z.object({ + id: z.string().uuid(), + projectId: z.string(), + caId: z.string().uuid(), + certificateTemplateId: z.string().uuid(), + slug: z.string(), + description: z.string().nullable().optional(), + enrollmentType: z.string(), + estConfigId: z.string().uuid().nullable().optional(), + apiConfigId: z.string().uuid().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TPkiCertificateProfiles = z.infer; +export type TPkiCertificateProfilesInsert = Omit, TImmutableDBKeys>; +export type TPkiCertificateProfilesUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/pki-certificate-templates-v2.ts b/backend/src/db/schemas/pki-certificate-templates-v2.ts new file mode 100644 index 000000000..de4603887 --- /dev/null +++ b/backend/src/db/schemas/pki-certificate-templates-v2.ts @@ -0,0 +1,29 @@ +// 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 PkiCertificateTemplatesV2Schema = z.object({ + id: z.string().uuid(), + projectId: z.string(), + name: z.string(), + description: z.string().nullable().optional(), + subject: z.unknown().nullable().optional(), + sans: z.unknown().nullable().optional(), + keyUsages: z.unknown().nullable().optional(), + extendedKeyUsages: z.unknown().nullable().optional(), + algorithms: z.unknown().nullable().optional(), + validity: z.unknown().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TPkiCertificateTemplatesV2 = z.infer; +export type TPkiCertificateTemplatesV2Insert = Omit, TImmutableDBKeys>; +export type TPkiCertificateTemplatesV2Update = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/pki-est-enrollment-configs.ts b/backend/src/db/schemas/pki-est-enrollment-configs.ts new file mode 100644 index 000000000..4a3b16eeb --- /dev/null +++ b/backend/src/db/schemas/pki-est-enrollment-configs.ts @@ -0,0 +1,25 @@ +// 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 { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const PkiEstEnrollmentConfigsSchema = z.object({ + id: z.string().uuid(), + disableBootstrapCaValidation: z.boolean().default(false).nullable().optional(), + hashedPassphrase: z.string(), + encryptedCaChain: zodBuffer.nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TPkiEstEnrollmentConfigs = z.infer; +export type TPkiEstEnrollmentConfigsInsert = Omit, TImmutableDBKeys>; +export type TPkiEstEnrollmentConfigsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/vault-external-migration-configs.ts b/backend/src/db/schemas/vault-external-migration-configs.ts new file mode 100644 index 000000000..310166669 --- /dev/null +++ b/backend/src/db/schemas/vault-external-migration-configs.ts @@ -0,0 +1,26 @@ +// 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 VaultExternalMigrationConfigsSchema = z.object({ + id: z.string().uuid(), + orgId: z.string().uuid(), + namespace: z.string(), + connectionId: z.string().uuid().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TVaultExternalMigrationConfigs = z.infer; +export type TVaultExternalMigrationConfigsInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TVaultExternalMigrationConfigsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/ee/routes/est/certificate-est-router.ts b/backend/src/ee/routes/est/certificate-est-router.ts index 33ebe910d..d5876782d 100644 --- a/backend/src/ee/routes/est/certificate-est-router.ts +++ b/backend/src/ee/routes/est/certificate-est-router.ts @@ -8,6 +8,35 @@ import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; export const registerCertificateEstRouter = async (server: FastifyZodProvider) => { const appCfg = getConfig(); + const getIdentifierType = async (identifier: string): Promise<"template" | "profile" | null> => { + try { + // Try to find as profile first using internal access + await server.services.certificateProfile.getEstConfigurationByProfile({ + profileId: identifier, + isInternal: true + }); + return "profile"; + } catch (profileError) { + try { + await server.services.certificateTemplate.getEstConfiguration({ + isInternal: true, + certificateTemplateId: identifier + }); + return "template"; + } catch (templateError) { + server.log.debug( + { + identifier, + profileError: profileError instanceof Error ? profileError.message : "Unknown error", + templateError: templateError instanceof Error ? templateError.message : "Unknown error" + }, + "EST identifier not found as profile or template" + ); + return null; + } + } + }; + // add support for CSR bodies server.addContentTypeParser("application/pkcs10", { parseAs: "string" }, (_, body, done) => { try { @@ -59,11 +88,29 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) = return; } - const certificateTemplateId = urlFragments.slice(-2)[0]; - const estConfig = await server.services.certificateTemplate.getEstConfiguration({ - isInternal: true, - certificateTemplateId - }); + const identifier = urlFragments.slice(-2)[0]; + + const identifierType = await getIdentifierType(identifier); + if (!identifierType) { + res.raw.statusCode = 404; + res.raw.setHeader("Content-Type", "text/plain"); + res.raw.write("Certificate template or profile not found"); + res.raw.flushHeaders(); + return; + } + + let estConfig; + if (identifierType === "profile") { + estConfig = await server.services.certificateProfile.getEstConfigurationByProfile({ + profileId: identifier, + isInternal: true + }); + } else { + estConfig = await server.services.certificateTemplate.getEstConfiguration({ + isInternal: true, + certificateTemplateId: identifier + }); + } if (!estConfig.isEnabled) { throw new BadRequestError({ @@ -95,14 +142,14 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) = server.route({ method: "POST", - url: "/:certificateTemplateId/simpleenroll", + url: "/:identifier/simpleenroll", config: { rateLimit: writeLimit }, schema: { body: z.string().min(1), params: z.object({ - certificateTemplateId: z.string().min(1) + identifier: z.string().min(1) }), response: { 200: z.string() @@ -112,9 +159,23 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) = void res.header("Content-Type", "application/pkcs7-mime; smime-type=certs-only"); void res.header("Content-Transfer-Encoding", "base64"); + const { identifier } = req.params; + const identifierType = await getIdentifierType(identifier); + + if (!identifierType) { + throw new BadRequestError({ message: "Certificate template or profile not found" }); + } + + if (identifierType === "profile") { + return server.services.certificateEstV3.simpleEnrollByProfile({ + csr: req.body, + profileId: identifier, + sslClientCert: req.headers[appCfg.SSL_CLIENT_CERTIFICATE_HEADER_KEY] as string + }); + } return server.services.certificateEst.simpleEnroll({ csr: req.body, - certificateTemplateId: req.params.certificateTemplateId, + certificateTemplateId: identifier, sslClientCert: req.headers[appCfg.SSL_CLIENT_CERTIFICATE_HEADER_KEY] as string }); } @@ -122,14 +183,14 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) = server.route({ method: "POST", - url: "/:certificateTemplateId/simplereenroll", + url: "/:identifier/simplereenroll", config: { rateLimit: writeLimit }, schema: { body: z.string().min(1), params: z.object({ - certificateTemplateId: z.string().min(1) + identifier: z.string().min(1) }), response: { 200: z.string() @@ -139,9 +200,23 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) = void res.header("Content-Type", "application/pkcs7-mime; smime-type=certs-only"); void res.header("Content-Transfer-Encoding", "base64"); + const { identifier } = req.params; + const identifierType = await getIdentifierType(identifier); + + if (!identifierType) { + throw new BadRequestError({ message: "Certificate template or profile not found" }); + } + + if (identifierType === "profile") { + return server.services.certificateEstV3.simpleReenrollByProfile({ + csr: req.body, + profileId: identifier, + sslClientCert: req.headers[appCfg.SSL_CLIENT_CERTIFICATE_HEADER_KEY] as string + }); + } return server.services.certificateEst.simpleReenroll({ csr: req.body, - certificateTemplateId: req.params.certificateTemplateId, + certificateTemplateId: identifier, sslClientCert: req.headers[appCfg.SSL_CLIENT_CERTIFICATE_HEADER_KEY] as string }); } @@ -149,13 +224,13 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) = server.route({ method: "GET", - url: "/:certificateTemplateId/cacerts", + url: "/:identifier/cacerts", config: { rateLimit: readLimit }, schema: { params: z.object({ - certificateTemplateId: z.string().min(1) + identifier: z.string().min(1) }), response: { 200: z.string() @@ -165,8 +240,20 @@ export const registerCertificateEstRouter = async (server: FastifyZodProvider) = void res.header("Content-Type", "application/pkcs7-mime; smime-type=certs-only"); void res.header("Content-Transfer-Encoding", "base64"); + const { identifier } = req.params; + const identifierType = await getIdentifierType(identifier); + + if (!identifierType) { + throw new BadRequestError({ message: "Certificate template or profile not found" }); + } + + if (identifierType === "profile") { + return server.services.certificateEstV3.getCaCertsByProfile({ + profileId: identifier + }); + } return server.services.certificateEst.getCaCerts({ - certificateTemplateId: req.params.certificateTemplateId + certificateTemplateId: identifier }); } }); diff --git a/backend/src/ee/routes/v1/kmip-spec-router.ts b/backend/src/ee/routes/v1/kmip-spec-router.ts index c7db12de9..6fcf05d99 100644 --- a/backend/src/ee/routes/v1/kmip-spec-router.ts +++ b/backend/src/ee/routes/v1/kmip-spec-router.ts @@ -129,7 +129,7 @@ export const registerKmipSpecRouter = async (server: FastifyZodProvider) => { id: z.string(), value: z.string(), algorithm: z.string(), - kmipMetadata: z.record(z.any()).optional() + kmipMetadata: z.record(z.any()).nullish() }) } }, @@ -435,7 +435,7 @@ export const registerKmipSpecRouter = async (server: FastifyZodProvider) => { key: z.string(), name: z.string(), algorithm: z.nativeEnum(SymmetricKeyAlgorithm), - kmipMetadata: z.record(z.any()).optional() + kmipMetadata: z.record(z.any()).nullish() }), response: { 200: z.object({ 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 bc50283d4..f3c95e434 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -352,9 +352,18 @@ export enum EventType { UPDATE_CERTIFICATE_TEMPLATE = "update-certificate-template", DELETE_CERTIFICATE_TEMPLATE = "delete-certificate-template", GET_CERTIFICATE_TEMPLATE = "get-certificate-template", + LIST_CERTIFICATE_TEMPLATES = "list-certificate-templates", CREATE_CERTIFICATE_TEMPLATE_EST_CONFIG = "create-certificate-template-est-config", UPDATE_CERTIFICATE_TEMPLATE_EST_CONFIG = "update-certificate-template-est-config", GET_CERTIFICATE_TEMPLATE_EST_CONFIG = "get-certificate-template-est-config", + CREATE_CERTIFICATE_PROFILE = "create-certificate-profile", + UPDATE_CERTIFICATE_PROFILE = "update-certificate-profile", + DELETE_CERTIFICATE_PROFILE = "delete-certificate-profile", + GET_CERTIFICATE_PROFILE = "get-certificate-profile", + LIST_CERTIFICATE_PROFILES = "list-certificate-profiles", + ISSUE_CERTIFICATE_FROM_PROFILE = "issue-certificate-from-profile", + SIGN_CERTIFICATE_FROM_PROFILE = "sign-certificate-from-profile", + ORDER_CERTIFICATE_FROM_PROFILE = "order-certificate-from-profile", ATTEMPT_CREATE_SLACK_INTEGRATION = "attempt-create-slack-integration", ATTEMPT_REINSTALL_SLACK_INTEGRATION = "attempt-reinstall-slack-integration", GET_PROJECT_SLACK_CONFIG = "get-project-slack-config", @@ -2512,46 +2521,6 @@ interface LoadProjectKmsBackupEvent { metadata: Record; // no metadata yet } -interface CreateCertificateTemplate { - type: EventType.CREATE_CERTIFICATE_TEMPLATE; - metadata: { - certificateTemplateId: string; - caId: string; - pkiCollectionId?: string; - name: string; - commonName: string; - subjectAlternativeName: string; - ttl: string; - }; -} - -interface GetCertificateTemplate { - type: EventType.GET_CERTIFICATE_TEMPLATE; - metadata: { - certificateTemplateId: string; - }; -} - -interface UpdateCertificateTemplate { - type: EventType.UPDATE_CERTIFICATE_TEMPLATE; - metadata: { - certificateTemplateId: string; - caId: string; - pkiCollectionId?: string; - name: string; - commonName: string; - subjectAlternativeName: string; - ttl: string; - }; -} - -interface DeleteCertificateTemplate { - type: EventType.DELETE_CERTIFICATE_TEMPLATE; - metadata: { - certificateTemplateId: string; - }; -} - interface OrgAdminAccessProjectEvent { type: EventType.ORG_ADMIN_ACCESS_PROJECT; metadata: { @@ -2598,6 +2567,138 @@ interface GetCertificateTemplateEstConfig { }; } +interface CreateCertificateTemplate { + type: EventType.CREATE_CERTIFICATE_TEMPLATE; + metadata: + | { + certificateTemplateId: string; + name: string; + projectId: string; + } + | { + certificateTemplateId: string; + caId: string; + pkiCollectionId: string; + name: string; + commonName: string; + subjectAlternativeName: string; + ttl: string; + projectId: string; + }; +} + +interface UpdateCertificateTemplate { + type: EventType.UPDATE_CERTIFICATE_TEMPLATE; + metadata: + | { + certificateTemplateId: string; + name: string; + } + | { + certificateTemplateId: string; + caId: string; + pkiCollectionId: string; + name: string; + commonName: string; + subjectAlternativeName: string; + ttl: string; + projectId: string; + }; +} + +interface DeleteCertificateTemplate { + type: EventType.DELETE_CERTIFICATE_TEMPLATE; + metadata: { + certificateTemplateId: string; + name: string; + }; +} + +interface GetCertificateTemplate { + type: EventType.GET_CERTIFICATE_TEMPLATE; + metadata: { + certificateTemplateId: string; + name: string; + }; +} + +interface ListCertificateTemplates { + type: EventType.LIST_CERTIFICATE_TEMPLATES; + metadata: { + projectId: string; + }; +} + +interface CreateCertificateProfile { + type: EventType.CREATE_CERTIFICATE_PROFILE; + metadata: { + certificateProfileId: string; + name: string; + projectId: string; + enrollmentType: string; + }; +} + +interface UpdateCertificateProfile { + type: EventType.UPDATE_CERTIFICATE_PROFILE; + metadata: { + certificateProfileId: string; + name: string; + }; +} + +interface DeleteCertificateProfile { + type: EventType.DELETE_CERTIFICATE_PROFILE; + metadata: { + certificateProfileId: string; + name: string; + }; +} + +interface GetCertificateProfile { + type: EventType.GET_CERTIFICATE_PROFILE; + metadata: { + certificateProfileId: string; + name: string; + }; +} + +interface ListCertificateProfiles { + type: EventType.LIST_CERTIFICATE_PROFILES; + metadata: { + projectId: string; + }; +} + +interface IssueCertificateFromProfile { + type: EventType.ISSUE_CERTIFICATE_FROM_PROFILE; + metadata: { + certificateProfileId: string; + certificateId: string; + commonName: string; + profileName: string; + }; +} + +interface SignCertificateFromProfile { + type: EventType.SIGN_CERTIFICATE_FROM_PROFILE; + metadata: { + certificateProfileId: string; + certificateId: string; + profileName: string; + commonName: string; + }; +} + +interface OrderCertificateFromProfile { + type: EventType.ORDER_CERTIFICATE_FROM_PROFILE; + metadata: { + certificateProfileId: string; + orderId: string; + profileName: string; + }; +} + interface AttemptCreateSlackIntegration { type: EventType.ATTEMPT_CREATE_SLACK_INTEGRATION; metadata: { @@ -4051,13 +4152,22 @@ export type Event = | LoadProjectKmsBackupEvent | OrgAdminAccessProjectEvent | OrgAdminBypassSSOEvent - | CreateCertificateTemplate - | UpdateCertificateTemplate - | GetCertificateTemplate - | DeleteCertificateTemplate | CreateCertificateTemplateEstConfig | UpdateCertificateTemplateEstConfig | GetCertificateTemplateEstConfig + | CreateCertificateTemplate + | UpdateCertificateTemplate + | DeleteCertificateTemplate + | GetCertificateTemplate + | ListCertificateTemplates + | CreateCertificateProfile + | UpdateCertificateProfile + | DeleteCertificateProfile + | GetCertificateProfile + | ListCertificateProfiles + | IssueCertificateFromProfile + | SignCertificateFromProfile + | OrderCertificateFromProfile | GetAzureAdCsTemplatesEvent | AttemptCreateSlackIntegration | AttemptReinstallSlackIntegration diff --git a/backend/src/ee/services/kmip/kmip-types.ts b/backend/src/ee/services/kmip/kmip-types.ts index c37d511c6..695f46942 100644 --- a/backend/src/ee/services/kmip/kmip-types.ts +++ b/backend/src/ee/services/kmip/kmip-types.ts @@ -78,7 +78,7 @@ export type TKmipRegisterDTO = { name: string; key: string; algorithm: SymmetricKeyAlgorithm; - kmipMetadata?: Record; + kmipMetadata?: Record | null; } & KmipOperationBaseDTO; export type TSetupOrgKmipDTO = { diff --git a/backend/src/ee/services/license/__mocks__/license-fns.ts b/backend/src/ee/services/license/__mocks__/license-fns.ts index f139ff2c1..d303859bb 100644 --- a/backend/src/ee/services/license/__mocks__/license-fns.ts +++ b/backend/src/ee/services/license/__mocks__/license-fns.ts @@ -33,7 +33,8 @@ export const getDefaultOnPremFeatures = () => { enterpriseSecretSyncs: false, enterpriseCertificateSyncs: false, enterpriseAppConnections: true, - machineIdentityAuthTemplates: false + machineIdentityAuthTemplates: false, + pkiLegacyTemplates: false }; }; diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index 2a3cf82cc..aba2c5e78 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -67,6 +67,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ fips: false, eventSubscriptions: false, machineIdentityAuthTemplates: false, + pkiLegacyTemplates: false, pam: false }); diff --git a/backend/src/ee/services/license/license-service.ts b/backend/src/ee/services/license/license-service.ts index 3a5928713..072167cfd 100644 --- a/backend/src/ee/services/license/license-service.ts +++ b/backend/src/ee/services/license/license-service.ts @@ -218,6 +218,20 @@ export const licenseServiceFactory = ({ const identityUsed = await licenseDAL.countOrgUsersAndIdentities(orgId); currentPlan.identitiesUsed = identityUsed; + if (currentPlan.identityLimit && currentPlan.identityLimit !== identityUsed) { + try { + await licenseServerCloudApi.request.patch(`/api/license-server/v1/customers/${org.customerId}/cloud-plan`, { + quantity: membersUsed, + quantityIdentities: identityUsed + }); + } catch (error) { + logger.error( + error, + `Update seats used: encountered an error when updating plan for customer [customerId=${org.customerId}]` + ); + } + } + await keyStore.setItemWithExpiry( FEATURE_CACHE_KEY(org.id), LICENSE_SERVER_CLOUD_PLAN_TTL, diff --git a/backend/src/ee/services/license/license-types.ts b/backend/src/ee/services/license/license-types.ts index 9cdcfcc3d..2276dcf36 100644 --- a/backend/src/ee/services/license/license-types.ts +++ b/backend/src/ee/services/license/license-types.ts @@ -78,6 +78,7 @@ export type TFeatureSet = { enterpriseCertificateSyncs: false; enterpriseAppConnections: false; machineIdentityAuthTemplates: false; + pkiLegacyTemplates: false; fips: false; eventSubscriptions: false; pam: false; diff --git a/backend/src/ee/services/permission/default-roles.ts b/backend/src/ee/services/permission/default-roles.ts index b9cabe022..34876f739 100644 --- a/backend/src/ee/services/permission/default-roles.ts +++ b/backend/src/ee/services/permission/default-roles.ts @@ -5,6 +5,7 @@ import { ProjectPermissionAppConnectionActions, ProjectPermissionAuditLogsActions, ProjectPermissionCertificateActions, + ProjectPermissionCertificateProfileActions, ProjectPermissionCmekActions, ProjectPermissionCommitsActions, ProjectPermissionDynamicSecretActions, @@ -72,8 +73,8 @@ const buildAdminPermissionRules = () => { ProjectPermissionPkiTemplateActions.Edit, ProjectPermissionPkiTemplateActions.Create, ProjectPermissionPkiTemplateActions.Delete, - ProjectPermissionPkiTemplateActions.IssueCert, - ProjectPermissionPkiTemplateActions.ListCerts + ProjectPermissionPkiTemplateActions.IssueCert, // deprecated + ProjectPermissionPkiTemplateActions.ListCerts // deprecated ], ProjectPermissionSub.CertificateTemplates ); @@ -99,6 +100,17 @@ const buildAdminPermissionRules = () => { ProjectPermissionSub.Certificates ); + can( + [ + ProjectPermissionCertificateProfileActions.Read, + ProjectPermissionCertificateProfileActions.Edit, + ProjectPermissionCertificateProfileActions.Create, + ProjectPermissionCertificateProfileActions.Delete, + ProjectPermissionCertificateProfileActions.IssueCert + ], + ProjectPermissionSub.CertificateProfiles + ); + can( [ProjectPermissionCommitsActions.Read, ProjectPermissionCommitsActions.PerformRollback], ProjectPermissionSub.Commits @@ -443,6 +455,7 @@ const buildMemberPermissionRules = () => { // double check if all CRUD are needed for CA and Certificates can([ProjectPermissionActions.Read], ProjectPermissionSub.CertificateAuthorities); + can([ProjectPermissionPkiTemplateActions.Read], ProjectPermissionSub.CertificateTemplates); can( [ @@ -454,7 +467,15 @@ const buildMemberPermissionRules = () => { ProjectPermissionSub.Certificates ); - can([ProjectPermissionPkiTemplateActions.Read], ProjectPermissionSub.CertificateTemplates); + can( + [ + ProjectPermissionCertificateProfileActions.Read, + ProjectPermissionCertificateProfileActions.Edit, + ProjectPermissionCertificateProfileActions.Create, + ProjectPermissionCertificateProfileActions.Delete + ], + ProjectPermissionSub.CertificateProfiles + ); can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiAlerts); can([ProjectPermissionActions.Read], ProjectPermissionSub.PkiCollections); diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index d2c5b30db..bb62440c1 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -111,6 +111,14 @@ export enum ProjectPermissionPkiSubscriberActions { ListCerts = "list-certs" } +export enum ProjectPermissionCertificateProfileActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete", + IssueCert = "issue-cert" +} + export enum ProjectPermissionSecretSyncActions { Read = "read", Create = "create", @@ -249,7 +257,8 @@ export enum ProjectPermissionSub { PamFolders = "pam-folders", PamResources = "pam-resources", PamAccounts = "pam-accounts", - PamSessions = "pam-sessions" + PamSessions = "pam-sessions", + CertificateProfiles = "certificate-profiles" } export type SecretSubjectFields = { @@ -438,7 +447,8 @@ export type ProjectPermissionSet = ProjectPermissionPamAccountActions, ProjectPermissionSub.PamAccounts | (ForcedSubject & PamAccountSubjectFields) ] - | [ProjectPermissionPamSessionActions, ProjectPermissionSub.PamSessions]; + | [ProjectPermissionPamSessionActions, ProjectPermissionSub.PamSessions] + | [ProjectPermissionCertificateProfileActions, ProjectPermissionSub.CertificateProfiles]; const SECRET_PATH_MISSING_SLASH_ERR_MSG = "Invalid Secret Path; it must start with a '/'"; const SECRET_PATH_PERMISSION_OPERATOR_SCHEMA = z.union([ @@ -1109,6 +1119,13 @@ export const ProjectPermissionV2Schema = z.discriminatedUnion("subject", [ "When specified, only matching conditions will be allowed to access given resource." ).optional() }), + z.object({ + subject: z.literal(ProjectPermissionSub.CertificateProfiles).describe("The entity this permission pertains to."), + inverted: z.boolean().optional().describe("Whether rule allows or forbids."), + action: CASL_ACTION_SCHEMA_NATIVE_ENUM(ProjectPermissionCertificateProfileActions).describe( + "Describe what action an entity can take." + ) + }), ...GeneralPermissionSchema ]); diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 2d0de59a2..5a336fa11 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -57,6 +57,7 @@ export enum ApiDocsTags { PkiCertificateAuthorities = "PKI Certificate Authorities", PkiCertificates = "PKI Certificates", PkiCertificateTemplates = "PKI Certificate Templates", + PkiCertificateProfiles = "PKI Certificate Profiles", PkiCertificateCollections = "PKI Certificate Collections", PkiAlerting = "PKI Alerting", PkiSubscribers = "PKI Subscribers", @@ -122,16 +123,16 @@ export const IDENTITIES = { hasDeleteProtection: "Prevents deletion of the identity when enabled." }, UPDATE: { - identityId: "The ID of the identity to update.", + identityId: "The ID of the machine identity to update.", name: "The new name of the identity.", role: "The new role of the identity.", hasDeleteProtection: "Prevents deletion of the identity when enabled." }, DELETE: { - identityId: "The ID of the identity to delete." + identityId: "The ID of the machine identity to delete." }, GET_BY_ID: { - identityId: "The ID of the identity to get details.", + identityId: "The ID of the machine identity to get details.", orgId: "The ID of the org of the identity" }, LIST: { @@ -156,7 +157,7 @@ export const UNIVERSAL_AUTH = { clientSecret: "Your Machine Identity Client Secret." }, ATTACH: { - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", clientSecretTrustedIps: "A list of IPs or CIDR ranges that the Client Secret can be used from together with the Client ID to get back an access token. You can use 0.0.0.0/0, to allow usage from any network address.", accessTokenTrustedIps: @@ -175,13 +176,13 @@ export const UNIVERSAL_AUTH = { "How long to wait from the most recent failed login until resetting the lockout counter." }, RETRIEVE: { - identityId: "The ID of the identity to retrieve the auth method for." + identityId: "The ID of the machine identity to retrieve the auth method for." }, REVOKE: { - identityId: "The ID of the identity to revoke the auth method for." + identityId: "The ID of the machine identity to revoke the auth method for." }, UPDATE: { - identityId: "The ID of the identity to update the auth method for.", + identityId: "The ID of the machine identity to update the auth method for.", clientSecretTrustedIps: "The new list of IPs or CIDR ranges that the Client Secret can be used from.", accessTokenTrustedIps: "The new list of IPs or CIDR ranges that access tokens can be used from.", accessTokenTTL: "The new lifetime for an access token in seconds.", @@ -195,25 +196,25 @@ export const UNIVERSAL_AUTH = { "How long to wait from the most recent failed login until resetting the lockout counter." }, CREATE_CLIENT_SECRET: { - identityId: "The ID of the identity to create a client secret for.", + identityId: "The ID of the machine identity to create a client secret for.", description: "The description of the client secret.", numUsesLimit: "The maximum number of times that the client secret can be used; a value of 0 implies infinite number of uses.", ttl: "The lifetime for the client secret in seconds." }, LIST_CLIENT_SECRETS: { - identityId: "The ID of the identity to list client secrets for." + identityId: "The ID of the machine identity to list client secrets for." }, GET_CLIENT_SECRET: { - identityId: "The ID of the identity to get the client secret from.", + identityId: "The ID of the machine identity to get the client secret from.", clientSecretId: "The ID of the client secret to get details." }, REVOKE_CLIENT_SECRET: { - identityId: "The ID of the identity to revoke the client secret from.", + identityId: "The ID of the machine identity to revoke the client secret from.", clientSecretId: "The ID of the client secret to revoke." }, CLEAR_CLIENT_LOCKOUTS: { - identityId: "The ID of the identity to clear the client lockouts from." + identityId: "The ID of the machine identity to clear the client lockouts from." }, RENEW_ACCESS_TOKEN: { accessToken: "The access token to renew." @@ -225,13 +226,13 @@ export const UNIVERSAL_AUTH = { export const LDAP_AUTH = { LOGIN: { - identityId: "The ID of the identity to login.", + identityId: "The ID of the machine identity to login.", username: "The username of the LDAP user to login.", password: "The password of the LDAP user to login." }, ATTACH: { templateId: "The ID of the identity auth template to attach the configuration onto.", - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", url: "The URL of the LDAP server.", allowedFields: "The comma-separated array of key/value pairs of required fields that the LDAP entry must have in order to authenticate.", @@ -251,7 +252,7 @@ export const LDAP_AUTH = { "How long to wait from the most recent failed login until resetting the lockout counter." }, UPDATE: { - identityId: "The ID of the identity to update the configuration for.", + identityId: "The ID of the machine identity to update the configuration for.", url: "The new URL of the LDAP server.", allowedFields: "The comma-separated list of allowed fields to return from the LDAP user.", searchBase: "The new base DN to search for the LDAP user.", @@ -271,19 +272,19 @@ export const LDAP_AUTH = { "How long to wait from the most recent failed login until resetting the lockout counter." }, RETRIEVE: { - identityId: "The ID of the identity to retrieve the configuration for." + identityId: "The ID of the machine identity to retrieve the configuration for." }, REVOKE: { - identityId: "The ID of the identity to revoke the configuration for." + identityId: "The ID of the machine identity to revoke the configuration for." }, CLEAR_CLIENT_LOCKOUTS: { - identityId: "The ID of the identity to clear the client lockouts from." + identityId: "The ID of the machine identity to clear the client lockouts from." } } as const; export const ALICLOUD_AUTH = { LOGIN: { - identityId: "The ID of the identity to login.", + identityId: "The ID of the machine identity to login.", Action: "The Alibaba Cloud API action. For STS GetCallerIdentity, this should be 'GetCallerIdentity'.", Format: "The response format. For STS GetCallerIdentity, this should be 'JSON'.", Version: "The API version. This should be in 'YYYY-MM-DD' format (e.g., '2015-04-01').", @@ -295,7 +296,7 @@ export const ALICLOUD_AUTH = { Signature: "The signature string calculated based on the request parameters and AccessKey Secret." }, ATTACH: { - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", allowedArns: "The comma-separated list of trusted ARNs that are allowed to authenticate with Infisical.", accessTokenTTL: "The lifetime for an access token in seconds.", accessTokenMaxTTL: "The maximum lifetime for an access token in seconds.", @@ -303,7 +304,7 @@ export const ALICLOUD_AUTH = { accessTokenTrustedIps: "The IPs or CIDR ranges that access tokens can be used from." }, UPDATE: { - identityId: "The ID of the identity to update the auth method for.", + identityId: "The ID of the machine identity to update the auth method for.", allowedArns: "The comma-separated list of trusted ARNs that are allowed to authenticate with Infisical.", accessTokenTTL: "The new lifetime for an access token in seconds.", accessTokenMaxTTL: "The new maximum lifetime for an access token in seconds.", @@ -311,19 +312,19 @@ export const ALICLOUD_AUTH = { accessTokenTrustedIps: "The new IPs or CIDR ranges that access tokens can be used from." }, RETRIEVE: { - identityId: "The ID of the identity to retrieve the auth method for." + identityId: "The ID of the machine identity to retrieve the auth method for." }, REVOKE: { - identityId: "The ID of the identity to revoke the auth method for." + identityId: "The ID of the machine identity to revoke the auth method for." } } as const; export const TLS_CERT_AUTH = { LOGIN: { - identityId: "The ID of the identity to login." + identityId: "The ID of the machine identity to login." }, ATTACH: { - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", allowedCommonNames: "The comma-separated list of trusted common names that are allowed to authenticate with Infisical.", caCertificate: "The PEM-encoded CA certificate to validate client certificates.", @@ -333,7 +334,7 @@ export const TLS_CERT_AUTH = { accessTokenTrustedIps: "The IPs or CIDR ranges that access tokens can be used from." }, UPDATE: { - identityId: "The ID of the identity to update the auth method for.", + identityId: "The ID of the machine identity to update the auth method for.", allowedCommonNames: "The comma-separated list of trusted common names that are allowed to authenticate with Infisical.", caCertificate: "The PEM-encoded CA certificate to validate client certificates.", @@ -343,16 +344,16 @@ export const TLS_CERT_AUTH = { accessTokenTrustedIps: "The new IPs or CIDR ranges that access tokens can be used from." }, RETRIEVE: { - identityId: "The ID of the identity to retrieve the auth method for." + identityId: "The ID of the machine identity to retrieve the auth method for." }, REVOKE: { - identityId: "The ID of the identity to revoke the auth method for." + identityId: "The ID of the machine identity to revoke the auth method for." } } as const; export const AWS_AUTH = { LOGIN: { - identityId: "The ID of the identity to login.", + identityId: "The ID of the machine identity to login.", iamHttpRequestMethod: "The HTTP request method used in the signed request.", iamRequestUrl: "The base64-encoded HTTP URL used in the signed request. Most likely, the base64-encoding of https://sts.amazonaws.com/.", @@ -361,7 +362,7 @@ export const AWS_AUTH = { iamRequestHeaders: "The base64-encoded headers of the sts:GetCallerIdentity signed request." }, ATTACH: { - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", allowedPrincipalArns: "The comma-separated list of trusted IAM principal ARNs that are allowed to authenticate with Infisical.", allowedAccountIds: @@ -373,7 +374,7 @@ export const AWS_AUTH = { accessTokenTrustedIps: "The IPs or CIDR ranges that access tokens can be used from." }, UPDATE: { - identityId: "The ID of the identity to update the auth method for.", + identityId: "The ID of the machine identity to update the auth method for.", allowedPrincipalArns: "The new comma-separated list of trusted IAM principal ARNs that are allowed to authenticate with Infisical.", allowedAccountIds: @@ -385,21 +386,21 @@ export const AWS_AUTH = { accessTokenTrustedIps: "The new IPs or CIDR ranges that access tokens can be used from." }, RETRIEVE: { - identityId: "The ID of the identity to retrieve the auth method for." + identityId: "The ID of the machine identity to retrieve the auth method for." }, REVOKE: { - identityId: "The ID of the identity to revoke the auth method for." + identityId: "The ID of the machine identity to revoke the auth method for." } } as const; export const OCI_AUTH = { LOGIN: { - identityId: "The ID of the identity to login.", + identityId: "The ID of the machine identity to login.", userOcid: "The OCID of the user attempting login.", headers: "The headers of the signed request." }, ATTACH: { - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", tenancyOcid: "The OCID of your tenancy.", allowedUsernames: "The comma-separated list of trusted OCI account usernames that are allowed to authenticate with Infisical.", @@ -409,7 +410,7 @@ export const OCI_AUTH = { accessTokenTrustedIps: "The IPs or CIDR ranges that access tokens can be used from." }, UPDATE: { - identityId: "The ID of the identity to update the auth method for.", + identityId: "The ID of the machine identity to update the auth method for.", tenancyOcid: "The OCID of your tenancy.", allowedUsernames: "The comma-separated list of trusted OCI account usernames that are allowed to authenticate with Infisical.", @@ -419,19 +420,19 @@ export const OCI_AUTH = { accessTokenTrustedIps: "The new IPs or CIDR ranges that access tokens can be used from." }, RETRIEVE: { - identityId: "The ID of the identity to retrieve the auth method for." + identityId: "The ID of the machine identity to retrieve the auth method for." }, REVOKE: { - identityId: "The ID of the identity to revoke the auth method for." + identityId: "The ID of the machine identity to revoke the auth method for." } } as const; export const AZURE_AUTH = { LOGIN: { - identityId: "The ID of the identity to login." + identityId: "The ID of the machine identity to login." }, ATTACH: { - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", tenantId: "The tenant ID for the Azure AD organization.", resource: "The resource URL for the application registered in Azure AD.", allowedServicePrincipalIds: @@ -442,7 +443,7 @@ export const AZURE_AUTH = { accessTokenNumUsesLimit: "The maximum number of times that an access token can be used." }, UPDATE: { - identityId: "The ID of the identity to update the auth method for.", + identityId: "The ID of the machine identity to update the auth method for.", tenantId: "The new tenant ID for the Azure AD organization.", resource: "The new resource URL for the application registered in Azure AD.", allowedServicePrincipalIds: @@ -453,19 +454,19 @@ export const AZURE_AUTH = { accessTokenNumUsesLimit: "The new maximum number of times that an access token can be used." }, RETRIEVE: { - identityId: "The ID of the identity to retrieve the auth method for." + identityId: "The ID of the machine identity to retrieve the auth method for." }, REVOKE: { - identityId: "The ID of the identity to revoke the auth method for." + identityId: "The ID of the machine identity to revoke the auth method for." } } as const; export const GCP_AUTH = { LOGIN: { - identityId: "The ID of the identity to login." + identityId: "The ID of the machine identity to login." }, ATTACH: { - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", allowedServiceAccounts: "The comma-separated list of trusted service account emails corresponding to the GCE resource(s) allowed to authenticate with Infisical.", allowedProjects: @@ -478,7 +479,7 @@ export const GCP_AUTH = { accessTokenNumUsesLimit: "The maximum number of times that an access token can be used." }, UPDATE: { - identityId: "The ID of the identity to update the auth method for.", + identityId: "The ID of the machine identity to update the auth method for.", allowedServiceAccounts: "The new comma-separated list of trusted service account emails corresponding to the GCE resource(s) allowed to authenticate with Infisical.", allowedProjects: @@ -491,19 +492,19 @@ export const GCP_AUTH = { accessTokenNumUsesLimit: "The new maximum number of times that an access token can be used." }, RETRIEVE: { - identityId: "The ID of the identity to retrieve the auth method for." + identityId: "The ID of the machine identity to retrieve the auth method for." }, REVOKE: { - identityId: "The ID of the identity to revoke the auth method for." + identityId: "The ID of the machine identity to revoke the auth method for." } } as const; export const KUBERNETES_AUTH = { LOGIN: { - identityId: "The ID of the identity to login." + identityId: "The ID of the machine identity to login." }, ATTACH: { - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", kubernetesHost: "The host string, host:port pair, or URL to the base of the Kubernetes API server.", caCert: "The PEM-encoded CA cert for the Kubernetes API server.", tokenReviewerJwt: @@ -522,7 +523,7 @@ export const KUBERNETES_AUTH = { accessTokenNumUsesLimit: "The maximum number of times that an access token can be used." }, UPDATE: { - identityId: "The ID of the identity to update the auth method for.", + identityId: "The ID of the machine identity to update the auth method for.", kubernetesHost: "The new host string, host:port pair, or URL to the base of the Kubernetes API server.", caCert: "The new PEM-encoded CA cert for the Kubernetes API server.", tokenReviewerJwt: @@ -541,41 +542,41 @@ export const KUBERNETES_AUTH = { accessTokenNumUsesLimit: "The new maximum number of times that an access token can be used." }, RETRIEVE: { - identityId: "The ID of the identity to retrieve the auth method for." + identityId: "The ID of the machine identity to retrieve the auth method for." }, REVOKE: { - identityId: "The ID of the identity to revoke the auth method for." + identityId: "The ID of the machine identity to revoke the auth method for." } } as const; export const TOKEN_AUTH = { ATTACH: { - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", accessTokenTrustedIps: "The IPs or CIDR ranges that access tokens can be used from.", accessTokenTTL: "The lifetime for an access token in seconds.", accessTokenMaxTTL: "The maximum lifetime for an access token in seconds.", accessTokenNumUsesLimit: "The maximum number of times that an access token can be used." }, UPDATE: { - identityId: "The ID of the identity to update the auth method for.", + identityId: "The ID of the machine identity to update the auth method for.", accessTokenTrustedIps: "The new IPs or CIDR ranges that access tokens can be used from.", accessTokenTTL: "The new lifetime for an access token in seconds.", accessTokenMaxTTL: "The new maximum lifetime for an access token in seconds.", accessTokenNumUsesLimit: "The new maximum number of times that an access token can be used." }, RETRIEVE: { - identityId: "The ID of the identity to retrieve the auth method for." + identityId: "The ID of the machine identity to retrieve the auth method for." }, REVOKE: { - identityId: "The ID of the identity to revoke the auth method for." + identityId: "The ID of the machine identity to revoke the auth method for." }, GET_TOKENS: { - identityId: "The ID of the identity to list token metadata for.", + identityId: "The ID of the machine identity to list token metadata for.", offset: "The offset to start from. If you enter 10, it will start from the 10th token.", limit: "The number of tokens to return." }, CREATE_TOKEN: { - identityId: "The ID of the identity to create the token for.", + identityId: "The ID of the machine identity to create the token for.", name: "The name of the token to create." }, UPDATE_TOKEN: { @@ -589,10 +590,10 @@ export const TOKEN_AUTH = { export const OIDC_AUTH = { LOGIN: { - identityId: "The ID of the identity to login." + identityId: "The ID of the machine identity to login." }, ATTACH: { - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", oidcDiscoveryUrl: "The URL used to retrieve the OpenID Connect configuration from the identity provider.", caCert: "The PEM-encoded CA cert for establishing secure communication with the Identity Provider endpoints.", boundIssuer: "The unique identifier of the identity provider issuing the JWT.", @@ -606,7 +607,7 @@ export const OIDC_AUTH = { accessTokenNumUsesLimit: "The maximum number of times that an access token can be used." }, UPDATE: { - identityId: "The ID of the identity to update the auth method for.", + identityId: "The ID of the machine identity to update the auth method for.", oidcDiscoveryUrl: "The new URL used to retrieve the OpenID Connect configuration from the identity provider.", caCert: "The new PEM-encoded CA cert for establishing secure communication with the Identity Provider endpoints.", boundIssuer: "The new unique identifier of the identity provider issuing the JWT.", @@ -620,19 +621,19 @@ export const OIDC_AUTH = { accessTokenNumUsesLimit: "The new maximum number of times that an access token can be used." }, RETRIEVE: { - identityId: "The ID of the identity to retrieve the auth method for." + identityId: "The ID of the machine identity to retrieve the auth method for." }, REVOKE: { - identityId: "The ID of the identity to revoke the auth method for." + identityId: "The ID of the machine identity to revoke the auth method for." } } as const; export const JWT_AUTH = { LOGIN: { - identityId: "The ID of the identity to login." + identityId: "The ID of the machine identity to login." }, ATTACH: { - identityId: "The ID of the identity to attach the configuration onto.", + identityId: "The ID of the machine identity to attach the configuration onto.", configurationType: "The configuration for validating JWTs. Must be one of: 'jwks', 'static'", jwksUrl: "The URL of the JWKS endpoint. Required if configurationType is 'jwks'. This endpoint must serve JSON Web Key Sets (JWKS) containing the public keys used to verify JWT signatures.", @@ -649,7 +650,7 @@ export const JWT_AUTH = { accessTokenNumUsesLimit: "The maximum number of times that an access token can be used." }, UPDATE: { - identityId: "The ID of the identity to update the auth method for.", + identityId: "The ID of the machine identity to update the auth method for.", configurationType: "The new configuration for validating JWTs. Must be one of: 'jwks', 'static'", jwksUrl: "The new URL of the JWKS endpoint. This endpoint must serve JSON Web Key Sets (JWKS) containing the public keys used to verify JWT signatures.", @@ -666,10 +667,10 @@ export const JWT_AUTH = { accessTokenNumUsesLimit: "The new maximum number of times that an access token can be used." }, RETRIEVE: { - identityId: "The ID of the identity to retrieve the auth method for." + identityId: "The ID of the machine identity to retrieve the auth method for." }, REVOKE: { - identityId: "The ID of the identity to revoke the auth method for." + identityId: "The ID of the machine identity to revoke the auth method for." } } as const; @@ -853,12 +854,12 @@ export const PROJECT_IDENTITIES = { search: "The text string that identity membership names will be filtered by." }, GET_IDENTITY_MEMBERSHIP_BY_ID: { - identityId: "The ID of the identity to get the membership for.", + identityId: "The ID of the machine identity to get the membership for.", projectId: "The ID of the project to get the identity membership for." }, UPDATE_IDENTITY_MEMBERSHIP: { projectId: "The ID of the project to update the identity membership for.", - identityId: "The ID of the identity to update the membership for.", + identityId: "The ID of the machine identity to update the membership for.", roles: { description: "A list of role slugs to assign to the identity project membership.", role: "The role slug to assign to the newly created identity project membership.", @@ -871,11 +872,11 @@ export const PROJECT_IDENTITIES = { }, DELETE_IDENTITY_MEMBERSHIP: { projectId: "The ID of the project to delete the identity membership from.", - identityId: "The ID of the identity to delete the membership from." + identityId: "The ID of the machine identity to delete the membership from." }, CREATE_IDENTITY_MEMBERSHIP: { projectId: "The ID of the project to create the identity membership from.", - identityId: "The ID of the identity to create the membership from.", + identityId: "The ID of the machine identity to create the membership from.", role: "The role slug to assign to the newly created identity project membership.", roles: { description: "A list of role slugs to assign to the newly created identity project membership.", @@ -949,7 +950,8 @@ export const FOLDERS = { projectId: "The ID of the project to delete the folder from.", environment: "The slug of the environment where the folder is located.", directory: "The directory of the folder to delete. (Deprecated in favor of path)", - path: "The path of the folder to delete." + path: "The path of the folder to delete.", + forceDelete: "Whether to force delete the folder even if it contains resources." } } as const; @@ -1270,7 +1272,7 @@ export const SECRET_TAGS = { export const IDENTITY_ADDITIONAL_PRIVILEGE = { CREATE: { projectSlug: "The slug of the project of the identity in.", - identityId: "The ID of the identity to create.", + identityId: "The ID of the machine identity to create.", slug: "The slug of the privilege to create.", permissions: `@deprecated - use privilegePermission The permission object for the privilege. @@ -1296,7 +1298,7 @@ The permission object for the privilege. }, UPDATE: { projectSlug: "The slug of the project of the identity in.", - identityId: "The ID of the identity to update.", + identityId: "The ID of the machine identity to update.", slug: "The slug of the privilege to update.", newSlug: "The new slug of the privilege to update.", permissions: `@deprecated - use privilegePermission @@ -1322,17 +1324,17 @@ The permission object for the privilege. }, DELETE: { projectSlug: "The slug of the project of the identity in.", - identityId: "The ID of the identity to delete.", + identityId: "The ID of the machine identity to delete.", slug: "The slug of the privilege to delete." }, GET_BY_SLUG: { projectSlug: "The slug of the project of the identity in.", - identityId: "The ID of the identity to list.", + identityId: "The ID of the machine identity to list.", slug: "The slug of the privilege." }, LIST: { projectSlug: "The slug of the project of the identity in.", - identityId: "The ID of the identity to list.", + identityId: "The ID of the machine identity to list.", unpacked: "Whether the system should send the permissions as unpacked." } }; @@ -1374,7 +1376,7 @@ export const PROJECT_USER_ADDITIONAL_PRIVILEGE = { export const IDENTITY_ADDITIONAL_PRIVILEGE_V2 = { CREATE: { - identityId: "The ID of the identity to create the privilege for.", + identityId: "The ID of the machine identity to create the privilege for.", projectId: "The ID of the project of the identity in.", slug: "The slug of the privilege to create.", permission: "The permission for the privilege.", @@ -1385,7 +1387,7 @@ export const IDENTITY_ADDITIONAL_PRIVILEGE_V2 = { }, UPDATE: { id: "The ID of the identity privilege.", - identityId: "The ID of the identity to update.", + identityId: "The ID of the machine identity to update.", slug: "The slug of the privilege to update.", privilegePermission: "The permission for the privilege.", isTemporary: "Whether the privilege is temporary.", @@ -1395,12 +1397,12 @@ export const IDENTITY_ADDITIONAL_PRIVILEGE_V2 = { }, DELETE: { id: "The ID of the identity privilege.", - identityId: "The ID of the identity to delete.", + identityId: "The ID of the machine identity to delete.", slug: "The slug of the privilege to delete." }, GET_BY_SLUG: { projectSlug: "The slug of the project of the identity in.", - identityId: "The ID of the identity to list.", + identityId: "The ID of the machine identity to list.", slug: "The slug of the privilege." }, GET_BY_ID: { @@ -1408,7 +1410,7 @@ export const IDENTITY_ADDITIONAL_PRIVILEGE_V2 = { }, LIST: { projectId: "The ID of the project that the identity is in.", - identityId: "The ID of the identity to list." + identityId: "The ID of the machine identity to list." } }; @@ -2355,6 +2357,9 @@ export const AppConnections = { sslRejectUnauthorized: "Whether or not to reject unauthorized SSL certificates (true/false). Set to false only in test environments with self-signed certificates.", sslCertificate: "The SSL certificate (PEM format) to use for secure connection." + }, + LARAVEL_FORGE: { + apiToken: "The API token used to authenticate with Laravel Forge." } } }; @@ -2507,6 +2512,14 @@ export const SecretSyncs = { branch: "The branch to sync preview secrets to.", teamId: "The ID of the Vercel team to sync secrets to." }, + LARAVEL_FORGE: { + orgSlug: "The slug of the Laravel Forge org to sync secrets to.", + orgName: "The name of the Laravel Forge org to sync secrets to.", + serverId: "The ID of the Laravel Forge server to sync secrets to.", + serverName: "The name of the Laravel Forge server to sync secrets to.", + siteId: "The ID of the Laravel Forge site to sync secrets to.", + siteName: "The name of the Laravel Forge site to sync secrets to." + }, WINDMILL: { workspace: "The Windmill workspace to sync secrets to.", path: "The Windmill workspace path to sync secrets to." diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index bee447c36..646cf93e0 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -169,15 +169,24 @@ import { externalCertificateAuthorityDALFactory } from "@app/services/certificat import { internalCertificateAuthorityDALFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-dal"; import { InternalCertificateAuthorityFns } from "@app/services/certificate-authority/internal/internal-certificate-authority-fns"; import { internalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; +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 { 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"; +import { certificateTemplateV2DALFactory } from "@app/services/certificate-template-v2/certificate-template-v2-dal"; +import { certificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; +import { certificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service"; import { cmekServiceFactory } from "@app/services/cmek/cmek-service"; import { convertorServiceFactory } from "@app/services/convertor/convertor-service"; +import { apiEnrollmentConfigDALFactory } from "@app/services/enrollment-config/api-enrollment-config-dal"; +import { estEnrollmentConfigDALFactory } from "@app/services/enrollment-config/est-enrollment-config-dal"; import { externalGroupOrgRoleMappingDALFactory } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-dal"; import { externalGroupOrgRoleMappingServiceFactory } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-service"; import { externalMigrationQueueFactory } from "@app/services/external-migration/external-migration-queue"; import { externalMigrationServiceFactory } from "@app/services/external-migration/external-migration-service"; +import { vaultExternalMigrationConfigDALFactory } from "@app/services/external-migration/vault-external-migration-config-dal"; import { folderCheckpointDALFactory } from "@app/services/folder-checkpoint/folder-checkpoint-dal"; import { folderCheckpointResourcesDALFactory } from "@app/services/folder-checkpoint-resources/folder-checkpoint-resources-dal"; import { folderCommitDALFactory } from "@app/services/folder-commit/folder-commit-dal"; @@ -535,6 +544,8 @@ export const registerRoutes = async ( const membershipRoleDAL = membershipRoleDALFactory(db); const roleDAL = roleDALFactory(db); + const vaultExternalMigrationConfigDAL = vaultExternalMigrationConfigDALFactory(db); + const eventBusService = eventBusFactory(server.redis); const sseService = sseServiceFactory(eventBusService, server.redis); @@ -1034,6 +1045,10 @@ export const registerRoutes = async ( const certificateAuthorityCrlDAL = certificateAuthorityCrlDALFactory(db); const certificateTemplateDAL = certificateTemplateDALFactory(db); const certificateTemplateEstConfigDAL = certificateTemplateEstConfigDALFactory(db); + const certificateTemplateV2DAL = certificateTemplateV2DALFactory(db); + const certificateProfileDAL = certificateProfileDALFactory(db); + const apiEnrollmentConfigDAL = apiEnrollmentConfigDALFactory(db); + const estEnrollmentConfigDAL = estEnrollmentConfigDALFactory(db); const certificateDAL = certificateDALFactory(db); const certificateBodyDAL = certificateBodyDALFactory(db); @@ -1118,6 +1133,21 @@ export const registerRoutes = async ( licenseService }); + const certificateTemplateV2Service = certificateTemplateV2ServiceFactory({ + certificateTemplateV2DAL, + permissionService + }); + + const certificateProfileService = certificateProfileServiceFactory({ + certificateProfileDAL, + certificateTemplateV2DAL, + apiEnrollmentConfigDAL, + estEnrollmentConfigDAL, + permissionService, + kmsService, + projectDAL + }); + const pkiAlertService = pkiAlertServiceFactory({ pkiAlertDAL, pkiCollectionDAL, @@ -1345,7 +1375,8 @@ export const registerRoutes = async ( projectDAL, folderCommitService, secretApprovalPolicyService, - secretV2BridgeDAL + secretV2BridgeDAL, + dynamicSecretDAL }); const secretImportService = secretImportServiceFactory({ @@ -1882,13 +1913,6 @@ export const registerRoutes = async ( notificationService }); - const migrationService = externalMigrationServiceFactory({ - externalMigrationQueue, - userDAL, - permissionService, - gatewayService - }); - const externalGroupOrgRoleMappingService = externalGroupOrgRoleMappingServiceFactory({ permissionService, licenseService, @@ -2090,6 +2114,27 @@ export const registerRoutes = async ( pkiSyncQueue }); + const certificateV3Service = certificateV3ServiceFactory({ + certificateDAL, + certificateAuthorityDAL, + certificateProfileDAL, + certificateTemplateV2Service, + internalCaService: internalCertificateAuthorityService, + permissionService + }); + + const certificateEstV3Service = certificateEstV3ServiceFactory({ + internalCertificateAuthorityService, + certificateTemplateV2Service, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService, + licenseService, + certificateProfileDAL, + estEnrollmentConfigDAL + }); + const pkiSubscriberService = pkiSubscriberServiceFactory({ pkiSubscriberDAL, certificateAuthorityDAL, @@ -2205,6 +2250,18 @@ export const registerRoutes = async ( kmsService }); + const migrationService = externalMigrationServiceFactory({ + externalMigrationQueue, + userDAL, + permissionService, + gatewayService, + kmsService, + appConnectionService, + vaultExternalMigrationConfigDAL, + secretService, + auditLogService + }); + // setup the communication with license key server await licenseService.init(); @@ -2309,6 +2366,8 @@ export const registerRoutes = async ( auditLog: auditLogService, auditLogStream: auditLogStreamService, certificate: certificateService, + certificateV3: certificateV3Service, + certificateEstV3: certificateEstV3Service, sshCertificateAuthority: sshCertificateAuthorityService, sshCertificateTemplate: sshCertificateTemplateService, sshHost: sshHostService, @@ -2316,6 +2375,8 @@ export const registerRoutes = async ( certificateAuthority: certificateAuthorityService, internalCertificateAuthority: internalCertificateAuthorityService, certificateTemplate: certificateTemplateService, + certificateTemplateV2: certificateTemplateV2Service, + certificateProfile: certificateProfileService, certificateAuthorityCrl: certificateAuthorityCrlService, certificateEst: certificateEstService, pit: pitService, 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 e5549f9fe..c799ef0f0 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 @@ -77,6 +77,10 @@ import { HumanitecConnectionListItemSchema, SanitizedHumanitecConnectionSchema } from "@app/services/app-connection/humanitec"; +import { + LaravelForgeConnectionListItemSchema, + SanitizedLaravelForgeConnectionSchema +} from "@app/services/app-connection/laravel-forge"; import { LdapConnectionListItemSchema, SanitizedLdapConnectionSchema } from "@app/services/app-connection/ldap"; import { MsSqlConnectionListItemSchema, SanitizedMsSqlConnectionSchema } from "@app/services/app-connection/mssql"; import { MySqlConnectionListItemSchema, SanitizedMySqlConnectionSchema } from "@app/services/app-connection/mysql"; @@ -158,7 +162,8 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedNetlifyConnectionSchema.options, ...SanitizedOktaConnectionSchema.options, ...SanitizedAzureADCSConnectionSchema.options, - ...SanitizedRedisConnectionSchema.options + ...SanitizedRedisConnectionSchema.options, + ...SanitizedLaravelForgeConnectionSchema.options ]); const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ @@ -200,7 +205,8 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ NetlifyConnectionListItemSchema, OktaConnectionListItemSchema, AzureADCSConnectionListItemSchema, - RedisConnectionListItemSchema + RedisConnectionListItemSchema, + LaravelForgeConnectionListItemSchema ]); export const registerAppConnectionRouter = async (server: FastifyZodProvider) => { 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 11d9ce5e6..2e3da4420 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -24,6 +24,7 @@ import { registerGitLabConnectionRouter } from "./gitlab-connection-router"; import { registerHCVaultConnectionRouter } from "./hc-vault-connection-router"; import { registerHerokuConnectionRouter } from "./heroku-connection-router"; import { registerHumanitecConnectionRouter } from "./humanitec-connection-router"; +import { registerLaravelForgeConnectionRouter } from "./laravel-forge-connection-router"; import { registerLdapConnectionRouter } from "./ldap-connection-router"; import { registerMsSqlConnectionRouter } from "./mssql-connection-router"; import { registerMySqlConnectionRouter } from "./mysql-connection-router"; @@ -71,6 +72,7 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record { + registerAppConnectionEndpoints({ + app: AppConnection.LaravelForge, + server, + sanitizedResponseSchema: SanitizedLaravelForgeConnectionSchema, + createSchema: CreateLaravelForgeConnectionSchema, + updateSchema: UpdateLaravelForgeConnectionSchema + }); + server.route({ + method: "GET", + url: `/:connectionId/organizations`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z + .object({ + id: z.string(), + name: z.string(), + slug: z.string() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + const organizations = await server.services.appConnection.laravelForge.listOrganizations( + connectionId, + req.permission + ); + + return organizations; + } + }); + + server.route({ + method: "GET", + url: `/:connectionId/servers`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + querystring: z.object({ + organizationSlug: z.string() + }), + response: { + 200: z + .object({ + id: z.string(), + name: z.string() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + const { organizationSlug } = req.query; + const servers = await server.services.appConnection.laravelForge.listServers( + connectionId, + req.permission, + organizationSlug + ); + + return servers; + } + }); + + server.route({ + method: "GET", + url: `/:connectionId/sites`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + querystring: z.object({ + organizationSlug: z.string(), + serverId: z.string() + }), + response: { + 200: z + .object({ + id: z.string(), + name: z.string() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + const { organizationSlug, serverId } = req.query; + const sites = await server.services.appConnection.laravelForge.listSites( + connectionId, + req.permission, + organizationSlug, + serverId + ); + + return sites; + } + }); +}; diff --git a/backend/src/server/routes/v1/certificate-profiles-router.ts b/backend/src/server/routes/v1/certificate-profiles-router.ts new file mode 100644 index 000000000..2292c3ba8 --- /dev/null +++ b/backend/src/server/routes/v1/certificate-profiles-router.ts @@ -0,0 +1,506 @@ +import RE2 from "re2"; +import { z } from "zod"; + +import { PkiCertificateProfilesSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags } from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { CertStatus } from "@app/services/certificate/certificate-types"; +import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; + +export const registerCertificateProfilesRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateProfiles], + body: z + .object({ + projectId: z.string().min(1), + caId: z.string().uuid(), + certificateTemplateId: z.string().uuid(), + slug: z + .string() + .min(1) + .max(255) + .regex(new RE2("^[a-z0-9-]+$"), "Slug must contain only lowercase letters, numbers, and hyphens"), + description: z.string().max(1000).optional(), + enrollmentType: z.nativeEnum(EnrollmentType), + estConfig: z + .object({ + disableBootstrapCaValidation: z.boolean().default(false), + passphrase: z.string().min(1), + caChain: z.string().optional() + }) + .optional(), + apiConfig: z + .object({ + autoRenew: z.boolean().default(false), + autoRenewDays: z.number().min(1).max(365).optional() + }) + .optional() + }) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.EST) { + if (!data.estConfig) { + return false; + } + if (data.apiConfig) { + return false; + } + } + if (data.enrollmentType === EnrollmentType.API) { + if (!data.apiConfig) { + return false; + } + if (data.estConfig) { + return false; + } + } + return true; + }, + { + message: + "EST enrollment type requires EST configuration and cannot have API configuration. API enrollment type requires API configuration and cannot have EST configuration." + } + ), + response: { + 200: z.object({ + certificateProfile: PkiCertificateProfilesSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateProfile = await server.services.certificateProfile.createProfile({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.body.projectId, + data: req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.body.projectId, + event: { + type: EventType.CREATE_CERTIFICATE_PROFILE, + metadata: { + certificateProfileId: certificateProfile.id, + name: certificateProfile.slug, + projectId: certificateProfile.projectId, + enrollmentType: certificateProfile.enrollmentType + } + } + }); + + return { certificateProfile }; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateProfiles], + querystring: z.object({ + projectId: z.string().min(1), + offset: z.coerce.number().min(0).default(0), + limit: z.coerce.number().min(1).max(100).default(20), + search: z.string().optional(), + enrollmentType: z.nativeEnum(EnrollmentType).optional(), + caId: z.string().uuid().optional(), + includeMetrics: z.coerce.boolean().optional().default(false), + expiringDays: z.coerce.number().min(1).max(365).optional().default(7) + }), + response: { + 200: z.object({ + certificateProfiles: PkiCertificateProfilesSchema.extend({ + metrics: z + .object({ + profileId: z.string(), + totalCertificates: z.number(), + activeCertificates: z.number(), + expiredCertificates: z.number(), + expiringCertificates: z.number(), + revokedCertificates: z.number() + }) + .optional(), + estConfig: z + .object({ + id: z.string(), + disableBootstrapCaValidation: z.boolean(), + passphrase: z.string().optional(), + caChain: z.string().optional() + }) + .optional(), + apiConfig: z + .object({ + id: z.string(), + autoRenew: z.boolean(), + autoRenewDays: z.number().optional() + }) + .optional() + }).array(), + totalCount: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { profiles, totalCount } = await server.services.certificateProfile.listProfiles({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.query + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.query.projectId, + event: { + type: EventType.LIST_CERTIFICATE_PROFILES, + metadata: { + projectId: req.query.projectId + } + } + }); + + return { certificateProfiles: profiles, totalCount }; + } + }); + + server.route({ + method: "GET", + url: "/:id", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateProfiles], + params: z.object({ + id: z.string().uuid() + }), + querystring: z.object({ + includeMetrics: z.coerce.boolean().optional().default(false), + expiringDays: z.coerce.number().min(1).max(365).optional().default(7) + }), + response: { + 200: z.object({ + certificateProfile: PkiCertificateProfilesSchema.extend({ + certificateAuthority: z + .object({ + id: z.string(), + projectId: z.string(), + status: z.string(), + name: z.string() + }) + .optional(), + certificateTemplate: z + .object({ + id: z.string(), + projectId: z.string(), + name: z.string(), + description: z.string().optional() + }) + .optional(), + estConfig: z + .object({ + id: z.string(), + disableBootstrapCaValidation: z.boolean(), + passphrase: z.string(), + caChain: z.string().optional() + }) + .optional(), + apiConfig: z + .object({ + id: z.string(), + autoRenew: z.boolean(), + autoRenewDays: z.number().optional() + }) + .optional(), + metrics: z + .object({ + profileId: z.string(), + totalCertificates: z.number(), + activeCertificates: z.number(), + expiredCertificates: z.number(), + expiringCertificates: z.number(), + revokedCertificates: z.number() + }) + .optional() + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateProfile = await server.services.certificateProfile.getProfileByIdWithConfigs({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: req.params.id + }); + + let result = certificateProfile; + + if (req.query.includeMetrics) { + const metrics = await server.services.certificateProfile.getProfileMetrics({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: req.params.id, + expiringDays: req.query.expiringDays + }); + result = { ...certificateProfile, metrics }; + } + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateProfile.projectId, + event: { + type: EventType.GET_CERTIFICATE_PROFILE, + metadata: { + certificateProfileId: certificateProfile.id, + name: certificateProfile.slug + } + } + }); + + return { certificateProfile: result }; + } + }); + + server.route({ + method: "GET", + url: "/slug/:slug", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateProfiles], + params: z.object({ + slug: z.string().min(1) + }), + querystring: z.object({ + projectId: z.string().min(1) + }), + response: { + 200: z.object({ + certificateProfile: PkiCertificateProfilesSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateProfile = await server.services.certificateProfile.getProfileBySlug({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + projectId: req.query.projectId, + slug: req.params.slug + }); + + return { certificateProfile }; + } + }); + + server.route({ + method: "PATCH", + url: "/:id", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateProfiles], + params: z.object({ + id: z.string().uuid() + }), + body: z + .object({ + slug: z + .string() + .min(1) + .max(255) + .regex(new RE2("^[a-z0-9-]+$"), "Slug must contain only lowercase letters, numbers, and hyphens") + .optional(), + description: z.string().max(1000).optional(), + enrollmentType: z.nativeEnum(EnrollmentType).optional(), + estConfig: z + .object({ + disableBootstrapCaValidation: z.boolean().default(false), + passphrase: z.string().min(1).optional(), + caChain: z.string().optional() + }) + .optional(), + apiConfig: z + .object({ + autoRenew: z.boolean().default(false), + autoRenewDays: z.number().min(1).max(365).optional() + }) + .optional() + }) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.EST) { + if (data.apiConfig) { + return false; + } + } + if (data.enrollmentType === EnrollmentType.API) { + if (data.estConfig) { + return false; + } + } + return true; + }, + { + message: "Cannot have EST config with API enrollment type or API config with EST enrollment type." + } + ), + response: { + 200: z.object({ + certificateProfile: PkiCertificateProfilesSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateProfile = await server.services.certificateProfile.updateProfile({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: req.params.id, + data: req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateProfile.projectId, + event: { + type: EventType.UPDATE_CERTIFICATE_PROFILE, + metadata: { + certificateProfileId: certificateProfile.id, + name: certificateProfile.slug + } + } + }); + + return { certificateProfile }; + } + }); + + server.route({ + method: "DELETE", + url: "/:id", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateProfiles], + params: z.object({ + id: z.string().uuid() + }), + response: { + 200: z.object({ + certificateProfile: PkiCertificateProfilesSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateProfile = await server.services.certificateProfile.deleteProfile({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: req.params.id + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateProfile.projectId, + event: { + type: EventType.DELETE_CERTIFICATE_PROFILE, + metadata: { + certificateProfileId: certificateProfile.id, + name: certificateProfile.slug + } + } + }); + + return { certificateProfile }; + } + }); + + server.route({ + method: "GET", + url: "/:id/certificates", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateProfiles], + params: z.object({ + id: z.string().uuid() + }), + querystring: z.object({ + offset: z.coerce.number().min(0).default(0), + limit: z.coerce.number().min(1).max(100).default(20), + status: z.nativeEnum(CertStatus).optional(), + search: z.string().optional() + }), + response: { + 200: z.object({ + certificates: z.array( + z.object({ + id: z.string(), + serialNumber: z.string(), + cn: z.string(), + status: z.string(), + notBefore: z.date(), + notAfter: z.date(), + revokedAt: z.date().nullable().optional(), + createdAt: z.date() + }) + ) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificates = await server.services.certificateProfile.getProfileCertificates({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: req.params.id, + ...req.query + }); + + return { certificates }; + } + }); +}; diff --git a/backend/src/server/routes/v1/certificate-template-router.ts b/backend/src/server/routes/v1/certificate-template-router.ts index 17f564be4..5ff0e39c0 100644 --- a/backend/src/server/routes/v1/certificate-template-router.ts +++ b/backend/src/server/routes/v1/certificate-template-router.ts @@ -52,7 +52,8 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid event: { type: EventType.GET_CERTIFICATE_TEMPLATE, metadata: { - certificateTemplateId: certificateTemplate.id + certificateTemplateId: certificateTemplate.id, + name: certificateTemplate.name } } }); @@ -121,7 +122,8 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid name: certificateTemplate.name, commonName: certificateTemplate.commonName, subjectAlternativeName: certificateTemplate.subjectAlternativeName, - ttl: certificateTemplate.ttl + ttl: certificateTemplate.ttl, + projectId: certificateTemplate.projectId } } }); @@ -184,9 +186,9 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid type: EventType.UPDATE_CERTIFICATE_TEMPLATE, metadata: { certificateTemplateId: certificateTemplate.id, + name: certificateTemplate.name, caId: certificateTemplate.caId, pkiCollectionId: certificateTemplate.pkiCollectionId as string, - name: certificateTemplate.name, commonName: certificateTemplate.commonName, subjectAlternativeName: certificateTemplate.subjectAlternativeName, ttl: certificateTemplate.ttl @@ -230,7 +232,8 @@ export const registerCertificateTemplateRouter = async (server: FastifyZodProvid event: { type: EventType.DELETE_CERTIFICATE_TEMPLATE, metadata: { - certificateTemplateId: certificateTemplate.id + certificateTemplateId: certificateTemplate.id, + name: certificateTemplate.name } } }); diff --git a/backend/src/server/routes/v1/deprecated-secret-folder-router.ts b/backend/src/server/routes/v1/deprecated-secret-folder-router.ts index ecb955025..955ec06ca 100644 --- a/backend/src/server/routes/v1/deprecated-secret-folder-router.ts +++ b/backend/src/server/routes/v1/deprecated-secret-folder-router.ts @@ -318,7 +318,8 @@ export const registerDeprecatedSecretFolderRouter = async (server: FastifyZodPro ...req.body, projectId: req.body.workspaceId, idOrName: req.params.folderIdOrName, - path + path, + forceDelete: true }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, diff --git a/backend/src/server/routes/v1/identity-access-token-router.ts b/backend/src/server/routes/v1/identity-access-token-router.ts index 20ed8d150..0a9cd8dd6 100644 --- a/backend/src/server/routes/v1/identity-access-token-router.ts +++ b/backend/src/server/routes/v1/identity-access-token-router.ts @@ -13,7 +13,7 @@ export const registerIdentityAccessTokenRouter = async (server: FastifyZodProvid schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "Renew access token", + description: "Renew machine identity access token", body: z.object({ accessToken: z.string().trim().describe(UNIVERSAL_AUTH.RENEW_ACCESS_TOKEN.accessToken) }), @@ -48,7 +48,7 @@ export const registerIdentityAccessTokenRouter = async (server: FastifyZodProvid schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "Revoke access token", + description: "Revoke machine identity access token", body: z.object({ accessToken: z.string().trim().describe(UNIVERSAL_AUTH.REVOKE_ACCESS_TOKEN.accessToken) }), diff --git a/backend/src/server/routes/v1/identity-alicloud-auth-router.ts b/backend/src/server/routes/v1/identity-alicloud-auth-router.ts index 4c444d704..3645a8bb6 100644 --- a/backend/src/server/routes/v1/identity-alicloud-auth-router.ts +++ b/backend/src/server/routes/v1/identity-alicloud-auth-router.ts @@ -21,7 +21,7 @@ export const registerIdentityAliCloudAuthRouter = async (server: FastifyZodProvi schema: { hide: false, tags: [ApiDocsTags.AliCloudAuth], - description: "Login with Alibaba Cloud Auth", + description: "Login with Alibaba Cloud Auth for machine identity", body: z.object({ identityId: z.string().trim().describe(ALICLOUD_AUTH.LOGIN.identityId), Action: z.enum(["GetCallerIdentity"]).describe(ALICLOUD_AUTH.LOGIN.Action), @@ -108,7 +108,7 @@ export const registerIdentityAliCloudAuthRouter = async (server: FastifyZodProvi schema: { hide: false, tags: [ApiDocsTags.AliCloudAuth], - description: "Attach Alibaba Cloud Auth configuration onto identity", + description: "Attach Alibaba Cloud Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -200,7 +200,7 @@ export const registerIdentityAliCloudAuthRouter = async (server: FastifyZodProvi schema: { hide: false, tags: [ApiDocsTags.AliCloudAuth], - description: "Update Alibaba Cloud Auth configuration on identity", + description: "Update Alibaba Cloud Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -292,7 +292,7 @@ export const registerIdentityAliCloudAuthRouter = async (server: FastifyZodProvi schema: { hide: false, tags: [ApiDocsTags.AliCloudAuth], - description: "Retrieve Alibaba Cloud Auth configuration on identity", + description: "Retrieve Alibaba Cloud Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -340,7 +340,7 @@ export const registerIdentityAliCloudAuthRouter = async (server: FastifyZodProvi schema: { hide: false, tags: [ApiDocsTags.AliCloudAuth], - description: "Delete Alibaba Cloud Auth configuration on identity", + description: "Delete Alibaba Cloud Auth configuration on machine identity", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts b/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts index fe7cf7d5b..59526899c 100644 --- a/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts +++ b/backend/src/server/routes/v1/identity-aws-iam-auth-router.ts @@ -23,7 +23,7 @@ export const registerIdentityAwsAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.AwsAuth], - description: "Login with AWS Auth", + description: "Login with AWS Auth for machine identity", body: z.object({ identityId: z.string().trim().describe(AWS_AUTH.LOGIN.identityId), iamHttpRequestMethod: z.string().default("POST").describe(AWS_AUTH.LOGIN.iamHttpRequestMethod), @@ -75,7 +75,7 @@ export const registerIdentityAwsAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.AwsAuth], - description: "Attach AWS Auth configuration onto identity", + description: "Attach AWS Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -171,7 +171,7 @@ export const registerIdentityAwsAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.AwsAuth], - description: "Update AWS Auth configuration on identity", + description: "Update AWS Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -255,7 +255,7 @@ export const registerIdentityAwsAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.AwsAuth], - description: "Retrieve AWS Auth configuration on identity", + description: "Retrieve AWS Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -303,7 +303,7 @@ export const registerIdentityAwsAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.AwsAuth], - description: "Delete AWS Auth configuration on identity", + description: "Delete AWS Auth configuration on machine identity", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-azure-auth-router.ts b/backend/src/server/routes/v1/identity-azure-auth-router.ts index 9ef733a25..2649655bd 100644 --- a/backend/src/server/routes/v1/identity-azure-auth-router.ts +++ b/backend/src/server/routes/v1/identity-azure-auth-router.ts @@ -20,7 +20,7 @@ export const registerIdentityAzureAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.AzureAuth], - description: "Login with Azure Auth", + description: "Login with Azure Auth for machine identity", body: z.object({ identityId: z.string().trim().describe(AZURE_AUTH.LOGIN.identityId), jwt: z.string() @@ -70,7 +70,7 @@ export const registerIdentityAzureAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.AzureAuth], - description: "Attach Azure Auth configuration onto identity", + description: "Attach Azure Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -165,7 +165,7 @@ export const registerIdentityAzureAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.AzureAuth], - description: "Update Azure Auth configuration on identity", + description: "Update Azure Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -255,7 +255,7 @@ export const registerIdentityAzureAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.AzureAuth], - description: "Retrieve Azure Auth configuration on identity", + description: "Retrieve Azure Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -304,7 +304,7 @@ export const registerIdentityAzureAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.AzureAuth], - description: "Delete Azure Auth configuration on identity", + description: "Delete Azure Auth configuration on machine identity", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-gcp-auth-router.ts b/backend/src/server/routes/v1/identity-gcp-auth-router.ts index 91e8038a7..d65c46613 100644 --- a/backend/src/server/routes/v1/identity-gcp-auth-router.ts +++ b/backend/src/server/routes/v1/identity-gcp-auth-router.ts @@ -20,7 +20,7 @@ export const registerIdentityGcpAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.GcpAuth], - description: "Login with GCP Auth", + description: "Login with GCP Auth for machine identity", body: z.object({ identityId: z.string().trim().describe(GCP_AUTH.LOGIN.identityId), jwt: z.string() @@ -70,7 +70,7 @@ export const registerIdentityGcpAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.GcpAuth], - description: "Attach GCP Auth configuration onto identity", + description: "Attach GCP Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -163,7 +163,7 @@ export const registerIdentityGcpAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.GcpAuth], - description: "Update GCP Auth configuration on identity", + description: "Update GCP Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -249,7 +249,7 @@ export const registerIdentityGcpAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.GcpAuth], - description: "Retrieve GCP Auth configuration on identity", + description: "Retrieve GCP Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -298,7 +298,7 @@ export const registerIdentityGcpAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.GcpAuth], - description: "Delete GCP Auth configuration on identity", + description: "Delete GCP Auth configuration on machine identity", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-jwt-auth-router.ts b/backend/src/server/routes/v1/identity-jwt-auth-router.ts index ecffaebe1..2a882471d 100644 --- a/backend/src/server/routes/v1/identity-jwt-auth-router.ts +++ b/backend/src/server/routes/v1/identity-jwt-auth-router.ts @@ -96,7 +96,7 @@ export const registerIdentityJwtAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.JwtAuth], - description: "Login with JWT Auth", + description: "Login with JWT Auth for machine identity", body: z.object({ identityId: z.string().trim().describe(JWT_AUTH.LOGIN.identityId), jwt: z.string().trim() @@ -148,7 +148,7 @@ export const registerIdentityJwtAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.JwtAuth], - description: "Attach JWT Auth configuration onto identity", + description: "Attach JWT Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -217,7 +217,7 @@ export const registerIdentityJwtAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.JwtAuth], - description: "Update JWT Auth configuration on identity", + description: "Update JWT Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -283,7 +283,7 @@ export const registerIdentityJwtAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.JwtAuth], - description: "Retrieve JWT Auth configuration on identity", + description: "Retrieve JWT Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -332,7 +332,7 @@ export const registerIdentityJwtAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.JwtAuth], - description: "Delete JWT Auth configuration on identity", + description: "Delete JWT Auth configuration on machine identity", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts b/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts index 6f0500c29..0794cf00d 100644 --- a/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts +++ b/backend/src/server/routes/v1/identity-kubernetes-auth-router.ts @@ -41,7 +41,7 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide schema: { hide: false, tags: [ApiDocsTags.KubernetesAuth], - description: "Login with Kubernetes Auth", + description: "Login with Kubernetes Auth for machine identity", body: z.object({ identityId: z.string().trim().describe(KUBERNETES_AUTH.LOGIN.identityId), jwt: z.string().trim() @@ -93,7 +93,7 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide schema: { hide: false, tags: [ApiDocsTags.KubernetesAuth], - description: "Attach Kubernetes Auth configuration onto identity", + description: "Attach Kubernetes Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -240,7 +240,7 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide schema: { hide: false, tags: [ApiDocsTags.KubernetesAuth], - description: "Update Kubernetes Auth configuration on identity", + description: "Update Kubernetes Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -383,7 +383,7 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide schema: { hide: false, tags: [ApiDocsTags.KubernetesAuth], - description: "Retrieve Kubernetes Auth configuration on identity", + description: "Retrieve Kubernetes Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -432,7 +432,7 @@ export const registerIdentityKubernetesRouter = async (server: FastifyZodProvide schema: { hide: false, tags: [ApiDocsTags.KubernetesAuth], - description: "Delete Kubernetes Auth configuration on identity", + description: "Delete Kubernetes Auth configuration on machine identity", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-ldap-auth-router.ts b/backend/src/server/routes/v1/identity-ldap-auth-router.ts index ac384d216..caf5708e3 100644 --- a/backend/src/server/routes/v1/identity-ldap-auth-router.ts +++ b/backend/src/server/routes/v1/identity-ldap-auth-router.ts @@ -120,7 +120,7 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.LdapAuth], - description: "Login with LDAP Auth", + description: "Login with LDAP Auth for machine identity", body: z.object({ identityId: z.string().trim().describe(LDAP_AUTH.LOGIN.identityId), username: z.string().describe(LDAP_AUTH.LOGIN.username), @@ -198,7 +198,7 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.LdapAuth], - description: "Attach LDAP Auth configuration onto identity", + description: "Attach LDAP Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -389,7 +389,7 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.LdapAuth], - description: "Update LDAP Auth configuration on identity", + description: "Update LDAP Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -510,7 +510,7 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.LdapAuth], - description: "Retrieve LDAP Auth configuration on identity", + description: "Retrieve LDAP Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -568,7 +568,7 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.LdapAuth], - description: "Delete LDAP Auth configuration on identity", + description: "Delete LDAP Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -621,7 +621,7 @@ export const registerIdentityLdapAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.LdapAuth], - description: "Clear LDAP Auth Lockouts for identity", + description: "Clear LDAP Auth Lockouts for machine identity", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-oci-auth-router.ts b/backend/src/server/routes/v1/identity-oci-auth-router.ts index df19492a6..24d414286 100644 --- a/backend/src/server/routes/v1/identity-oci-auth-router.ts +++ b/backend/src/server/routes/v1/identity-oci-auth-router.ts @@ -20,7 +20,7 @@ export const registerIdentityOciAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.OciAuth], - description: "Login with OCI Auth", + description: "Login with OCI Auth for machine identity", body: z.object({ identityId: z.string().trim().describe(OCI_AUTH.LOGIN.identityId), userOcid: z.string().trim().describe(OCI_AUTH.LOGIN.userOcid), @@ -87,7 +87,7 @@ export const registerIdentityOciAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.OciAuth], - description: "Attach OCI Auth configuration onto identity", + description: "Attach OCI Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -176,7 +176,7 @@ export const registerIdentityOciAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.OciAuth], - description: "Update OCI Auth configuration on identity", + description: "Update OCI Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -259,7 +259,7 @@ export const registerIdentityOciAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.OciAuth], - description: "Retrieve OCI Auth configuration on identity", + description: "Retrieve OCI Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -307,7 +307,7 @@ export const registerIdentityOciAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.OciAuth], - description: "Delete OCI Auth configuration on identity", + description: "Delete OCI Auth configuration on machine identity", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-oidc-auth-router.ts b/backend/src/server/routes/v1/identity-oidc-auth-router.ts index 147105aba..48fa64bf4 100644 --- a/backend/src/server/routes/v1/identity-oidc-auth-router.ts +++ b/backend/src/server/routes/v1/identity-oidc-auth-router.ts @@ -44,7 +44,7 @@ export const registerIdentityOidcAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.OidcAuth], - description: "Login with OIDC Auth", + description: "Login with OIDC Auth for machine identity", body: z.object({ identityId: z.string().trim().describe(OIDC_AUTH.LOGIN.identityId), jwt: z.string().trim() @@ -100,7 +100,7 @@ export const registerIdentityOidcAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.OidcAuth], - description: "Attach OIDC Auth configuration onto identity", + description: "Attach OIDC Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -201,7 +201,7 @@ export const registerIdentityOidcAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.OidcAuth], - description: "Update OIDC Auth configuration on identity", + description: "Update OIDC Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -300,7 +300,7 @@ export const registerIdentityOidcAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.OidcAuth], - description: "Retrieve OIDC Auth configuration on identity", + description: "Retrieve OIDC Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -349,7 +349,7 @@ export const registerIdentityOidcAuthRouter = async (server: FastifyZodProvider) schema: { hide: false, tags: [ApiDocsTags.OidcAuth], - description: "Delete OIDC Auth configuration on identity", + description: "Delete OIDC Auth configuration on machine identity", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-router.ts b/backend/src/server/routes/v1/identity-router.ts index 65b9448c5..d6a42c4a2 100644 --- a/backend/src/server/routes/v1/identity-router.ts +++ b/backend/src/server/routes/v1/identity-router.ts @@ -34,7 +34,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.Identities], - description: "Create identity", + description: "Create machine identity", security: [ { bearerAuth: [] @@ -109,7 +109,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.Identities], - description: "Update identity", + description: "Update machine identity", security: [ { bearerAuth: [] @@ -173,7 +173,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.Identities], - description: "Delete identity", + description: "Delete machine identity", security: [ { bearerAuth: [] @@ -222,7 +222,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.Identities], - description: "Get an identity by id", + description: "Get a machine identity by id", security: [ { bearerAuth: [] @@ -280,7 +280,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.Identities], - description: "List identities", + description: "List machine identities", security: [ { bearerAuth: [] @@ -330,7 +330,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.Identities], - description: "Search identities", + description: "Search machine identities", security: [ { bearerAuth: [] @@ -427,7 +427,7 @@ export const registerIdentityRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), schema: { - description: "List project memberships that identity with id is part of", + description: "List project memberships that machine identity with id is part of", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-tls-cert-auth-router.ts b/backend/src/server/routes/v1/identity-tls-cert-auth-router.ts index 0503ed16f..d549160db 100644 --- a/backend/src/server/routes/v1/identity-tls-cert-auth-router.ts +++ b/backend/src/server/routes/v1/identity-tls-cert-auth-router.ts @@ -44,7 +44,7 @@ export const registerIdentityTlsCertAuthRouter = async (server: FastifyZodProvid schema: { hide: false, tags: [ApiDocsTags.TlsCertAuth], - description: "Login with TLS Certificate Auth", + description: "Login with TLS Certificate Auth for machine identity", body: z.object({ identityId: z.string().trim().describe(TLS_CERT_AUTH.LOGIN.identityId) }), @@ -102,7 +102,7 @@ export const registerIdentityTlsCertAuthRouter = async (server: FastifyZodProvid schema: { hide: false, tags: [ApiDocsTags.TlsCertAuth], - description: "Attach TLS Certificate Auth configuration onto identity", + description: "Attach TLS Certificate Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -203,7 +203,7 @@ export const registerIdentityTlsCertAuthRouter = async (server: FastifyZodProvid schema: { hide: false, tags: [ApiDocsTags.TlsCertAuth], - description: "Update TLS Certificate Auth configuration on identity", + description: "Update TLS Certificate Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -304,7 +304,7 @@ export const registerIdentityTlsCertAuthRouter = async (server: FastifyZodProvid schema: { hide: false, tags: [ApiDocsTags.TlsCertAuth], - description: "Retrieve TLS Certificate Auth configuration on identity", + description: "Retrieve TLS Certificate Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -354,7 +354,7 @@ export const registerIdentityTlsCertAuthRouter = async (server: FastifyZodProvid schema: { hide: false, tags: [ApiDocsTags.TlsCertAuth], - description: "Delete TLS Certificate Auth configuration on identity", + description: "Delete TLS Certificate Auth configuration on machine identity", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-token-auth-router.ts b/backend/src/server/routes/v1/identity-token-auth-router.ts index 9a33d0651..9040d8909 100644 --- a/backend/src/server/routes/v1/identity-token-auth-router.ts +++ b/backend/src/server/routes/v1/identity-token-auth-router.ts @@ -20,7 +20,7 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.TokenAuth], - description: "Attach Token Auth configuration onto identity", + description: "Attach Token Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -112,7 +112,7 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.TokenAuth], - description: "Update Token Auth configuration on identity", + description: "Update Token Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -198,7 +198,7 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.TokenAuth], - description: "Retrieve Token Auth configuration on identity", + description: "Retrieve Token Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -247,7 +247,7 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.TokenAuth], - description: "Delete Token Auth configuration on identity", + description: "Delete Token Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -297,7 +297,7 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.TokenAuth], - description: "Create token for identity with Token Auth", + description: "Create token for machine identity with Token Auth", security: [ { bearerAuth: [] @@ -361,7 +361,7 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.TokenAuth], - description: "Get tokens for identity with Token Auth", + description: "Get tokens for machine identity with Token Auth", security: [ { bearerAuth: [] @@ -416,7 +416,7 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.TokenAuth], - description: "Update token for identity with Token Auth", + description: "Update token for machine identity with Token Auth", security: [ { bearerAuth: [] @@ -472,7 +472,7 @@ export const registerIdentityTokenAuthRouter = async (server: FastifyZodProvider schema: { hide: false, tags: [ApiDocsTags.TokenAuth], - description: "Revoke token for identity with Token Auth", + description: "Revoke token for machine identity with Token Auth", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/identity-universal-auth-router.ts b/backend/src/server/routes/v1/identity-universal-auth-router.ts index 153e1e641..0443d35dd 100644 --- a/backend/src/server/routes/v1/identity-universal-auth-router.ts +++ b/backend/src/server/routes/v1/identity-universal-auth-router.ts @@ -32,7 +32,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "Login with Universal Auth", + description: "Login with Universal Auth for machine identity", body: z.object({ clientId: z.string().trim().describe(UNIVERSAL_AUTH.LOGIN.clientId), clientSecret: z.string().trim().describe(UNIVERSAL_AUTH.LOGIN.clientSecret) @@ -90,7 +90,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "Attach Universal Auth configuration onto identity", + description: "Attach Universal Auth configuration onto machine identity", security: [ { bearerAuth: [] @@ -208,7 +208,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "Update Universal Auth configuration on identity", + description: "Update Universal Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -331,7 +331,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "Retrieve Universal Auth configuration on identity", + description: "Retrieve Universal Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -380,7 +380,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "Delete Universal Auth configuration on identity", + description: "Delete Universal Auth configuration on machine identity", security: [ { bearerAuth: [] @@ -429,7 +429,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "Create Universal Auth Client Secret for identity", + description: "Create Universal Auth Client Secret for machine identity", security: [ { bearerAuth: [] @@ -487,7 +487,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "List Universal Auth Client Secrets for identity", + description: "List Universal Auth Client Secrets for machine identity", security: [ { bearerAuth: [] @@ -537,7 +537,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "Get Universal Auth Client Secret for identity", + description: "Get Universal Auth Client Secret for machine identity", security: [ { bearerAuth: [] @@ -567,7 +567,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { ...req.auditLogInfo, orgId: clientSecretData.orgId, event: { - type: EventType.REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET, + type: EventType.GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET_BY_ID, metadata: { identityId: clientSecretData.identityId, clientSecretId: clientSecretData.id @@ -589,7 +589,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "Revoke Universal Auth Client Secrets for identity", + description: "Revoke Universal Auth Client Secrets for machine identity", security: [ { bearerAuth: [] @@ -641,7 +641,7 @@ export const registerIdentityUaRouter = async (server: FastifyZodProvider) => { schema: { hide: false, tags: [ApiDocsTags.UniversalAuth], - description: "Clear Universal Auth Lockouts for identity", + description: "Clear Universal Auth Lockouts for machine identity", security: [ { bearerAuth: [] diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 89865b1a1..589a79721 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -11,6 +11,7 @@ import { registerAuthRoutes } from "./auth-router"; import { registerProjectBotRouter } from "./bot-router"; import { registerCaRouter } from "./certificate-authority-router"; import { CERTIFICATE_AUTHORITY_REGISTER_ROUTER_MAP } from "./certificate-authority-routers"; +import { registerCertificateProfilesRouter } from "./certificate-profiles-router"; import { registerCertRouter } from "./certificate-router"; import { registerCertificateTemplateRouter } from "./certificate-template-router"; import { registerDeprecatedProjectEnvRouter } from "./deprecated-project-env-router"; @@ -146,6 +147,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { ); await pkiRouter.register(registerCertRouter, { prefix: "/certificates" }); await pkiRouter.register(registerCertificateTemplateRouter, { prefix: "/certificate-templates" }); + await pkiRouter.register(registerCertificateProfilesRouter, { prefix: "/certificate-profiles" }); await pkiRouter.register(registerPkiAlertRouter, { prefix: "/alerts" }); await pkiRouter.register(registerPkiCollectionRouter, { prefix: "/collections" }); await pkiRouter.register(registerPkiSubscriberRouter, { prefix: "/subscribers" }); 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 fed56277e..e778dbd7c 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/index.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/index.ts @@ -21,6 +21,7 @@ import { registerGitLabSyncRouter } from "./gitlab-sync-router"; import { registerHCVaultSyncRouter } from "./hc-vault-sync-router"; import { registerHerokuSyncRouter } from "./heroku-sync-router"; import { registerHumanitecSyncRouter } from "./humanitec-sync-router"; +import { registerLaravelForgeSyncRouter } from "./laravel-forge-sync-router"; import { registerNetlifySyncRouter } from "./netlify-sync-router"; import { registerRailwaySyncRouter } from "./railway-sync-router"; import { registerRenderSyncRouter } from "./render-sync-router"; @@ -63,5 +64,6 @@ export const SECRET_SYNC_REGISTER_ROUTER_MAP: Record + registerSyncSecretsEndpoints({ + destination: SecretSync.LaravelForge, + server, + responseSchema: LaravelForgeSyncSchema, + createSchema: CreateLaravelForgeSyncSchema, + updateSchema: UpdateLaravelForgeSyncSchema + }); diff --git a/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts b/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts index 71e8b2cca..1bfa32eeb 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/secret-sync-router.ts @@ -44,6 +44,7 @@ import { GitLabSyncListItemSchema, GitLabSyncSchema } from "@app/services/secret import { HCVaultSyncListItemSchema, HCVaultSyncSchema } from "@app/services/secret-sync/hc-vault"; import { HerokuSyncListItemSchema, HerokuSyncSchema } from "@app/services/secret-sync/heroku"; import { HumanitecSyncListItemSchema, HumanitecSyncSchema } from "@app/services/secret-sync/humanitec"; +import { LaravelForgeSyncListItemSchema, LaravelForgeSyncSchema } from "@app/services/secret-sync/laravel-forge"; import { NetlifySyncListItemSchema, NetlifySyncSchema } from "@app/services/secret-sync/netlify"; import { RailwaySyncListItemSchema, RailwaySyncSchema } from "@app/services/secret-sync/railway/railway-sync-schemas"; import { RenderSyncListItemSchema, RenderSyncSchema } from "@app/services/secret-sync/render/render-sync-schemas"; @@ -84,7 +85,8 @@ const SecretSyncSchema = z.discriminatedUnion("destination", [ ChecklySyncSchema, DigitalOceanAppPlatformSyncSchema, NetlifySyncSchema, - BitbucketSyncSchema + BitbucketSyncSchema, + LaravelForgeSyncSchema ]); const SecretSyncOptionsSchema = z.discriminatedUnion("destination", [ @@ -117,7 +119,8 @@ const SecretSyncOptionsSchema = z.discriminatedUnion("destination", [ ChecklySyncListItemSchema, SupabaseSyncListItemSchema, NetlifySyncListItemSchema, - BitbucketSyncListItemSchema + BitbucketSyncListItemSchema, + LaravelForgeSyncListItemSchema ]); export const registerSecretSyncRouter = async (server: FastifyZodProvider) => { diff --git a/backend/src/server/routes/v2/certificate-templates-v2-router.ts b/backend/src/server/routes/v2/certificate-templates-v2-router.ts new file mode 100644 index 000000000..8a7189727 --- /dev/null +++ b/backend/src/server/routes/v2/certificate-templates-v2-router.ts @@ -0,0 +1,367 @@ +import RE2 from "re2"; +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags } from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { + CertExtendedKeyUsageType, + CertKeyUsageType, + CertSubjectAlternativeNameType, + CertSubjectAttributeType +} from "@app/services/certificate-common/certificate-constants"; +import { certificateTemplateV2ResponseSchema } from "@app/services/certificate-template-v2/certificate-template-v2-schemas"; + +const attributeTypeSchema = z.nativeEnum(CertSubjectAttributeType); +const sanTypeSchema = z.nativeEnum(CertSubjectAlternativeNameType); + +const templateV2SubjectSchema = z + .object({ + type: attributeTypeSchema, + allowed: z.array(z.string()).optional(), + required: z.array(z.string()).optional(), + denied: z.array(z.string()).optional() + }) + .refine( + (data) => { + if (!data.allowed && !data.required && !data.denied) { + return false; + } + return true; + }, + { + message: "Subject attribute must have at least one allowed, required, or denied value" + } + ); + +const templateV2KeyUsagesSchema = z + .object({ + allowed: z.array(z.nativeEnum(CertKeyUsageType)).optional(), + required: z.array(z.nativeEnum(CertKeyUsageType)).optional(), + denied: z.array(z.nativeEnum(CertKeyUsageType)).optional() + }) + .refine( + (data) => { + if (!data.allowed && !data.required && !data.denied) { + return false; + } + return true; + }, + { + message: "Key usages must have at least one allowed, required, or denied value" + } + ); + +const templateV2ExtendedKeyUsagesSchema = z + .object({ + allowed: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(), + required: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(), + denied: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional() + }) + .refine( + (data) => { + if (!data.allowed && !data.required && !data.denied) { + return false; + } + return true; + }, + { + message: "Extended key usages must have at least one allowed, required, or denied value" + } + ); + +const templateV2SanSchema = z + .object({ + type: sanTypeSchema, + allowed: z.array(z.string()).optional(), + required: z.array(z.string()).optional(), + denied: z.array(z.string()).optional() + }) + .refine( + (data) => { + if (!data.allowed && !data.required && !data.denied) { + return false; + } + return true; + }, + { + message: "SAN must have at least one allowed, required, or denied value" + } + ); + +const templateV2ValiditySchema = z.object({ + max: z + .string() + .refine( + (val) => { + if (!val) return true; + if (val.length < 2) return false; + const unit = val.slice(-1); + const number = val.slice(0, -1); + const digitRegex = new RE2("^\\d+$"); + return ["d", "h", "m", "y"].includes(unit) && digitRegex.test(number); + }, + { + message: "Max validity must be in format like '365d', '12m', '1y', or '24h'" + } + ) + .optional() +}); + +const templateV2AlgorithmsSchema = z.object({ + signature: z.array(z.string()).min(1, "At least one signature algorithm must be provided").optional(), + keyAlgorithm: z.array(z.string()).min(1, "At least one key algorithm must be provided").optional() +}); + +const createCertificateTemplateV2Schema = z.object({ + projectId: z.string().min(1), + name: z.string().min(1).max(255, "Name must be between 1 and 255 characters"), + description: z.string().max(1000).optional(), + subject: z.array(templateV2SubjectSchema).optional(), + sans: z.array(templateV2SanSchema).optional(), + keyUsages: templateV2KeyUsagesSchema.optional(), + extendedKeyUsages: templateV2ExtendedKeyUsagesSchema.optional(), + algorithms: templateV2AlgorithmsSchema.optional(), + validity: templateV2ValiditySchema.optional() +}); + +const updateCertificateTemplateV2Schema = z.object({ + name: z.string().min(1).max(255, "Name must be between 1 and 255 characters").optional(), + description: z.string().max(1000).optional(), + subject: z.array(templateV2SubjectSchema).optional(), + sans: z.array(templateV2SanSchema).optional(), + keyUsages: templateV2KeyUsagesSchema.optional(), + extendedKeyUsages: templateV2ExtendedKeyUsagesSchema.optional(), + algorithms: templateV2AlgorithmsSchema.optional(), + validity: templateV2ValiditySchema.optional() +}); + +export const registerCertificateTemplatesV2Router = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + body: createCertificateTemplateV2Schema, + response: { + 200: z.object({ + certificateTemplate: certificateTemplateV2ResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { projectId, ...data } = req.body; + const certificateTemplate = await server.services.certificateTemplateV2.createTemplateV2({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod!, + actorOrgId: req.permission.orgId, + projectId, + data + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId, + event: { + type: EventType.CREATE_CERTIFICATE_TEMPLATE, + metadata: { + certificateTemplateId: certificateTemplate.id, + name: certificateTemplate.name, + projectId: certificateTemplate.projectId + } + } + }); + + return { certificateTemplate }; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + querystring: z.object({ + projectId: z.string().min(1), + offset: z.coerce.number().min(0).default(0), + limit: z.coerce.number().min(1).max(100).default(20), + search: z.string().optional() + }), + response: { + 200: z.object({ + certificateTemplates: certificateTemplateV2ResponseSchema.array(), + totalCount: z.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { templates, totalCount } = await server.services.certificateTemplateV2.listTemplatesV2({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod!, + actorOrgId: req.permission.orgId, + ...req.query + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.query.projectId, + event: { + type: EventType.LIST_CERTIFICATE_TEMPLATES, + metadata: { + projectId: req.query.projectId + } + } + }); + + return { certificateTemplates: templates, totalCount }; + } + }); + + server.route({ + method: "GET", + url: "/:id", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + params: z.object({ + id: z.string().uuid() + }), + response: { + 200: z.object({ + certificateTemplate: certificateTemplateV2ResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateTemplate = await server.services.certificateTemplateV2.getTemplateV2ById({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod!, + actorOrgId: req.permission.orgId, + templateId: req.params.id + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateTemplate.projectId, + event: { + type: EventType.GET_CERTIFICATE_TEMPLATE, + metadata: { + certificateTemplateId: certificateTemplate.id, + name: certificateTemplate.name + } + } + }); + + return { certificateTemplate }; + } + }); + + server.route({ + method: "PATCH", + url: "/:id", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + params: z.object({ + id: z.string().uuid() + }), + body: updateCertificateTemplateV2Schema, + response: { + 200: z.object({ + certificateTemplate: certificateTemplateV2ResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateTemplate = await server.services.certificateTemplateV2.updateTemplateV2({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod!, + actorOrgId: req.permission.orgId, + templateId: req.params.id, + data: req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateTemplate.projectId, + event: { + type: EventType.UPDATE_CERTIFICATE_TEMPLATE, + metadata: { + certificateTemplateId: certificateTemplate.id, + name: certificateTemplate.name + } + } + }); + + return { certificateTemplate }; + } + }); + + server.route({ + method: "DELETE", + url: "/:id", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateTemplates], + params: z.object({ + id: z.string().uuid() + }), + response: { + 200: z.object({ + certificateTemplate: certificateTemplateV2ResponseSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateTemplate = await server.services.certificateTemplateV2.deleteTemplateV2({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod!, + actorOrgId: req.permission.orgId, + templateId: req.params.id + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: certificateTemplate.projectId, + event: { + type: EventType.DELETE_CERTIFICATE_TEMPLATE, + metadata: { + certificateTemplateId: certificateTemplate.id, + name: certificateTemplate.name + } + } + }); + + return { certificateTemplate }; + } + }); +}; diff --git a/backend/src/server/routes/v2/index.ts b/backend/src/server/routes/v2/index.ts index aade29bb7..db4ebb176 100644 --- a/backend/src/server/routes/v2/index.ts +++ b/backend/src/server/routes/v2/index.ts @@ -1,4 +1,5 @@ import { registerCaRouter } from "./certificate-authority-router"; +import { registerCertificateTemplatesV2Router } from "./certificate-templates-v2-router"; import { registerDeprecatedGroupProjectRouter } from "./deprecated-group-project-router"; import { registerDeprecatedIdentityProjectRouter } from "./deprecated-identity-project-router"; import { registerDeprecatedProjectMembershipRouter } from "./deprecated-project-membership-router"; @@ -19,6 +20,8 @@ export const registerV2Routes = async (server: FastifyZodProvider) => { await server.register(registerServiceTokenRouter, { prefix: "/service-token" }); await server.register(registerPasswordRouter, { prefix: "/password" }); + await server.register(registerCertificateTemplatesV2Router, { prefix: "/certificate-templates" }); + await server.register( async (pkiRouter) => { await pkiRouter.register(registerCaRouter, { prefix: "/ca" }); diff --git a/backend/src/server/routes/v2/secret-folder-router.ts b/backend/src/server/routes/v2/secret-folder-router.ts index 0bf062452..251ad8e85 100644 --- a/backend/src/server/routes/v2/secret-folder-router.ts +++ b/backend/src/server/routes/v2/secret-folder-router.ts @@ -263,7 +263,8 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => .default("/") .transform(prefixWithSlash) // Transformations get skipped if path is undefined .transform(removeTrailingSlash) - .describe(FOLDERS.DELETE.path) + .describe(FOLDERS.DELETE.path), + forceDelete: z.boolean().optional().default(false).describe(FOLDERS.DELETE.forceDelete) }), response: { 200: z.object({ @@ -279,7 +280,8 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => actorAuthMethod: req.permission.authMethod, actorOrgId: req.permission.orgId, ...req.body, - idOrName: req.params.folderIdOrName + idOrName: req.params.folderIdOrName, + forceDelete: req.body.forceDelete }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, diff --git a/backend/src/server/routes/v3/certificates-router.ts b/backend/src/server/routes/v3/certificates-router.ts new file mode 100644 index 000000000..549310738 --- /dev/null +++ b/backend/src/server/routes/v3/certificates-router.ts @@ -0,0 +1,346 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags } from "@app/lib/api-docs"; +import { ms } from "@app/lib/ms"; +import { writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { + ACMESANType, + CertificateOrderStatus, + CertKeyAlgorithm, + CertSignatureAlgorithm +} from "@app/services/certificate/certificate-types"; +import { validateCaDateField } from "@app/services/certificate-authority/certificate-authority-validators"; +import { + CertExtendedKeyUsageType, + CertKeyUsageType, + CertSubjectAlternativeNameType +} from "@app/services/certificate-common/certificate-constants"; +import { mapEnumsForValidation } from "@app/services/certificate-common/certificate-utils"; +import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators"; + +interface CertificateRequestForService { + commonName?: string; + keyUsages?: CertKeyUsageType[]; + extendedKeyUsages?: CertExtendedKeyUsageType[]; + altNames?: Array<{ + type: CertSubjectAlternativeNameType; + value: string; + }>; + validity: { + ttl: string; + }; + notBefore?: Date; + notAfter?: Date; + signatureAlgorithm?: string; + keyAlgorithm?: string; +} + +const validateTtlAndDateFields = (data: { notBefore?: string; notAfter?: string; ttl?: string }) => { + const hasDateFields = data.notBefore || data.notAfter; + const hasTtl = data.ttl; + return !(hasDateFields && hasTtl); +}; + +const validateDateOrder = (data: { notBefore?: string; notAfter?: string }) => { + if (data.notBefore && data.notAfter) { + const notBefore = new Date(data.notBefore); + const notAfter = new Date(data.notAfter); + return notBefore < notAfter; + } + return true; +}; + +export const registerCertificatesRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/issue-certificate", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificates], + body: z + .object({ + profileId: z.string().uuid(), + commonName: validateTemplateRegexField.optional(), + ttl: z + .string() + .trim() + .min(1, "TTL cannot be empty") + .refine((val) => ms(val) > 0, "TTL must be a positive number"), + keyUsages: z.nativeEnum(CertKeyUsageType).array().optional(), + extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsageType).array().optional(), + notBefore: validateCaDateField.optional(), + notAfter: validateCaDateField.optional(), + altNames: z + .array( + z.object({ + type: z.nativeEnum(CertSubjectAlternativeNameType), + value: z.string().min(1, "SAN value cannot be empty") + }) + ) + .optional(), + signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm).optional(), + keyAlgorithm: z.nativeEnum(CertKeyAlgorithm).optional() + }) + .refine(validateTtlAndDateFields, { + message: + "Cannot specify both TTL and notBefore/notAfter. Use either TTL for duration-based validity or notBefore/notAfter for explicit date range." + }) + .refine(validateDateOrder, { + message: "notBefore must be earlier than notAfter" + }), + response: { + 200: z.object({ + certificate: z.string().trim(), + issuingCaCertificate: z.string().trim(), + certificateChain: z.string().trim(), + privateKey: z.string().trim().optional(), + serialNumber: z.string().trim(), + certificateId: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const certificateRequestForService: CertificateRequestForService = { + commonName: req.body.commonName, + keyUsages: req.body.keyUsages, + extendedKeyUsages: req.body.extendedKeyUsages, + altNames: req.body.altNames, + validity: { + ttl: req.body.ttl + }, + notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined, + notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined, + signatureAlgorithm: req.body.signatureAlgorithm, + keyAlgorithm: req.body.keyAlgorithm + }; + + const mappedCertificateRequest = mapEnumsForValidation(certificateRequestForService); + + const data = await server.services.certificateV3.issueCertificateFromProfile({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: req.body.profileId, + certificateRequest: mappedCertificateRequest + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: data.projectId, + event: { + type: EventType.ISSUE_CERTIFICATE_FROM_PROFILE, + metadata: { + certificateProfileId: req.body.profileId, + certificateId: data.certificateId, + commonName: req.body.commonName || "", + profileName: data.profileName + } + } + }); + + return data; + } + }); + + server.route({ + method: "POST", + url: "/sign-certificate", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificates], + body: z + .object({ + profileId: z.string().uuid(), + csr: z.string().trim().min(1, "CSR cannot be empty").max(4096, "CSR cannot exceed 4096 characters"), + ttl: z + .string() + .trim() + .min(1, "TTL cannot be empty") + .refine((val) => ms(val) > 0, "TTL must be a positive number"), + notBefore: validateCaDateField.optional(), + notAfter: validateCaDateField.optional(), + signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm).optional(), + keyAlgorithm: z.nativeEnum(CertKeyAlgorithm).optional() + }) + .refine(validateTtlAndDateFields, { + message: + "Cannot specify both TTL and notBefore/notAfter. Use either TTL for duration-based validity or notBefore/notAfter for explicit date range." + }) + .refine(validateDateOrder, { + message: "notBefore must be earlier than notAfter" + }), + response: { + 200: z.object({ + certificate: z.string().trim(), + issuingCaCertificate: z.string().trim(), + certificateChain: z.string().trim(), + serialNumber: z.string().trim(), + certificateId: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const data = await server.services.certificateV3.signCertificateFromProfile({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: req.body.profileId, + csr: req.body.csr, + validity: { + ttl: req.body.ttl + }, + notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined, + notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined, + signatureAlgorithm: req.body.signatureAlgorithm, + keyAlgorithm: req.body.keyAlgorithm + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: data.projectId, + event: { + type: EventType.SIGN_CERTIFICATE_FROM_PROFILE, + metadata: { + certificateProfileId: req.body.profileId, + certificateId: data.certificateId, + profileName: data.profileName, + commonName: "" + } + } + }); + + return data; + } + }); + + server.route({ + method: "POST", + url: "/order-certificate", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificates], + body: z + .object({ + profileId: z.string().uuid(), + subjectAlternativeNames: z + .array( + z.object({ + type: z.nativeEnum(ACMESANType), + value: z + .string() + .trim() + .min(1, "SAN value cannot be empty") + .max(255, "SAN value must be less than 255 characters") + }) + ) + .min(1, "At least one subject alternative name must be provided"), + ttl: z + .string() + .trim() + .min(1, "TTL cannot be empty") + .refine((val) => ms(val) > 0, "TTL must be a positive number"), + keyUsages: z.nativeEnum(CertKeyUsageType).array().optional(), + extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsageType).array().optional(), + notBefore: validateCaDateField.optional(), + notAfter: validateCaDateField.optional(), + commonName: validateTemplateRegexField.optional(), + signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm).optional(), + keyAlgorithm: z.nativeEnum(CertKeyAlgorithm).optional() + }) + .refine(validateTtlAndDateFields, { + message: + "Cannot specify both TTL and notBefore/notAfter. Use either TTL for duration-based validity or notBefore/notAfter for explicit date range." + }) + .refine(validateDateOrder, { + message: "notBefore must be earlier than notAfter" + }), + response: { + 200: z.object({ + orderId: z.string(), + status: z.nativeEnum(CertificateOrderStatus), + subjectAlternativeNames: z.array( + z.object({ + type: z.nativeEnum(ACMESANType), + value: z.string(), + status: z.nativeEnum(CertificateOrderStatus) + }) + ), + authorizations: z.array( + z.object({ + identifier: z.object({ + type: z.nativeEnum(ACMESANType), + value: z.string() + }), + status: z.nativeEnum(CertificateOrderStatus), + expires: z.string().optional(), + challenges: z.array( + z.object({ + type: z.string(), + status: z.nativeEnum(CertificateOrderStatus), + url: z.string(), + token: z.string() + }) + ) + }) + ), + finalize: z.string(), + certificate: z.string().optional() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const data = await server.services.certificateV3.orderCertificateFromProfile({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: req.body.profileId, + certificateOrder: { + altNames: req.body.subjectAlternativeNames, + validity: { + ttl: req.body.ttl + }, + commonName: req.body.commonName, + keyUsages: req.body.keyUsages, + extendedKeyUsages: req.body.extendedKeyUsages, + notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined, + notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined, + signatureAlgorithm: req.body.signatureAlgorithm, + keyAlgorithm: req.body.keyAlgorithm + } + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: data.projectId, + event: { + type: EventType.ORDER_CERTIFICATE_FROM_PROFILE, + metadata: { + certificateProfileId: req.body.profileId, + orderId: data.orderId, + profileName: data.profileName + } + } + }); + + return data; + } + }); +}; diff --git a/backend/src/server/routes/v3/external-migration-router.ts b/backend/src/server/routes/v3/external-migration-router.ts index a3b744485..f0737e01d 100644 --- a/backend/src/server/routes/v3/external-migration-router.ts +++ b/backend/src/server/routes/v3/external-migration-router.ts @@ -7,6 +7,7 @@ import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; import { ExternalMigrationProviders, + VaultImportStatus, VaultMappingType } from "@app/services/external-migration/external-migration-types"; @@ -113,4 +114,366 @@ export const registerExternalMigrationRouter = async (server: FastifyZodProvider return { enabled }; } }); + + server.route({ + method: "GET", + url: "/vault/configs", + config: { + rateLimit: readLimit + }, + schema: { + response: { + 200: z.object({ + configs: z + .object({ + id: z.string(), + orgId: z.string(), + namespace: z.string(), + connectionId: z.string().nullish(), + createdAt: z.date(), + updatedAt: z.date() + }) + .array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const configs = await server.services.migration.getVaultExternalMigrationConfigs({ + actor: req.permission + }); + + return { configs }; + } + }); + + server.route({ + method: "POST", + url: "/vault/configs", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + connectionId: z.string(), + namespace: z.string() + }), + response: { + 200: z.object({ + config: z.object({ + id: z.string(), + orgId: z.string(), + namespace: z.string(), + connectionId: z.string().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const config = await server.services.migration.createVaultExternalMigration({ + ...req.body, + actor: req.permission + }); + + return { config }; + } + }); + + server.route({ + method: "PUT", + url: "/vault/configs/:id", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + id: z.string() + }), + body: z.object({ + connectionId: z.string(), + namespace: z.string() + }), + response: { + 200: z.object({ + config: z.object({ + id: z.string(), + orgId: z.string(), + namespace: z.string(), + connectionId: z.string().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const config = await server.services.migration.updateVaultExternalMigration({ + id: req.params.id, + ...req.body, + actor: req.permission + }); + + return { config }; + } + }); + + server.route({ + method: "DELETE", + url: "/vault/configs/:id", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + id: z.string() + }), + response: { + 200: z.object({ + config: z.object({ + id: z.string(), + orgId: z.string(), + namespace: z.string(), + connectionId: z.string().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const config = await server.services.migration.deleteVaultExternalMigration({ + id: req.params.id, + actor: req.permission + }); + + return { config }; + } + }); + + server.route({ + method: "GET", + url: "/vault/namespaces", + config: { + rateLimit: readLimit + }, + schema: { + response: { + 200: z.object({ + namespaces: z.array(z.object({ id: z.string(), name: z.string() })) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const namespaces = await server.services.migration.getVaultNamespaces({ + actor: req.permission + }); + + return { namespaces }; + } + }); + + server.route({ + method: "GET", + url: "/vault/policies", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + namespace: z.string() + }), + response: { + 200: z.object({ + policies: z.array(z.object({ name: z.string(), rules: z.string() })) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const policies = await server.services.migration.getVaultPolicies({ + actor: req.permission, + namespace: req.query.namespace + }); + + return { policies }; + } + }); + + server.route({ + method: "GET", + url: "/vault/mounts", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + namespace: z.string() + }), + response: { + 200: z.object({ + mounts: z.array(z.object({ path: z.string(), type: z.string(), version: z.string().nullish() })) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const mounts = await server.services.migration.getVaultMounts({ + actor: req.permission, + namespace: req.query.namespace + }); + + return { mounts }; + } + }); + + server.route({ + method: "GET", + url: "/vault/auth-mounts", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + namespace: z.string(), + authType: z.string().optional() + }), + response: { + 200: z.object({ + mounts: z.array(z.object({ path: z.string(), type: z.string() })) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const mounts = await server.services.migration.getVaultAuthMounts({ + actor: req.permission, + namespace: req.query.namespace, + authType: req.query.authType + }); + + return { mounts }; + } + }); + + server.route({ + method: "POST", + url: "/vault/import-secrets", + config: { + rateLimit: writeLimit + }, + schema: { + body: z.object({ + projectId: z.string(), + environment: z.string(), + secretPath: z.string(), + vaultNamespace: z.string(), + vaultSecretPath: z.string() + }), + response: { + 200: z.object({ + status: z.nativeEnum(VaultImportStatus) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const result = await server.services.migration.importVaultSecrets({ + actor: req.permission, + auditLogInfo: req.auditLogInfo, + ...req.body + }); + + return result; + } + }); + + server.route({ + method: "GET", + url: "/vault/secret-paths", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + namespace: z.string(), + mountPath: z.string() + }), + response: { + 200: z.object({ + secretPaths: z.string().array() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const secretPaths = await server.services.migration.getVaultSecretPaths({ + actor: req.permission, + namespace: req.query.namespace, + mountPath: req.query.mountPath + }); + + return { secretPaths }; + } + }); + + server.route({ + method: "GET", + url: "/vault/auth-roles/kubernetes", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + namespace: z.string(), + mountPath: z.string() + }), + + response: { + 200: z.object({ + roles: z.array( + z.object({ + name: z.string(), + mountPath: z.string(), + bound_service_account_names: z.array(z.string()), + bound_service_account_namespaces: z.array(z.string()), + token_ttl: z.number().optional(), + token_max_ttl: z.number().optional(), + token_policies: z.array(z.string()).optional(), + token_bound_cidrs: z.array(z.string()).optional(), + token_explicit_max_ttl: z.number().optional(), + token_no_default_policy: z.boolean().optional(), + token_num_uses: z.number().optional(), + token_period: z.number().optional(), + token_type: z.string().optional(), + audience: z.string().optional(), + alias_name_source: z.string().optional(), + config: z.object({ + kubernetes_host: z.string(), + kubernetes_ca_cert: z.string().optional(), + issuer: z.string().optional(), + disable_iss_validation: z.boolean().optional(), + disable_local_ca_jwt: z.boolean().optional() + }) + }) + ) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const roles = await server.services.migration.getVaultKubernetesAuthRoles({ + actor: req.permission, + namespace: req.query.namespace, + mountPath: req.query.mountPath + }); + + return { roles }; + } + }); }; diff --git a/backend/src/server/routes/v3/index.ts b/backend/src/server/routes/v3/index.ts index d7fa94d6b..47c3c2cb8 100644 --- a/backend/src/server/routes/v3/index.ts +++ b/backend/src/server/routes/v3/index.ts @@ -1,3 +1,4 @@ +import { registerCertificatesRouter } from "./certificates-router"; import { registerDeprecatedSecretRouter } from "./deprecated-secret-router"; import { registerExternalMigrationRouter } from "./external-migration-router"; import { registerLoginRouter } from "./login-router"; @@ -10,4 +11,5 @@ export const registerV3Routes = async (server: FastifyZodProvider) => { await server.register(registerUserRouter, { prefix: "/users" }); await server.register(registerDeprecatedSecretRouter, { prefix: "/secrets" }); await server.register(registerExternalMigrationRouter, { prefix: "/external-migration" }); + await server.register(registerCertificatesRouter, { prefix: "/certificates" }); }; diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index 996cd872a..54b70c7d3 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -37,7 +37,8 @@ export enum AppConnection { DigitalOcean = "digital-ocean", Netlify = "netlify", Okta = "okta", - Redis = "redis" + Redis = "redis", + LaravelForge = "laravel-forge" } export enum AWSRegion { diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index 73abef78d..464f718ce 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -103,6 +103,11 @@ import { HumanitecConnectionMethod, validateHumanitecConnectionCredentials } from "./humanitec"; +import { + getLaravelForgeConnectionListItem, + LaravelForgeConnectionMethod, + validateLaravelForgeConnectionCredentials +} from "./laravel-forge"; import { getLdapConnectionListItem, LdapConnectionMethod, validateLdapConnectionCredentials } from "./ldap"; import { getMsSqlConnectionListItem, MsSqlConnectionMethod } from "./mssql"; import { MySqlConnectionMethod } from "./mysql/mysql-connection-enums"; @@ -187,6 +192,7 @@ export const listAppConnectionOptions = (projectType?: ProjectType) => { getOnePassConnectionListItem(), getHerokuConnectionListItem(), getRenderConnectionListItem(), + getLaravelForgeConnectionListItem(), getFlyioConnectionListItem(), getGitLabConnectionListItem(), getCloudflareConnectionListItem(), @@ -316,6 +322,7 @@ export const validateAppConnectionCredentials = async ( [AppConnection.OnePass]: validateOnePassConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Heroku]: validateHerokuConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Render]: validateRenderConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.LaravelForge]: validateLaravelForgeConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Flyio]: validateFlyioConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.GitLab]: validateGitLabConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Cloudflare]: validateCloudflareConnectionCredentials as TAppConnectionCredentialsValidator, @@ -368,6 +375,7 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => case ZabbixConnectionMethod.ApiToken: case DigitalOceanConnectionMethod.ApiToken: case OktaConnectionMethod.ApiToken: + case LaravelForgeConnectionMethod.ApiToken: return "API Token"; case PostgresConnectionMethod.UsernameAndPassword: case MsSqlConnectionMethod.UsernameAndPassword: @@ -463,7 +471,8 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.DigitalOcean]: platformManagedCredentialsNotSupported, [AppConnection.Netlify]: platformManagedCredentialsNotSupported, [AppConnection.Okta]: platformManagedCredentialsNotSupported, - [AppConnection.Redis]: platformManagedCredentialsNotSupported + [AppConnection.Redis]: platformManagedCredentialsNotSupported, + [AppConnection.LaravelForge]: 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 e3235d2f7..c01d9d1b4 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -28,6 +28,7 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.OnePass]: "1Password", [AppConnection.Heroku]: "Heroku", [AppConnection.Render]: "Render", + [AppConnection.LaravelForge]: "Laravel Forge", [AppConnection.Flyio]: "Fly.io", [AppConnection.GitLab]: "GitLab", [AppConnection.Cloudflare]: "Cloudflare", @@ -70,6 +71,7 @@ export const APP_CONNECTION_PLAN_MAP: Record { + let activeCount = 0; + const queue: Array<() => void> = []; + + const next = () => { + activeCount -= 1; + if (queue.length > 0) { + const resolve = queue.shift(); + resolve?.(); + } + }; + + return async (fn: () => Promise): Promise => { + // If we're at the limit, wait in queue + if (activeCount >= limit) { + await new Promise((resolve) => { + queue.push(resolve); + }); + } + + activeCount += 1; + + try { + return await fn(); + } finally { + next(); + } + }; +}; export const getHCVaultInstanceUrl = async (config: THCVaultConnectionConfig) => { const instanceUrl = removeTrailingSlash(config.credentials.instanceUrl); @@ -181,30 +229,573 @@ export const validateHCVaultConnectionCredentials = async ( } }; -export const listHCVaultMounts = async ( +export const listHCVaultPolicies = async ( + namespace: string, connection: THCVaultConnection, gatewayService: Pick ) => { const instanceUrl = await getHCVaultInstanceUrl(connection); const accessToken = await getHCVaultAccessToken(connection, gatewayService); + try { + const { data: listData } = await requestWithHCVaultGateway<{ + data: { + policies: string[]; + }; + }>(connection, gatewayService, { + url: `${instanceUrl}/v1/sys/policy`, + method: "GET", + headers: { + "X-Vault-Token": accessToken, + "X-Vault-Namespace": namespace + } + }); + + const policyNames = listData.data.policies || []; + + const limiter = createConcurrencyLimiter(HC_VAULT_CONCURRENCY_LIMIT); + + const policies = await Promise.all( + policyNames.map((policyName) => + limiter(async () => { + try { + const { data: policyData } = await requestWithHCVaultGateway<{ + data: { + name: string; + rules: string; + }; + }>(connection, gatewayService, { + url: `${instanceUrl}/v1/sys/policy/${policyName}`, + method: "GET", + headers: { + "X-Vault-Token": accessToken, + "X-Vault-Namespace": namespace + } + }); + + return { + name: policyData.data.name, + rules: policyData.data.rules + }; + } catch (error: unknown) { + logger.error(error, `Unable to fetch policy details for ${policyName}`); + return { + name: policyName, + rules: "" + }; + } + }) + ) + ); + + return policies; + } catch (error: unknown) { + logger.error(error, "Unable to list HC Vault policies"); + + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to list policies: ${error.message || "Unknown error"}` + }); + } + + throw new BadRequestError({ + message: "Unable to list policies from HashiCorp Vault" + }); + } +}; + +export const listHCVaultNamespaces = async ( + connection: THCVaultConnection, + gatewayService: Pick +) => { + const instanceUrl = await getHCVaultInstanceUrl(connection); + const accessToken = await getHCVaultAccessToken(connection, gatewayService); + + const currentNamespace = connection.credentials.namespace || "/"; + + // Helper function to fetch namespaces at a specific path + const fetchNamespacesAtPath = async (namespacePath: string): Promise => { + try { + const { data } = await requestWithHCVaultGateway<{ + data: { + keys: string[]; + key_info?: { + [key: string]: { + id: string; + path: string; + custom_metadata?: Record; + }; + }; + }; + }>(connection, gatewayService, { + url: `${instanceUrl}/v1/sys/namespaces?list=true`, + method: "GET", + headers: { + "X-Vault-Token": accessToken, + "X-Vault-Namespace": namespacePath + } + }); + + return data.data.keys || []; + } catch (error: unknown) { + if (error instanceof AxiosError && error.response?.status === 404) { + // No child namespaces at this path + return null; + } + throw error; + } + }; + + // Recursive function to get all namespaces at all depths with controlled parallelization + const recursivelyGetAllNamespaces = async ( + parentPath: string, + limiter: ReturnType + ): Promise => { + const childKeys = await fetchNamespacesAtPath(parentPath); + + if (childKeys === null || childKeys.length === 0) { + return []; + } + + // Process namespaces in parallel with concurrency control + const namespacesArrays = await Promise.all( + childKeys.map((namespaceKey) => + limiter(async () => { + // Remove trailing slash from the key + const cleanNamespaceKey = namespaceKey.replace(/\/$/, ""); + + // Build the full path + let fullNamespacePath: string; + if (parentPath === "/") { + fullNamespacePath = cleanNamespaceKey; + } else { + fullNamespacePath = `${parentPath}/${cleanNamespaceKey}`; + } + + // Recursively fetch child namespaces + const childNamespaces = await recursivelyGetAllNamespaces(fullNamespacePath, limiter); + + // Return this namespace and all its children + return [fullNamespacePath, ...childNamespaces]; + }) + ) + ); + + // Flatten the arrays into a single array + return namespacesArrays.flat(); + }; + + try { + // Create concurrency limiter to avoid overwhelming the Vault instance + const limiter = createConcurrencyLimiter(HC_VAULT_CONCURRENCY_LIMIT); + + // Get all namespaces starting from currentNamespace + const childNamespaces = await recursivelyGetAllNamespaces(currentNamespace, limiter); + + // Build the result array with full paths + const namespaces = childNamespaces.map((path) => ({ + id: path, + name: path + })); + + // Always include the current/root namespace + namespaces.unshift({ + id: currentNamespace, + name: currentNamespace + }); + + return namespaces; + } catch (error: unknown) { + logger.error(error, "Unable to list HC Vault namespaces"); + + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to list namespaces: ${error.message || "Unknown error"}` + }); + } + + throw new BadRequestError({ + message: "Unable to list namespaces from HashiCorp Vault" + }); + } +}; + +export const listHCVaultMounts = async ( + connection: THCVaultConnection, + gatewayService: Pick, + namespace?: string +) => { + const instanceUrl = await getHCVaultInstanceUrl(connection); + const accessToken = await getHCVaultAccessToken(connection, gatewayService); + + const targetNamespace = namespace || connection.credentials.namespace; + const { data } = await requestWithHCVaultGateway(connection, gatewayService, { url: `${instanceUrl}/v1/sys/mounts`, method: "GET", headers: { "X-Vault-Token": accessToken, - ...(connection.credentials.namespace ? { "X-Vault-Namespace": connection.credentials.namespace } : {}) + ...(targetNamespace ? { "X-Vault-Namespace": targetNamespace } : {}) } }); - const mounts: string[] = []; + const mounts: THCVaultMount[] = []; - // Filter for "kv" version 2 type only Object.entries(data.data).forEach(([path, mount]) => { - if (mount.type === "kv" && mount.options?.version === "2") { - mounts.push(path); - } + mounts.push({ + path, + type: mount.type, + version: mount.options?.version + }); }); return mounts; }; + +export const listHCVaultSecretPaths = async ( + namespace: string, + connection: THCVaultConnection, + gatewayService: Pick, + filterMountPath?: string +) => { + const instanceUrl = await getHCVaultInstanceUrl(connection); + const accessToken = await getHCVaultAccessToken(connection, gatewayService); + + const getPaths = async (mountPath: string, secretPath: string, kvVersion: "1" | "2"): Promise => { + try { + let path: string; + if (kvVersion === "2") { + // For KV v2: /v1/{mount}/metadata/{path}?list=true + path = secretPath ? `${mountPath}/metadata/${secretPath}` : `${mountPath}/metadata`; + } else { + // For KV v1: /v1/{mount}/{path}?list=true + path = secretPath ? `${mountPath}/${secretPath}` : mountPath; + } + + const { data } = await requestWithHCVaultGateway<{ + data: { + keys: string[]; + }; + }>(connection, gatewayService, { + url: `${instanceUrl}/v1/${path}?list=true`, + method: "GET", + headers: { + "X-Vault-Token": accessToken, + "X-Vault-Namespace": namespace + } + }); + + return data.data.keys; + } catch (error) { + if (error instanceof AxiosError && error.response?.status === 404) { + return null; + } + + throw error; + } + }; + + // Recursive function to get all secret paths in a mount with controlled parallelization + const recursivelyGetAllPaths = async ( + mountPath: string, + kvVersion: "1" | "2", + limiter: ReturnType, + currentPath: string = "" + ): Promise => { + const paths = await getPaths(mountPath, currentPath, kvVersion); + + if (paths === null || paths.length === 0) { + return []; + } + + // Process paths in parallel with concurrency control + const secretPathsArrays = await Promise.all( + paths.map((path) => + limiter(async () => { + const cleanPath = path.endsWith("/") ? path.slice(0, -1) : path; + const fullItemPath = currentPath ? `${currentPath}/${cleanPath}` : cleanPath; + + if (path.endsWith("/")) { + // it's a folder so we recurse into it + return recursivelyGetAllPaths(mountPath, kvVersion, limiter, fullItemPath); + } + // it's a secret so we return it + return [`${mountPath}/${fullItemPath}`]; + }) + ) + ); + + // Flatten the arrays into a single array + return secretPathsArrays.flat(); + }; + + // Get all mounts + const mounts = await listHCVaultMounts(connection, gatewayService, namespace); + + // Filter for KV mounts (kv, kv-v1, kv-v2) + let kvMounts = mounts.filter((mount) => mount.type === "kv" || mount.type.startsWith("kv")); + + // If filterMountPath is provided, filter to only that mount + if (filterMountPath) { + const normalizedFilterPath = filterMountPath.replace(/\/$/, ""); // Remove trailing slash + kvMounts = kvMounts.filter((mount) => mount.path.replace(/\/$/, "") === normalizedFilterPath); + } + + // Create concurrency limiter to avoid overwhelming the Vault instance + const limiter = createConcurrencyLimiter(HC_VAULT_CONCURRENCY_LIMIT); + + // Collect all secret paths from all KV mounts in parallel + const allSecretPathsArrays = await Promise.all( + kvMounts.map(async (mount) => { + const kvVersion = mount.version === "2" ? "2" : "1"; + const cleanMountPath = mount.path.replace(/\/$/, ""); // Remove trailing slash + return recursivelyGetAllPaths(cleanMountPath, kvVersion, limiter); + }) + ); + + // Flatten the arrays into a single array + const allSecretPaths = allSecretPathsArrays.flat(); + + return allSecretPaths; +}; + +export const getHCVaultSecretsForPath = async ( + namespace: string, + secretPath: string, + connection: THCVaultConnection, + gatewayService: Pick +) => { + const instanceUrl = await getHCVaultInstanceUrl(connection); + const accessToken = await getHCVaultAccessToken(connection, gatewayService); + + try { + // Extract mount and path from the secretPath + // secretPath format: {mount}/{path} + const pathParts = secretPath.split("/"); + const mountPath = pathParts[0]; + const actualPath = pathParts.slice(1).join("/"); + + if (!mountPath || !actualPath) { + throw new BadRequestError({ + message: "Invalid secret path format. Expected format: {mount}/{path}" + }); + } + + // Get mounts to determine KV version + const mounts = await listHCVaultMounts(connection, gatewayService, namespace); + const mount = mounts.find((m) => m.path.replace(/\/$/, "") === mountPath); + + if (!mount) { + throw new BadRequestError({ + message: `Mount '${mountPath}' not found in HashiCorp Vault` + }); + } + + const kvVersion = mount.version === "2" ? "2" : "1"; + + // Fetch secrets based on KV version + if (kvVersion === "2") { + // For KV v2: /v1/{mount}/data/{path} + const { data } = await requestWithHCVaultGateway<{ + data: { + data: Record; // KV v2 has nested data structure + metadata: { + created_time: string; + deletion_time: string; + destroyed: boolean; + version: number; + }; + }; + }>(connection, gatewayService, { + url: `${instanceUrl}/v1/${mountPath}/data/${actualPath}`, + method: "GET", + headers: { + "X-Vault-Token": accessToken, + "X-Vault-Namespace": namespace + } + }); + + return data.data.data; + } + + // For KV v1: /v1/{mount}/{path} + const { data } = await requestWithHCVaultGateway<{ + data: Record; // KV v1 has flat data structure + lease_duration: number; + lease_id: string; + renewable: boolean; + }>(connection, gatewayService, { + url: `${instanceUrl}/v1/${mountPath}/${actualPath}`, + method: "GET", + headers: { + "X-Vault-Token": accessToken, + "X-Vault-Namespace": namespace + } + }); + + return data.data; + } catch (error: unknown) { + logger.error(error, "Unable to fetch secrets from HC Vault path"); + + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to fetch secrets: ${error.message || "Unknown error"}` + }); + } + + if (error instanceof BadRequestError) { + throw error; + } + + throw new BadRequestError({ + message: "Unable to fetch secrets from HashiCorp Vault" + }); + } +}; + +export const getHCVaultAuthMounts = async ( + namespace: string, + authType: HCVaultAuthType | undefined, + connection: THCVaultConnection, + gatewayService: Pick +): Promise => { + const instanceUrl = await getHCVaultInstanceUrl(connection); + const accessToken = await getHCVaultAccessToken(connection, gatewayService); + + try { + const { data } = await requestWithHCVaultGateway(connection, gatewayService, { + url: `${instanceUrl}/v1/sys/auth`, + method: "GET", + headers: { + "X-Vault-Token": accessToken, + "X-Vault-Namespace": namespace + } + }); + + const authMounts: THCVaultAuthMount[] = []; + + Object.entries(data.data).forEach(([path, authMethod]) => { + // If authType is specified, filter by it; otherwise, include all + if (!authType || authMethod.type === authType) { + authMounts.push({ + path, + type: authMethod.type, + description: authMethod.description, + accessor: authMethod.accessor + }); + } + }); + + return authMounts; + } catch (error: unknown) { + const authTypeStr = authType || "all"; + logger.error(error, `Unable to list HC Vault ${authTypeStr} auth mounts`); + + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to list ${authTypeStr} auth mounts: ${error.message || "Unknown error"}` + }); + } + + throw new BadRequestError({ + message: `Unable to list ${authTypeStr} auth mounts from HashiCorp Vault` + }); + } +}; + +export const getHCVaultKubernetesAuthRoles = async ( + namespace: string, + mountPath: string, + connection: THCVaultConnection, + gatewayService: Pick +): Promise => { + const instanceUrl = await getHCVaultInstanceUrl(connection); + const accessToken = await getHCVaultAccessToken(connection, gatewayService); + + // Remove trailing slash from mount path + const cleanMountPath = mountPath.endsWith("/") ? mountPath.slice(0, -1) : mountPath; + + try { + // 1. Get the Kubernetes auth configuration for this mount + const { data: configResponse } = await requestWithHCVaultGateway<{ data: THCVaultKubernetesAuthConfig }>( + connection, + gatewayService, + { + url: `${instanceUrl}/v1/auth/${cleanMountPath}/config`, + method: "GET", + headers: { + "X-Vault-Token": accessToken, + "X-Vault-Namespace": namespace + } + } + ); + + const kubernetesConfig = configResponse.data; + + // 2. List all roles in this mount + const { data: roleListResponse } = await requestWithHCVaultGateway<{ data: { keys: string[] } }>( + connection, + gatewayService, + { + url: `${instanceUrl}/v1/auth/${cleanMountPath}/role`, + method: "LIST", + headers: { + "X-Vault-Token": accessToken, + "X-Vault-Namespace": namespace + } + } + ); + + const roleNames = roleListResponse.data.keys; + + if (!roleNames || roleNames.length === 0) { + return []; + } + + // 3. Fetch details for each role with concurrency control + const limiter = createConcurrencyLimiter(HC_VAULT_CONCURRENCY_LIMIT); + + const roleDetailsPromises = roleNames.map((roleName) => + limiter(async () => { + const { data: roleResponse } = await requestWithHCVaultGateway<{ data: THCVaultKubernetesAuthRole }>( + connection, + gatewayService, + { + url: `${instanceUrl}/v1/auth/${cleanMountPath}/role/${roleName}`, + method: "GET", + headers: { + "X-Vault-Token": accessToken, + "X-Vault-Namespace": namespace + } + } + ); + + // 4. Merge the role with the config + return { + ...roleResponse.data, + name: roleName, + config: kubernetesConfig, + mountPath: cleanMountPath + } as THCVaultKubernetesAuthRoleWithConfig; + }) + ); + + const roles = await Promise.all(roleDetailsPromises); + + return roles; + } catch (error: unknown) { + logger.error(error, "Unable to list HC Vault Kubernetes auth roles"); + + if (error instanceof AxiosError) { + const errorMessage = + (error.response?.data as { errors?: string[] })?.errors?.[0] || error.message || "Unknown error"; + throw new BadRequestError({ + message: `Failed to list Kubernetes auth roles: ${errorMessage}` + }); + } + + throw new BadRequestError({ + message: "Unable to list Kubernetes auth roles from HashiCorp Vault" + }); + } +}; diff --git a/backend/src/services/app-connection/hc-vault/hc-vault-connection-service.ts b/backend/src/services/app-connection/hc-vault/hc-vault-connection-service.ts index 589c7c1bd..037964da4 100644 --- a/backend/src/services/app-connection/hc-vault/hc-vault-connection-service.ts +++ b/backend/src/services/app-connection/hc-vault/hc-vault-connection-service.ts @@ -21,7 +21,8 @@ export const hcVaultConnectionService = ( try { const mounts = await listHCVaultMounts(appConnection, gatewayService); - return mounts; + // Filter for KV version 2 mounts only and extract just the paths + return mounts.filter((mount) => mount.type === "kv" && mount.version === "2").map((mount) => mount.path); } catch (error) { logger.error(error, "Failed to establish connection with Hashicorp Vault"); return []; diff --git a/backend/src/services/app-connection/hc-vault/hc-vault-connection-types.ts b/backend/src/services/app-connection/hc-vault/hc-vault-connection-types.ts index 6f254eda0..d25dbc9e6 100644 --- a/backend/src/services/app-connection/hc-vault/hc-vault-connection-types.ts +++ b/backend/src/services/app-connection/hc-vault/hc-vault-connection-types.ts @@ -33,3 +33,65 @@ export type THCVaultMountResponse = { }; }; }; + +export type THCVaultMount = { + path: string; + type: string; + version?: string | null; +}; + +export type THCVaultAuthMountResponse = { + data: { + [key: string]: { + type: string; + description: string; + accessor: string; + config: { + default_lease_ttl: number; + max_lease_ttl: number; + force_no_cache: boolean; + }; + local: boolean; + seal_wrap: boolean; + external_entropy_access: boolean; + options: Record | null; + }; + }; +}; + +export type THCVaultAuthMount = { + path: string; + type: string; + description: string; + accessor: string; +}; + +export type THCVaultKubernetesAuthConfig = { + kubernetes_host: string; + kubernetes_ca_cert?: string; + issuer?: string; + disable_iss_validation?: boolean; + disable_local_ca_jwt?: boolean; +}; + +export type THCVaultKubernetesAuthRole = { + name: string; + bound_service_account_names: string[]; + bound_service_account_namespaces: string[]; + token_ttl?: number; + token_max_ttl?: number; + token_policies?: string[]; + token_bound_cidrs?: string[]; + token_explicit_max_ttl?: number; + token_no_default_policy?: boolean; + token_num_uses?: number; + token_period?: number; + token_type?: string; + audience?: string; + alias_name_source?: string; +}; + +export type THCVaultKubernetesAuthRoleWithConfig = THCVaultKubernetesAuthRole & { + config: THCVaultKubernetesAuthConfig; + mountPath: string; +}; diff --git a/backend/src/services/app-connection/laravel-forge/index.ts b/backend/src/services/app-connection/laravel-forge/index.ts new file mode 100644 index 000000000..9a3637d06 --- /dev/null +++ b/backend/src/services/app-connection/laravel-forge/index.ts @@ -0,0 +1,4 @@ +export * from "./laravel-forge-connection-enums"; +export * from "./laravel-forge-connection-fns"; +export * from "./laravel-forge-connection-schemas"; +export * from "./laravel-forge-connection-types"; diff --git a/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-enums.ts b/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-enums.ts new file mode 100644 index 000000000..548d3adfb --- /dev/null +++ b/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-enums.ts @@ -0,0 +1,3 @@ +export enum LaravelForgeConnectionMethod { + ApiToken = "api-token" +} diff --git a/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-fns.ts b/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-fns.ts new file mode 100644 index 000000000..e63c659a7 --- /dev/null +++ b/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-fns.ts @@ -0,0 +1,165 @@ +/* eslint-disable no-await-in-loop */ +import { AxiosError } from "axios"; + +import { request } from "@app/lib/config/request"; +import { BadRequestError, InternalServerError } from "@app/lib/errors"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; + +import { AppConnection } from "../app-connection-enums"; +import { LaravelForgeConnectionMethod } from "./laravel-forge-connection-enums"; +import { + TLaravelForgeConnection, + TLaravelForgeConnectionConfig, + TLaravelForgeOrganization, + TLaravelForgeServer, + TLaravelForgeSite, + TRawLaravelForgeOrganization, + TRawLaravelForgeServer, + TRawLaravelForgeSite +} from "./laravel-forge-connection-types"; + +export const getLaravelForgeConnectionListItem = () => { + return { + name: "Laravel Forge" as const, + app: AppConnection.LaravelForge as const, + methods: Object.values(LaravelForgeConnectionMethod) as [LaravelForgeConnectionMethod.ApiToken] + }; +}; + +export const validateLaravelForgeConnectionCredentials = async (config: TLaravelForgeConnectionConfig) => { + const { credentials: inputCredentials } = config; + + try { + // Using the /api/me endpoint to validate the API token + await request.get(`${IntegrationUrls.LARAVELFORGE_API_URL}/api/me`, { + headers: { + Authorization: `Bearer ${inputCredentials.apiToken}`, + Accept: "application/json", + "Content-Type": "application/json" + } + }); + } catch (error) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to validate credentials: ${error.message || "Unknown error"}` + }); + } + throw new BadRequestError({ + message: "Unable to validate connection: verify credentials" + }); + } + + return inputCredentials; +}; + +type TLaravelForgeApiResponse = { + data: T[]; + links?: { + next?: string; + }; + meta?: { + next_cursor?: string; + prev_cursor?: string | null; + }; +}; + +const fetchAllPages = async ( + apiToken: string, + url: string, + params?: Record +): Promise => { + const allItems: T[] = []; + let nextUrl: string | null = url; + const queryParams = params || {}; + + while (nextUrl) { + try { + const response: { data: TLaravelForgeApiResponse } = await request.get>(nextUrl, { + params: queryParams, + headers: { + Authorization: `Bearer ${apiToken}`, + Accept: "application/json", + "Content-Type": "application/json" + } + }); + + if (!response?.data?.data) { + throw new InternalServerError({ + message: `Failed to fetch data from ${url}: Response was empty or malformed` + }); + } + + allItems.push(...response.data.data); + + if (response.data.links?.next) { + nextUrl = response.data.links.next; + } else { + nextUrl = null; + } + } catch (error) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to fetch data from ${url}: ${error.message || "Unknown error"}` + }); + } + throw error; + } + } + + return allItems; +}; + +export const listLaravelForgeOrganizations = async ( + appConnection: TLaravelForgeConnection +): Promise => { + const { credentials } = appConnection; + const { apiToken } = credentials; + + const rawOrganizations = await fetchAllPages( + apiToken, + `${IntegrationUrls.LARAVELFORGE_API_URL}/api/orgs` + ); + + return rawOrganizations.map((org: TRawLaravelForgeOrganization) => ({ + id: org.id, + name: org.attributes.name, + slug: org.attributes.slug + })); +}; + +export const listLaravelForgeServers = async ( + appConnection: TLaravelForgeConnection, + organizationSlug: string +): Promise => { + const { credentials } = appConnection; + const { apiToken } = credentials; + + const rawServers = await fetchAllPages( + apiToken, + `${IntegrationUrls.LARAVELFORGE_API_URL}/api/orgs/${organizationSlug}/servers` + ); + + return rawServers.map((server: TRawLaravelForgeServer) => ({ + id: server.id, + name: server.attributes.name + })); +}; + +export const listLaravelForgeSites = async ( + appConnection: TLaravelForgeConnection, + organizationSlug: string, + serverId: string +): Promise => { + const { credentials } = appConnection; + const { apiToken } = credentials; + + const rawSites = await fetchAllPages( + apiToken, + `${IntegrationUrls.LARAVELFORGE_API_URL}/api/orgs/${organizationSlug}/servers/${serverId}/sites` + ); + + return rawSites.map((site: TRawLaravelForgeSite) => ({ + id: site.id, + name: site.attributes.name + })); +}; diff --git a/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-schemas.ts b/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-schemas.ts new file mode 100644 index 000000000..1647b38a1 --- /dev/null +++ b/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-schemas.ts @@ -0,0 +1,58 @@ +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 { LaravelForgeConnectionMethod } from "./laravel-forge-connection-enums"; + +export const LaravelForgeConnectionApiTokenCredentialsSchema = z.object({ + apiToken: z.string().trim().min(1, "API token required").describe(AppConnections.CREDENTIALS.LARAVEL_FORGE.apiToken) +}); + +const BaseLaravelForgeConnectionSchema = BaseAppConnectionSchema.extend({ app: z.literal(AppConnection.LaravelForge) }); + +export const LaravelForgeConnectionSchema = BaseLaravelForgeConnectionSchema.extend({ + method: z.literal(LaravelForgeConnectionMethod.ApiToken), + credentials: LaravelForgeConnectionApiTokenCredentialsSchema +}); + +export const SanitizedLaravelForgeConnectionSchema = z.discriminatedUnion("method", [ + BaseLaravelForgeConnectionSchema.extend({ + method: z.literal(LaravelForgeConnectionMethod.ApiToken), + credentials: LaravelForgeConnectionApiTokenCredentialsSchema.pick({}) + }) +]); + +export const ValidateLaravelForgeConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z + .literal(LaravelForgeConnectionMethod.ApiToken) + .describe(AppConnections.CREATE(AppConnection.LaravelForge).method), + credentials: LaravelForgeConnectionApiTokenCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.LaravelForge).credentials + ) + }) +]); + +export const CreateLaravelForgeConnectionSchema = ValidateLaravelForgeConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.LaravelForge) +); + +export const UpdateLaravelForgeConnectionSchema = z + .object({ + credentials: LaravelForgeConnectionApiTokenCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.LaravelForge).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.LaravelForge)); + +export const LaravelForgeConnectionListItemSchema = z.object({ + name: z.literal("Laravel Forge"), + app: z.literal(AppConnection.LaravelForge), + methods: z.nativeEnum(LaravelForgeConnectionMethod).array() +}); diff --git a/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-service.ts b/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-service.ts new file mode 100644 index 000000000..fc3c2bf80 --- /dev/null +++ b/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-service.ts @@ -0,0 +1,74 @@ +import { logger } from "@app/lib/logger"; +import { OrgServiceActor } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + listLaravelForgeOrganizations, + listLaravelForgeServers, + listLaravelForgeSites +} from "./laravel-forge-connection-fns"; +import { + TLaravelForgeConnection, + TLaravelForgeOrganization, + TLaravelForgeServer, + TLaravelForgeSite +} from "./laravel-forge-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +export const laravelForgeConnectionService = (getAppConnection: TGetAppConnectionFunc) => { + const listOrganizations = async ( + connectionId: string, + actor: OrgServiceActor + ): Promise => { + const appConnection = await getAppConnection(AppConnection.LaravelForge, connectionId, actor); + try { + const organizations = await listLaravelForgeOrganizations(appConnection); + return organizations; + } catch (error) { + logger.error(error, "Failed to list organizations for Laravel Forge connection"); + return []; + } + }; + + const listServers = async ( + connectionId: string, + actor: OrgServiceActor, + organizationSlug: string + ): Promise => { + const appConnection = await getAppConnection(AppConnection.LaravelForge, connectionId, actor); + try { + const servers = await listLaravelForgeServers(appConnection, organizationSlug); + return servers; + } catch (error) { + logger.error(error, "Failed to list servers for Laravel Forge connection"); + return []; + } + }; + + const listSites = async ( + connectionId: string, + actor: OrgServiceActor, + organizationSlug: string, + serverId: string + ): Promise => { + const appConnection = await getAppConnection(AppConnection.LaravelForge, connectionId, actor); + try { + const sites = await listLaravelForgeSites(appConnection, organizationSlug, serverId); + return sites; + } catch (error) { + logger.error(error, "Failed to list sites for Laravel Forge connection"); + return []; + } + }; + + return { + listOrganizations, + listServers, + listSites + }; +}; diff --git a/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-types.ts b/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-types.ts new file mode 100644 index 000000000..ad134ef3d --- /dev/null +++ b/backend/src/services/app-connection/laravel-forge/laravel-forge-connection-types.ts @@ -0,0 +1,63 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateLaravelForgeConnectionSchema, + LaravelForgeConnectionSchema, + ValidateLaravelForgeConnectionCredentialsSchema +} from "./laravel-forge-connection-schemas"; + +export type TLaravelForgeConnection = z.infer; + +export type TLaravelForgeConnectionInput = z.infer & { + app: AppConnection.LaravelForge; +}; + +export type TValidateLaravelForgeConnectionCredentialsSchema = typeof ValidateLaravelForgeConnectionCredentialsSchema; + +export type TLaravelForgeConnectionConfig = DiscriminativePick< + TLaravelForgeConnectionInput, + "method" | "app" | "credentials" +> & { + orgSlug: string; +}; + +export type TLaravelForgeOrganization = { + id: string; + name: string; + slug: string; +}; + +export type TLaravelForgeServer = { + id: string; + name: string; +}; + +export type TLaravelForgeSite = { + id: string; + name: string; +}; + +export type TRawLaravelForgeOrganization = { + id: string; + attributes: { + name: string; + slug: string; + }; +}; + +export type TRawLaravelForgeServer = { + id: string; + attributes: { + name: string; + }; +}; + +export type TRawLaravelForgeSite = { + id: string; + attributes: { + name: string; + }; +}; diff --git a/backend/src/services/certificate-authority/certificate-authority-fns.test.ts b/backend/src/services/certificate-authority/certificate-authority-fns.test.ts new file mode 100644 index 000000000..870055711 --- /dev/null +++ b/backend/src/services/certificate-authority/certificate-authority-fns.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from "vitest"; + +import { CertKeyAlgorithm } from "@app/services/certificate/certificate-types"; + +import { signatureAlgorithmToAlgCfg } from "./certificate-authority-fns"; + +describe("signatureAlgorithmToAlgCfg", () => { + describe("RSA algorithms", () => { + it("should handle RSA-SHA256 correctly", () => { + const result = signatureAlgorithmToAlgCfg("RSA-SHA256", CertKeyAlgorithm.RSA_2048); + + expect(result).toEqual({ + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256", + publicExponent: new Uint8Array([1, 0, 1]), + modulusLength: 2048 + }); + }); + + it("should handle RSA-SHA384 correctly", () => { + const result = signatureAlgorithmToAlgCfg("RSA-SHA384", CertKeyAlgorithm.RSA_4096); + + expect(result).toEqual({ + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-384", + publicExponent: new Uint8Array([1, 0, 1]), + modulusLength: 4096 + }); + }); + + it("should handle RSA-SHA256 with RSA_3072 correctly", () => { + const result = signatureAlgorithmToAlgCfg("RSA-SHA256", CertKeyAlgorithm.RSA_3072); + + expect(result).toEqual({ + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256", + publicExponent: new Uint8Array([1, 0, 1]), + modulusLength: 3072 + }); + }); + + it("should handle RSA-SHA512 correctly", () => { + const result = signatureAlgorithmToAlgCfg("RSA-SHA512", CertKeyAlgorithm.RSA_2048); + + expect(result).toEqual({ + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-512", + publicExponent: new Uint8Array([1, 0, 1]), + modulusLength: 2048 + }); + }); + }); + + describe("ECDSA algorithms", () => { + it("should handle ECDSA-SHA256 with P-256 curve", () => { + const result = signatureAlgorithmToAlgCfg("ECDSA-SHA256", CertKeyAlgorithm.ECDSA_P256); + + expect(result).toEqual({ + name: "ECDSA", + namedCurve: "P-256", + hash: "SHA-256" + }); + }); + + it("should handle ECDSA-SHA384 with P-384 curve", () => { + const result = signatureAlgorithmToAlgCfg("ECDSA-SHA384", CertKeyAlgorithm.ECDSA_P384); + + expect(result).toEqual({ + name: "ECDSA", + namedCurve: "P-384", + hash: "SHA-384" + }); + }); + + it("should handle ECDSA-SHA256 with EC_prime256v1 string format", () => { + const result = signatureAlgorithmToAlgCfg("ECDSA-SHA256", "EC_prime256v1"); + + expect(result).toEqual({ + name: "ECDSA", + namedCurve: "P-256", + hash: "SHA-256" + }); + }); + + it("should handle ECDSA-SHA384 with EC_secp384r1 string format", () => { + const result = signatureAlgorithmToAlgCfg("ECDSA-SHA384", "EC_secp384r1"); + + expect(result).toEqual({ + name: "ECDSA", + namedCurve: "P-384", + hash: "SHA-384" + }); + }); + }); + + describe("hash format normalization", () => { + it("should normalize SHA256 to SHA-256", () => { + const result = signatureAlgorithmToAlgCfg("RSA-SHA256", CertKeyAlgorithm.RSA_2048); + expect(result.hash).toBe("SHA-256"); + }); + + it("should normalize SHA384 to SHA-384", () => { + const result = signatureAlgorithmToAlgCfg("ECDSA-SHA384", CertKeyAlgorithm.ECDSA_P384); + expect(result.hash).toBe("SHA-384"); + }); + + it("should normalize SHA512 to SHA-512", () => { + const result = signatureAlgorithmToAlgCfg("RSA-SHA512", CertKeyAlgorithm.RSA_4096); + expect(result.hash).toBe("SHA-512"); + }); + + it("should handle SHA1 format", () => { + const result = signatureAlgorithmToAlgCfg("RSA-SHA1", CertKeyAlgorithm.RSA_2048); + expect(result.hash).toBe("SHA-1"); + }); + + it("should handle SHA224 format", () => { + const result = signatureAlgorithmToAlgCfg("ECDSA-SHA224", CertKeyAlgorithm.ECDSA_P256); + expect(result.hash).toBe("SHA-224"); + }); + + it("should handle case insensitive hash normalization", () => { + const result = signatureAlgorithmToAlgCfg("RSA-sha256", CertKeyAlgorithm.RSA_2048); + expect(result.hash).toBe("SHA-256"); + }); + + it("should handle already normalized hash formats", () => { + const result = signatureAlgorithmToAlgCfg("ECDSA-SHA256", CertKeyAlgorithm.ECDSA_P256); + expect(result.hash).toBe("SHA-256"); + }); + + it("should handle SHA-3 family hashes", () => { + const result = signatureAlgorithmToAlgCfg("RSA-SHA3256", CertKeyAlgorithm.RSA_2048); + expect(result.hash).toBe("SHA3-256"); + }); + }); + + describe("dynamic key algorithm support", () => { + it("should support future RSA key sizes", () => { + const result = signatureAlgorithmToAlgCfg("RSA-SHA256", "RSA_8192"); + + expect(result.name).toBe("RSASSA-PKCS1-v1_5"); + expect(result.hash).toBe("SHA-256"); + }); + + it("should support future EC curves", () => { + const result = signatureAlgorithmToAlgCfg("ECDSA-SHA256", "EC_secp521r1"); + + expect(result.name).toBe("ECDSA"); + expect(result.namedCurve).toBe("P-521"); + expect(result.hash).toBe("SHA-256"); + }); + + it("should support EC_P384 string format", () => { + const result = signatureAlgorithmToAlgCfg("ECDSA-SHA384", "EC_P384"); + + expect(result).toEqual({ + name: "ECDSA", + namedCurve: "P-384", + hash: "SHA-384" + }); + }); + }); +}); diff --git a/backend/src/services/certificate-authority/certificate-authority-fns.ts b/backend/src/services/certificate-authority/certificate-authority-fns.ts index 9991e462e..aff81dcc5 100644 --- a/backend/src/services/certificate-authority/certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/certificate-authority-fns.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-nested-ternary */ import * as x509 from "@peculiar/x509"; import { crypto } from "@app/lib/crypto/cryptography"; @@ -68,6 +69,13 @@ export const parseDistinguishedName = (dn: string): TDNParts => { export const keyAlgorithmToAlgCfg = (keyAlgorithm: CertKeyAlgorithm) => { switch (keyAlgorithm) { + case CertKeyAlgorithm.RSA_3072: + return { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256", + publicExponent: new Uint8Array([1, 0, 1]), + modulusLength: 3072 + }; case CertKeyAlgorithm.RSA_4096: return { name: "RSASSA-PKCS1-v1_5", @@ -99,6 +107,73 @@ export const keyAlgorithmToAlgCfg = (keyAlgorithm: CertKeyAlgorithm) => { } }; +export const signatureAlgorithmToAlgCfg = (signatureAlgorithm: string, keyAlgorithm: CertKeyAlgorithm | string) => { + // Parse signature algorithm like "RSA-SHA256", "ECDSA-SHA256" etc. + if (!signatureAlgorithm || typeof signatureAlgorithm !== "string" || !signatureAlgorithm.includes("-")) { + throw new Error(`Invalid signature algorithm format: ${signatureAlgorithm}`); + } + + const [keyType, hashType] = signatureAlgorithm.split("-"); + + if (!keyType || !hashType) { + throw new Error(`Malformed signature algorithm: ${signatureAlgorithm}`); + } + + const normalizeHashType = (hash: string) => { + const upperHash = hash.toUpperCase(); + + if (upperHash === "SHA1" || upperHash === "SHA-1") return "SHA-1"; + + if (upperHash === "SHA224" || upperHash === "SHA-224") return "SHA-224"; + if (upperHash === "SHA256" || upperHash === "SHA-256") return "SHA-256"; + if (upperHash === "SHA384" || upperHash === "SHA-384") return "SHA-384"; + if (upperHash === "SHA512" || upperHash === "SHA-512") return "SHA-512"; + + if (upperHash === "SHA3224" || upperHash === "SHA3-224") return "SHA3-224"; + if (upperHash === "SHA3256" || upperHash === "SHA3-256") return "SHA3-256"; + if (upperHash === "SHA3384" || upperHash === "SHA3-384") return "SHA3-384"; + if (upperHash === "SHA3512" || upperHash === "SHA3-512") return "SHA3-512"; + + throw new Error(`Unsupported hash algorithm: ${hash}`); + }; + + const normalizedHash = hashType ? normalizeHashType(hashType) : undefined; + + switch (keyType) { + case "RSA": + return { + name: "RSASSA-PKCS1-v1_5", + hash: normalizedHash || "SHA-256", + publicExponent: new Uint8Array([1, 0, 1]), + modulusLength: + keyAlgorithm === CertKeyAlgorithm.RSA_4096 ? 4096 : keyAlgorithm === CertKeyAlgorithm.RSA_3072 ? 3072 : 2048 + }; + case "ECDSA": + // eslint-disable-next-line no-case-declarations + const is384Curve = + keyAlgorithm === CertKeyAlgorithm.ECDSA_P384 || keyAlgorithm === "EC_secp384r1" || keyAlgorithm === "EC_P384"; + // eslint-disable-next-line no-case-declarations + const is521Curve = keyAlgorithm === "EC_secp521r1" || keyAlgorithm === "EC_P521"; + // eslint-disable-next-line no-case-declarations + let namedCurve: string; + if (is521Curve) { + namedCurve = "P-521"; + } else if (is384Curve) { + namedCurve = "P-384"; + } else { + namedCurve = "P-256"; + } + return { + name: "ECDSA", + namedCurve, + hash: normalizedHash || (namedCurve === "P-384" ? "SHA-384" : "SHA-256") + }; + default: + // Fallback to key algorithm default + return keyAlgorithmToAlgCfg(keyAlgorithm as CertKeyAlgorithm); + } +}; + /** * Return the public and private key of CA with id [caId] * Note: credentials are returned as crypto.webcrypto.CryptoKey @@ -111,7 +186,8 @@ export const getCaCredentials = async ({ certificateAuthorityDAL, certificateAuthoritySecretDAL, projectDAL, - kmsService + kmsService, + signatureAlgorithm }: TGetCaCredentialsDTO) => { const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId); if (!ca?.internalCa?.id) throw new NotFoundError({ message: `Internal CA with ID '${caId}' not found` }); @@ -132,7 +208,7 @@ export const getCaCredentials = async ({ cipherTextBlob: caSecret.encryptedPrivateKey }); - const alg = keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm); + const alg = signatureAlgorithm || keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm); const skObj = crypto.nativeCrypto.createPrivateKey({ key: decryptedPrivateKey, format: "der", type: "pkcs8" }); const caPrivateKey = await crypto.nativeCrypto.subtle.importKey( "pkcs8", diff --git a/backend/src/services/certificate-authority/certificate-authority-schemas.ts b/backend/src/services/certificate-authority/certificate-authority-schemas.ts index 61d620156..5ecc50a4b 100644 --- a/backend/src/services/certificate-authority/certificate-authority-schemas.ts +++ b/backend/src/services/certificate-authority/certificate-authority-schemas.ts @@ -18,7 +18,7 @@ export const BaseCertificateAuthoritySchema = CertificateAuthoritiesSchema.pick( export const GenericCreateCertificateAuthorityFieldsSchema = (type: CaType) => z.object({ name: slugSchema({ field: "name" }).describe(CertificateAuthorities.CREATE(type).name), - projectId: z.string().trim().min(1, "Project ID required").describe(CertificateAuthorities.CREATE(type).projectId), + projectId: z.string().uuid("Project ID must be valid").describe(CertificateAuthorities.CREATE(type).projectId), enableDirectIssuance: z.boolean().describe(CertificateAuthorities.CREATE(type).enableDirectIssuance), status: z.nativeEnum(CaStatus).describe(CertificateAuthorities.CREATE(type).status) }); @@ -26,7 +26,7 @@ export const GenericCreateCertificateAuthorityFieldsSchema = (type: CaType) => export const GenericUpdateCertificateAuthorityFieldsSchema = (type: CaType) => z.object({ name: slugSchema({ field: "name" }).optional().describe(CertificateAuthorities.UPDATE(type).name), - projectId: z.string().trim().min(1, "Project ID required").describe(CertificateAuthorities.UPDATE(type).projectId), + projectId: z.string().uuid("Project ID must be valid").describe(CertificateAuthorities.UPDATE(type).projectId), enableDirectIssuance: z.boolean().optional().describe(CertificateAuthorities.UPDATE(type).enableDirectIssuance), status: z.nativeEnum(CaStatus).optional().describe(CertificateAuthorities.UPDATE(type).status) }); diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts index ab89ea996..5b9cd78ee 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-service.ts @@ -32,6 +32,8 @@ import { CertExtendedKeyUsageOIDToName, CertKeyAlgorithm, CertKeyUsage, + CertSignatureAlgorithm, + CertSignatureType, CertStatus, TAltNameMapping } from "../../certificate/certificate-types"; @@ -48,7 +50,8 @@ import { getCaCertChains, getCaCredentials, keyAlgorithmToAlgCfg, - parseDistinguishedName + parseDistinguishedName, + signatureAlgorithmToAlgCfg } from "../certificate-authority-fns"; import { TCertificateAuthorityQueueFactory } from "../certificate-authority-queue"; import { TCertificateAuthoritySecretDALFactory } from "../certificate-authority-secret-dal"; @@ -1174,7 +1177,10 @@ export const internalCertificateAuthorityServiceFactory = ({ actor, actorOrgId, keyUsages, - extendedKeyUsages + extendedKeyUsages, + signatureAlgorithm, + keyAlgorithm, + isFromProfile }: TIssueCertFromCaDTO) => { let ca: TCertificateAuthorityWithAssociatedCa | undefined; let certificateTemplate: TCertificateTemplates | undefined; @@ -1221,7 +1227,7 @@ export const internalCertificateAuthorityServiceFactory = ({ if (ca.status !== CaStatus.ACTIVE) throw new BadRequestError({ message: "CA is not active" }); if (!ca.internalCa.activeCaCertId) throw new BadRequestError({ message: "CA does not have a certificate installed" }); - if (!ca.enableDirectIssuance && !certificateTemplate) { + if (!isFromProfile && !ca.enableDirectIssuance && !certificateTemplate) { throw new BadRequestError({ message: "Certificate template or subscriber is required for issuance" }); } @@ -1277,13 +1283,43 @@ export const internalCertificateAuthorityServiceFactory = ({ throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" }); } - const alg = keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm); - const leafKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]); + const effectiveKeyAlgorithm = + (keyAlgorithm as CertKeyAlgorithm) || (ca.internalCa.keyAlgorithm as CertKeyAlgorithm); + const keyGenAlg = keyAlgorithmToAlgCfg(effectiveKeyAlgorithm); + const leafKeys = await crypto.nativeCrypto.subtle.generateKey(keyGenAlg, true, ["sign", "verify"]); + + if (signatureAlgorithm) { + const caKeyAlgorithm = ca.internalCa.keyAlgorithm; + const requestedKeyType = signatureAlgorithm.split("-")[0]; + + const isRsaCa = caKeyAlgorithm.startsWith(CertKeyAlgorithm.RSA_2048.split("_")[0]); + const isEcdsaCa = caKeyAlgorithm.startsWith(CertKeyAlgorithm.ECDSA_P256.split("_")[0]); + + if ( + (requestedKeyType === CertSignatureAlgorithm.RSA_SHA256.split("-")[0] && !isRsaCa) || + (requestedKeyType === CertSignatureAlgorithm.ECDSA_SHA256.split("-")[0] && !isEcdsaCa) + ) { + // eslint-disable-next-line no-nested-ternary + const supportedType = isRsaCa + ? CertSignatureAlgorithm.RSA_SHA256.split("-")[0] + : isEcdsaCa + ? CertSignatureAlgorithm.ECDSA_SHA256.split("-")[0] + : "unknown"; + throw new BadRequestError({ + message: `Requested signature algorithm ${signatureAlgorithm} is not compatible with CA key algorithm ${caKeyAlgorithm}. CA can only sign with ${supportedType}-based signature algorithms.` + }); + } + } + + // Determine signing algorithm for certificate signing + const signingAlg = signatureAlgorithm + ? signatureAlgorithmToAlgCfg(signatureAlgorithm, ca.internalCa.keyAlgorithm as CertKeyAlgorithm) + : keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm); const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({ name: `CN=${commonName}`, keys: leafKeys, - signingAlgorithm: alg, + signingAlgorithm: keyGenAlg, extensions: [ // eslint-disable-next-line no-bitwise new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment) @@ -1296,7 +1332,8 @@ export const internalCertificateAuthorityServiceFactory = ({ certificateAuthorityDAL, certificateAuthoritySecretDAL, projectDAL, - kmsService + kmsService, + signatureAlgorithm: signingAlg }); const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id }); @@ -1319,7 +1356,7 @@ export const internalCertificateAuthorityServiceFactory = ({ // handle key usages let selectedKeyUsages: CertKeyUsage[] = keyUsages ?? []; if (keyUsages === undefined && !certificateTemplate) { - selectedKeyUsages = [CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT]; + selectedKeyUsages = isFromProfile ? [] : [CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT]; } if (keyUsages === undefined && certificateTemplate) { @@ -1405,7 +1442,7 @@ export const internalCertificateAuthorityServiceFactory = ({ notAfter: notAfterDate, signingKey: caPrivateKey, publicKey: csrObj.publicKey, - signingAlgorithm: alg, + signingAlgorithm: signingAlg, extensions }); @@ -1517,7 +1554,9 @@ export const internalCertificateAuthorityServiceFactory = ({ notBefore, notAfter, keyUsages, - extendedKeyUsages + extendedKeyUsages, + signatureAlgorithm, + keyAlgorithm } = dto; let collectionId = pkiCollectionId; @@ -1563,7 +1602,7 @@ export const internalCertificateAuthorityServiceFactory = ({ if (ca.status !== CaStatus.ACTIVE) throw new BadRequestError({ message: "CA is not active" }); if (!ca.internalCa.activeCaCertId) throw new BadRequestError({ message: "CA does not have a certificate installed" }); - if (!ca.enableDirectIssuance && !certificateTemplate) { + if (!dto.isFromProfile && !ca.enableDirectIssuance && !certificateTemplate) { throw new BadRequestError({ message: "Certificate template or subscriber is required for issuance" }); } @@ -1622,7 +1661,29 @@ export const internalCertificateAuthorityServiceFactory = ({ throw new BadRequestError({ message: "notAfter date is after CA certificate's notAfter date" }); } - const alg = keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm); + if (signatureAlgorithm) { + const caKeyAlgorithm = ca.internalCa.keyAlgorithm; + const requestedKeyType = signatureAlgorithm.split("-")[0]; // Get the first part (RSA, ECDSA) + + const isRsaCa = caKeyAlgorithm.startsWith(CertSignatureType.RSA); + const isEcdsaCa = caKeyAlgorithm.startsWith(CertSignatureType.ECDSA); + + if ( + (requestedKeyType === CertSignatureType.RSA && !isRsaCa) || + (requestedKeyType === CertSignatureType.ECDSA && !isEcdsaCa) + ) { + // eslint-disable-next-line no-nested-ternary + const supportedType = isRsaCa ? CertSignatureType.RSA : isEcdsaCa ? CertSignatureType.ECDSA : "unknown"; + throw new BadRequestError({ + message: `Requested signature algorithm ${signatureAlgorithm} is not compatible with CA key algorithm ${caKeyAlgorithm}. CA can only sign with ${supportedType}-based signature algorithms.` + }); + } + } + + const effectiveKeyAlgorithm = (keyAlgorithm || ca.internalCa.keyAlgorithm) as CertKeyAlgorithm; + const alg = signatureAlgorithm + ? signatureAlgorithmToAlgCfg(signatureAlgorithm, effectiveKeyAlgorithm) + : keyAlgorithmToAlgCfg(ca.internalCa.keyAlgorithm as CertKeyAlgorithm); const csrObj = new x509.Pkcs10CertificateRequest(csr); @@ -1671,7 +1732,7 @@ export const internalCertificateAuthorityServiceFactory = ({ if (csrKeyUsageExtension) { selectedKeyUsages = csrKeyUsages; } else { - selectedKeyUsages = [CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT]; + selectedKeyUsages = dto.isFromProfile ? [] : [CertKeyUsage.DIGITAL_SIGNATURE, CertKeyUsage.KEY_ENCIPHERMENT]; } } diff --git a/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts b/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts index fadd7b88d..22cb86d28 100644 --- a/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts +++ b/backend/src/services/certificate-authority/internal/internal-certificate-authority-types.ts @@ -3,7 +3,12 @@ import { z } from "zod"; import { TCertificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal"; import { TProjectPermission } from "@app/lib/types"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; -import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "@app/services/certificate/certificate-types"; +import { + CertExtendedKeyUsage, + CertKeyAlgorithm, + CertKeyUsage, + CertSignatureAlgorithm +} from "@app/services/certificate/certificate-types"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TProjectDALFactory } from "@app/services/project/project-dal"; @@ -131,6 +136,9 @@ export type TIssueCertFromCaDTO = { notAfter?: string; keyUsages?: CertKeyUsage[]; extendedKeyUsages?: CertExtendedKeyUsage[]; + signatureAlgorithm?: CertSignatureAlgorithm; + keyAlgorithm?: CertKeyAlgorithm; + isFromProfile?: boolean; } & Omit; export type TSignCertFromCaDTO = @@ -148,6 +156,9 @@ export type TSignCertFromCaDTO = notAfter?: string; keyUsages?: CertKeyUsage[]; extendedKeyUsages?: CertExtendedKeyUsage[]; + signatureAlgorithm?: string; + keyAlgorithm?: string; + isFromProfile?: boolean; } | ({ isInternal: false; @@ -163,6 +174,9 @@ export type TSignCertFromCaDTO = notAfter?: string; keyUsages?: CertKeyUsage[]; extendedKeyUsages?: CertExtendedKeyUsage[]; + signatureAlgorithm?: string; + keyAlgorithm?: string; + isFromProfile?: boolean; } & Omit); export type TGetCaCertificateTemplatesDTO = { @@ -184,6 +198,7 @@ export type TGetCaCredentialsDTO = { certificateAuthoritySecretDAL: Pick; projectDAL: Pick; kmsService: Pick; + signatureAlgorithm?: RsaHashedImportParams | EcKeyImportParams; }; export type TGetCaCertChainsDTO = { diff --git a/backend/src/services/certificate-common/certificate-constants.ts b/backend/src/services/certificate-common/certificate-constants.ts new file mode 100644 index 000000000..bbd589110 --- /dev/null +++ b/backend/src/services/certificate-common/certificate-constants.ts @@ -0,0 +1,187 @@ +export enum CertSubjectAlternativeNameType { + DNS_NAME = "dns_name", + IP_ADDRESS = "ip_address", + EMAIL = "email", + URI = "uri" +} + +export enum CertKeyUsageType { + DIGITAL_SIGNATURE = "digital_signature", + KEY_ENCIPHERMENT = "key_encipherment", + NON_REPUDIATION = "non_repudiation", + DATA_ENCIPHERMENT = "data_encipherment", + KEY_AGREEMENT = "key_agreement", + KEY_CERT_SIGN = "key_cert_sign", + CRL_SIGN = "crl_sign", + ENCIPHER_ONLY = "encipher_only", + DECIPHER_ONLY = "decipher_only" +} + +export enum CertExtendedKeyUsageType { + CLIENT_AUTH = "client_auth", + SERVER_AUTH = "server_auth", + CODE_SIGNING = "code_signing", + EMAIL_PROTECTION = "email_protection", + OCSP_SIGNING = "ocsp_signing", + TIME_STAMPING = "time_stamping" +} + +export enum CertIncludeType { + MANDATORY = "mandatory", + OPTIONAL = "optional", + PROHIBIT = "prohibit" +} + +export enum CertAttributeRule { + ALLOW = "allow", + DENY = "deny" +} + +export enum CertSanEffect { + ALLOW = "allow", + DENY = "deny", + REQUIRE = "require" +} + +export enum CertDurationUnit { + DAYS = "days", + MONTHS = "months", + YEARS = "years" +} + +export enum CertSubjectAttributeType { + COMMON_NAME = "common_name", + ORGANIZATION = "organization", + COUNTRY = "country" +} + +export const mapKeyUsageToLegacy = (usage: CertKeyUsageType): string => { + switch (usage) { + case CertKeyUsageType.DIGITAL_SIGNATURE: + return "digitalSignature"; + case CertKeyUsageType.KEY_ENCIPHERMENT: + return "keyEncipherment"; + case CertKeyUsageType.NON_REPUDIATION: + return "nonRepudiation"; + case CertKeyUsageType.DATA_ENCIPHERMENT: + return "dataEncipherment"; + case CertKeyUsageType.KEY_AGREEMENT: + return "keyAgreement"; + case CertKeyUsageType.KEY_CERT_SIGN: + return "keyCertSign"; + case CertKeyUsageType.CRL_SIGN: + return "cRLSign"; + case CertKeyUsageType.ENCIPHER_ONLY: + return "encipherOnly"; + case CertKeyUsageType.DECIPHER_ONLY: + return "decipherOnly"; + default: + return usage; + } +}; + +export const mapLegacyKeyUsageToStandard = (usage: string): CertKeyUsageType => { + switch (usage) { + case "digitalSignature": + case "digital_signature": + return CertKeyUsageType.DIGITAL_SIGNATURE; + case "keyEncipherment": + case "key_encipherment": + return CertKeyUsageType.KEY_ENCIPHERMENT; + case "nonRepudiation": + case "non_repudiation": + return CertKeyUsageType.NON_REPUDIATION; + case "dataEncipherment": + case "data_encipherment": + return CertKeyUsageType.DATA_ENCIPHERMENT; + case "keyAgreement": + case "key_agreement": + return CertKeyUsageType.KEY_AGREEMENT; + case "keyCertSign": + case "key_cert_sign": + return CertKeyUsageType.KEY_CERT_SIGN; + case "cRLSign": + case "crl_sign": + return CertKeyUsageType.CRL_SIGN; + case "encipherOnly": + case "encipher_only": + return CertKeyUsageType.ENCIPHER_ONLY; + case "decipherOnly": + case "decipher_only": + return CertKeyUsageType.DECIPHER_ONLY; + default: + throw new Error(`Unknown key usage: ${usage}`); + } +}; + +export const mapExtendedKeyUsageToLegacy = (usage: CertExtendedKeyUsageType): string => { + switch (usage) { + case CertExtendedKeyUsageType.CLIENT_AUTH: + return "clientAuth"; + case CertExtendedKeyUsageType.SERVER_AUTH: + return "serverAuth"; + case CertExtendedKeyUsageType.CODE_SIGNING: + return "codeSigning"; + case CertExtendedKeyUsageType.EMAIL_PROTECTION: + return "emailProtection"; + case CertExtendedKeyUsageType.OCSP_SIGNING: + return "ocspSigning"; + case CertExtendedKeyUsageType.TIME_STAMPING: + return "timeStamping"; + default: + return usage; + } +}; + +export const mapLegacyExtendedKeyUsageToStandard = (usage: string): CertExtendedKeyUsageType => { + switch (usage) { + case "clientAuth": + case "client_auth": + return CertExtendedKeyUsageType.CLIENT_AUTH; + case "serverAuth": + case "server_auth": + return CertExtendedKeyUsageType.SERVER_AUTH; + case "codeSigning": + case "code_signing": + return CertExtendedKeyUsageType.CODE_SIGNING; + case "emailProtection": + case "email_protection": + return CertExtendedKeyUsageType.EMAIL_PROTECTION; + case "ocspSigning": + case "ocsp_signing": + return CertExtendedKeyUsageType.OCSP_SIGNING; + case "timeStamping": + case "time_stamping": + return CertExtendedKeyUsageType.TIME_STAMPING; + default: + throw new Error(`Unknown extended key usage: ${usage}`); + } +}; + +export enum CertKeyAlgorithm { + RSA_2048 = "RSA_2048", + RSA_3072 = "RSA_3072", + RSA_4096 = "RSA_4096", + ECDSA_P256 = "EC_prime256v1", + ECDSA_P384 = "EC_secp384r1" +} + +export enum CertSignatureAlgorithm { + RSA_SHA256 = "RSA-SHA256", + RSA_SHA384 = "RSA-SHA384", + RSA_SHA512 = "RSA-SHA512", + ECDSA_SHA256 = "ECDSA-SHA256", + ECDSA_SHA384 = "ECDSA-SHA384", + ECDSA_SHA512 = "ECDSA-SHA512" +} + +export const SAN_TYPE_OPTIONS = Object.values(CertSubjectAlternativeNameType); +export const KEY_USAGE_OPTIONS = Object.values(CertKeyUsageType); +export const EXTENDED_KEY_USAGE_OPTIONS = Object.values(CertExtendedKeyUsageType); +export const INCLUDE_TYPE_OPTIONS = Object.values(CertIncludeType); +export const DURATION_UNIT_OPTIONS = Object.values(CertDurationUnit); +export const SUBJECT_ATTRIBUTE_TYPE_OPTIONS = Object.values(CertSubjectAttributeType); +export const ATTRIBUTE_RULE_OPTIONS = Object.values(CertAttributeRule); +export const SAN_EFFECT_OPTIONS = Object.values(CertSanEffect); +export const KEY_ALGORITHM_OPTIONS = Object.values(CertKeyAlgorithm); +export const SIGNATURE_ALGORITHM_OPTIONS = Object.values(CertSignatureAlgorithm); diff --git a/backend/src/services/certificate-common/certificate-utils.ts b/backend/src/services/certificate-common/certificate-utils.ts new file mode 100644 index 000000000..b88f183db --- /dev/null +++ b/backend/src/services/certificate-common/certificate-utils.ts @@ -0,0 +1,198 @@ +import RE2 from "re2"; + +import { CertExtendedKeyUsage, CertKeyUsage } from "../certificate/certificate-types"; +import { + CertExtendedKeyUsageType, + CertKeyUsageType, + mapExtendedKeyUsageToLegacy, + mapKeyUsageToLegacy, + mapLegacyExtendedKeyUsageToStandard, + mapLegacyKeyUsageToStandard +} from "./certificate-constants"; + +interface CertificateRequestInput { + keyUsages?: string[]; + extendedKeyUsages?: string[]; +} + +export const mapEnumsForValidation = (request: T): T => { + const mapKeyUsage = (usage: string): string => { + try { + return mapLegacyKeyUsageToStandard(usage); + } catch { + return usage; + } + }; + + const mapExtendedKeyUsage = (usage: string): string => { + try { + return mapLegacyExtendedKeyUsageToStandard(usage); + } catch { + return usage; + } + }; + + return { + ...request, + keyUsages: request.keyUsages?.map(mapKeyUsage), + extendedKeyUsages: request.extendedKeyUsages?.map(mapExtendedKeyUsage) + } as T; +}; + +export const normalizeDateForApi = (date: Date | string | undefined): string | undefined => { + if (!date) return undefined; + return date instanceof Date ? date.toISOString() : date; +}; + +export const bufferToString = (data: Buffer | string): string => { + return String(data); +}; + +export const buildCertificateSubjectFromTemplate = ( + request: Record, + templateAttributes?: Array<{ + type: string; + allowed?: string[]; + required?: string[]; + denied?: string[]; + }> +): Record => { + const subject: Record = {}; + const attributeMap: Record = { + common_name: "commonName", + organization: "organization", + country: "country" + }; + + if (!templateAttributes || templateAttributes.length === 0) { + return subject; + } + + templateAttributes.forEach((attr) => { + const requestKey = attributeMap[attr.type]; + const value = request[requestKey]; + + if (value && typeof value === "string" && (attr.allowed || attr.required)) { + subject[attr.type] = value; + } + }); + + return subject; +}; + +const isWildcardPattern = (value: string): boolean => { + return value.includes("*"); +}; + +const createWildcardRegex = (pattern: string): RE2 => { + const escapeRegex = new RE2(/[.+?^${}()|[\]\\]/g); + const escaped = pattern.replace(escapeRegex, "\\$&"); + const wildcardRegex = new RE2(/\*/g); + const regexPattern = escaped.replace(wildcardRegex, ".*"); + return new RE2(`^${regexPattern}$`); +}; + +const validateValueAgainstPatterns = (value: string, patterns: string[]): boolean => { + if (!patterns || patterns.length === 0) { + return false; + } + + for (const pattern of patterns) { + if (isWildcardPattern(pattern)) { + try { + const regex = createWildcardRegex(pattern); + if (regex.test(value)) { + return true; + } + } catch { + if (pattern === value) { + return true; + } + } + } else if (pattern === value) { + return true; + } + } + + return false; +}; + +export const buildSubjectAlternativeNamesFromTemplate = ( + request: { subjectAlternativeNames?: Array<{ type: string; value: string }> }, + templateSans?: Array<{ + type: string; + allowed?: string[]; + required?: string[]; + denied?: string[]; + }> +): string => { + if (!request.subjectAlternativeNames || request.subjectAlternativeNames.length === 0) { + return ""; + } + + if (!templateSans || templateSans.length === 0) { + return request.subjectAlternativeNames.map((san) => san.value).join(","); + } + + const allowedSans: string[] = []; + + request.subjectAlternativeNames.forEach((san) => { + const templateSan = templateSans.find((template) => template.type === san.type); + + if (!templateSan) { + allowedSans.push(san.value); + return; + } + + if (templateSan.denied && validateValueAgainstPatterns(san.value, templateSan.denied)) { + throw new Error(`SAN value '${san.value}' is explicitly denied for type '${san.type}'`); + } + + const isRequired = templateSan.required && validateValueAgainstPatterns(san.value, templateSan.required); + const isAllowed = templateSan.allowed && validateValueAgainstPatterns(san.value, templateSan.allowed); + + if (isRequired || isAllowed || (!templateSan.allowed && !templateSan.required)) { + allowedSans.push(san.value); + } else { + throw new Error(`SAN value '${san.value}' is not allowed for type '${san.type}'`); + } + }); + + return allowedSans.join(","); +}; + +export const convertLegacyKeyUsage = (usage: CertKeyUsage): CertKeyUsageType => { + return mapLegacyKeyUsageToStandard(usage); +}; + +export const convertToLegacyKeyUsage = (usage: CertKeyUsageType): CertKeyUsage => { + return mapKeyUsageToLegacy(usage) as CertKeyUsage; +}; + +export const convertLegacyExtendedKeyUsage = (usage: CertExtendedKeyUsage): CertExtendedKeyUsageType => { + return mapLegacyExtendedKeyUsageToStandard(usage); +}; + +export const convertToLegacyExtendedKeyUsage = (usage: CertExtendedKeyUsageType): CertExtendedKeyUsage => { + return mapExtendedKeyUsageToLegacy(usage) as CertExtendedKeyUsage; +}; + +export const convertKeyUsageArrayFromLegacy = (usages?: CertKeyUsage[]): CertKeyUsageType[] | undefined => { + return usages?.map(convertLegacyKeyUsage); +}; + +export const convertKeyUsageArrayToLegacy = (usages?: CertKeyUsageType[]): CertKeyUsage[] | undefined => { + return usages?.map(convertToLegacyKeyUsage); +}; + +export const convertExtendedKeyUsageArrayFromLegacy = ( + usages?: CertExtendedKeyUsage[] +): CertExtendedKeyUsageType[] | undefined => { + return usages?.map(convertLegacyExtendedKeyUsage); +}; + +export const convertExtendedKeyUsageArrayToLegacy = ( + usages?: CertExtendedKeyUsageType[] +): CertExtendedKeyUsage[] | undefined => { + return usages?.map(convertToLegacyExtendedKeyUsage); +}; diff --git a/backend/src/services/certificate-est-v3/certificate-est-v3-service.test.ts b/backend/src/services/certificate-est-v3/certificate-est-v3-service.test.ts new file mode 100644 index 000000000..b6408a986 --- /dev/null +++ b/backend/src/services/certificate-est-v3/certificate-est-v3-service.test.ts @@ -0,0 +1,737 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable no-bitwise */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { BadRequestError, NotFoundError } from "@app/lib/errors"; + +import { EnrollmentType } from "../certificate-profile/certificate-profile-types"; +import { certificateEstV3ServiceFactory, TCertificateEstV3ServiceFactory } from "./certificate-est-v3-service"; + +// Mock the x509 module +vi.mock("@peculiar/x509", () => ({ + Pkcs10CertificateRequest: vi.fn(), + GeneralNames: vi.fn(), + KeyUsagesExtension: vi.fn(), + ExtendedKeyUsageExtension: vi.fn(), + X509Certificate: vi.fn(), + KeyUsageFlags: { + digitalSignature: 1, + nonRepudiation: 2, + keyEncipherment: 4, + dataEncipherment: 8, + keyAgreement: 16, + keyCertSign: 32, + cRLSign: 64, + encipherOnly: 128, + decipherOnly: 256 + } +})); + +// Mock other dependencies +vi.mock("@app/services/certificate-authority/certificate-authority-fns", () => ({ + parseDistinguishedName: vi.fn((subject: string) => { + const parts = subject.split(","); + const result: any = {}; + parts.forEach((part) => { + const [key, value] = part.split("="); + switch (key.trim()) { + case "CN": + result.commonName = value; + break; + case "O": + result.organization = value; + break; + case "OU": + result.ou = value; + break; + case "L": + result.locality = value; + break; + case "ST": + result.province = value; + break; + case "C": + result.country = value; + break; + default: + break; + } + }); + return result; + }) +})); + +vi.mock("@app/services/certificate-authority/certificate-authority-validators", () => ({ + validateAndMapAltNameType: vi.fn((value: string) => { + if (value.includes(".") && !value.match(/^\d+\.\d+\.\d+\.\d+$/)) { + return { type: "dns", value }; + } + if (value.match(/^\d+\.\d+\.\d+\.\d+$/)) { + return { type: "ip", value }; + } + return null; + }) +})); + +vi.mock("@app/services/certificate-common/certificate-constants", () => ({ + mapLegacyKeyUsageToStandard: vi.fn((usage: string) => { + const mapping: Record = { + digitalSignature: "digital_signature", + keyEncipherment: "key_encipherment", + keyCertSign: "key_cert_sign" + }; + return mapping[usage] || usage; + }), + mapLegacyExtendedKeyUsageToStandard: vi.fn((usage: string) => { + const mapping: Record = { + clientAuth: "client_auth", + serverAuth: "server_auth", + codeSigning: "code_signing" + }; + return mapping[usage] || usage; + }), + CertKeyUsageType: { + DIGITAL_SIGNATURE: "digital_signature", + KEY_ENCIPHERMENT: "key_encipherment", + KEY_CERT_SIGN: "key_cert_sign" + }, + CertExtendedKeyUsageType: { + CLIENT_AUTH: "client_auth", + SERVER_AUTH: "server_auth", + CODE_SIGNING: "code_signing" + }, + CertSubjectAlternativeNameType: { + DNS_NAME: "dns_name", + IP_ADDRESS: "ip_address", + RFC822_NAME: "rfc822_name", + UNIFORM_RESOURCE_IDENTIFIER: "uniform_resource_identifier" + } +})); + +vi.mock("@app/services/certificate/certificate-types", () => ({ + mapLegacyAltNameType: vi.fn((type: string) => { + const mapping: Record = { + dns: "dns_name", + ip: "ip_address", + email: "rfc822_name", + url: "uniform_resource_identifier" + }; + return mapping[type] || type; + }), + TAltNameType: { + EMAIL: "email", + DNS: "dns", + IP: "ip", + URL: "url" + }, + CertExtendedKeyUsageOIDToName: { + "1.3.6.1.5.5.7.3.1": "serverAuth", + "1.3.6.1.5.5.7.3.2": "clientAuth", + "1.3.6.1.5.5.7.3.3": "codeSigning" + }, + CertKeyUsage: { + DIGITAL_SIGNATURE: "digitalSignature", + KEY_ENCIPHERMENT: "keyEncipherment", + KEY_CERT_SIGN: "keyCertSign", + NON_REPUDIATION: "nonRepudiation", + DATA_ENCIPHERMENT: "dataEncipherment", + KEY_AGREEMENT: "keyAgreement", + CRL_SIGN: "cRLSign", + ENCIPHER_ONLY: "encipherOnly", + DECIPHER_ONLY: "decipherOnly" + }, + CertExtendedKeyUsage: { + CLIENT_AUTH: "clientAuth", + SERVER_AUTH: "serverAuth", + CODE_SIGNING: "codeSigning" + } +})); + +vi.mock("@app/services/certificate-common/certificate-utils", () => ({ + mapEnumsForValidation: vi.fn((req: any) => req) +})); + +vi.mock("../../ee/services/certificate-est/certificate-est-fns", () => ({ + convertRawCertsToPkcs7: vi.fn(() => "mocked-pkcs7-response") +})); + +describe("CertificateEstV3Service Security Fix", () => { + let service: TCertificateEstV3ServiceFactory; + + const mockInternalCertificateAuthorityService = { + signCertFromCa: vi.fn() + }; + + const mockCertificateTemplateV2Service = { + validateCertificateRequest: vi.fn() + }; + + const mockCertificateAuthorityDAL = { + findById: vi.fn(), + findByIdWithAssociatedCa: vi.fn() + }; + + const mockCertificateAuthorityCertDAL = { + find: vi.fn(), + findById: vi.fn() + }; + + const mockProjectDAL = { + findOne: vi.fn(), + updateById: vi.fn(), + transaction: vi.fn() + }; + + const mockKmsService = { + decryptWithKmsKey: vi.fn(), + generateKmsKey: vi.fn() + }; + + const mockLicenseService = { + getPlan: vi.fn() + }; + + const mockCertificateProfileDAL = { + findByIdWithConfigs: vi.fn() + }; + + const mockEstEnrollmentConfigDAL = { + findById: vi.fn() + }; + + const mockProfile = { + id: "profile-123", + projectId: "project-123", + caId: "ca-123", + certificateTemplateId: "template-v2-123", + enrollmentType: EnrollmentType.EST, + estConfigId: "est-config-123" + }; + + const mockEstConfig = { + id: "est-config-123", + disableBootstrapCaValidation: true + }; + + const mockProject = { + id: "project-123", + orgId: "org-123" + }; + + const mockPlan = { + pkiEst: true + }; + + beforeEach(async () => { + const { Pkcs10CertificateRequest, GeneralNames } = await import("@peculiar/x509"); + + service = certificateEstV3ServiceFactory({ + internalCertificateAuthorityService: mockInternalCertificateAuthorityService, + certificateTemplateV2Service: mockCertificateTemplateV2Service, + certificateAuthorityDAL: mockCertificateAuthorityDAL, + certificateAuthorityCertDAL: mockCertificateAuthorityCertDAL, + projectDAL: mockProjectDAL, + kmsService: mockKmsService, + licenseService: mockLicenseService, + certificateProfileDAL: mockCertificateProfileDAL, + estEnrollmentConfigDAL: mockEstEnrollmentConfigDAL + }); + + mockCertificateProfileDAL.findByIdWithConfigs.mockResolvedValue(mockProfile); + mockEstEnrollmentConfigDAL.findById.mockResolvedValue(mockEstConfig); + mockProjectDAL.findOne.mockResolvedValue(mockProject); + mockLicenseService.getPlan.mockResolvedValue(mockPlan); + + // Set up the default CSR parsing behavior + (Pkcs10CertificateRequest as any).mockImplementation((csr: string) => { + const parsed = JSON.parse(csr); + const mockExtensions: any[] = []; + + if (parsed.sans && parsed.sans.length > 0) { + mockExtensions.push({ type: "2.5.29.17", value: "mock-san-value" }); + } + + return { + subject: parsed.subject, + extensions: mockExtensions, + getExtension: vi.fn((oid: string) => { + if (oid === "2.5.29.15" && parsed.keyUsages && parsed.keyUsages.length > 0) { + // Calculate usages as bitwise OR of key usage flags + let usages = 0; + parsed.keyUsages.forEach((usage: string) => { + switch (usage) { + case "digital_signature": + usages |= 1; // KeyUsageFlags.digitalSignature + break; + case "key_encipherment": + usages |= 4; // KeyUsageFlags.keyEncipherment + break; + case "key_cert_sign": + usages |= 32; // KeyUsageFlags.keyCertSign + break; + default: + break; + } + }); + return { usages }; + } + if (oid === "2.5.29.37" && parsed.extendedKeyUsages && parsed.extendedKeyUsages.length > 0) { + const ekuOids = parsed.extendedKeyUsages.map((eku: string) => { + switch (eku) { + case "client_auth": + return "1.3.6.1.5.5.7.3.2"; + case "server_auth": + return "1.3.6.1.5.5.7.3.1"; + case "code_signing": + return "1.3.6.1.5.5.7.3.3"; + default: + return "1.3.6.1.5.5.7.3.1"; + } + }); + return { usages: ekuOids }; + } + return undefined; + }) + }; + }); + + (GeneralNames as any).mockImplementation(() => ({ + items: [ + { type: "dns", value: "test.example.com" }, + { type: "ip", value: "192.168.1.1" } + ] + })); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + const createMockCSR = ( + options: { + subject?: string; + keyUsages?: string[]; + extendedKeyUsages?: string[]; + sans?: Array<{ type: string; value: string }>; + } = {} + ) => { + const { + subject = "CN=test.example.com,O=Test Org,C=US", + keyUsages = [], + extendedKeyUsages = [], + sans = [] + } = options; + + return JSON.stringify({ + subject, + keyUsages, + extendedKeyUsages, + sans + }); + }; + + describe("CSR Extraction and Template Validation", () => { + it("should extract subject attributes from CSR", async () => { + const csr = createMockCSR({ + subject: "CN=test.example.com,O=Test Organization,OU=IT Department,L=San Francisco,ST=California,C=US" + }); + + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + + mockInternalCertificateAuthorityService.signCertFromCa.mockResolvedValue({ + certificate: { rawData: new ArrayBuffer(0) } + }); + + await service.simpleEnrollByProfile({ + csr, + profileId: "profile-123", + sslClientCert: "" + }); + + expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith( + "template-v2-123", + expect.objectContaining({ + commonName: "test.example.com", + organization: "Test Organization", + organizationUnit: "IT Department", + locality: "San Francisco", + state: "California", + country: "US" + }) + ); + }); + + it("should extract key usages from CSR", async () => { + const csr = createMockCSR({ + keyUsages: ["digital_signature", "key_encipherment"] + }); + + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + + mockInternalCertificateAuthorityService.signCertFromCa.mockResolvedValue({ + certificate: { rawData: new ArrayBuffer(0) } + }); + + await service.simpleEnrollByProfile({ + csr, + profileId: "profile-123", + sslClientCert: "" + }); + + expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith( + "template-v2-123", + expect.objectContaining({ + keyUsages: expect.arrayContaining(["digital_signature", "key_encipherment"]) + }) + ); + }); + + it("should extract extended key usages from CSR", async () => { + const csr = createMockCSR({ + extendedKeyUsages: ["client_auth", "server_auth"] + }); + + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + + mockInternalCertificateAuthorityService.signCertFromCa.mockResolvedValue({ + certificate: { rawData: new ArrayBuffer(0) } + }); + + await service.simpleEnrollByProfile({ + csr, + profileId: "profile-123", + sslClientCert: "" + }); + + expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith( + "template-v2-123", + expect.objectContaining({ + extendedKeyUsages: expect.arrayContaining(["client_auth", "server_auth"]) + }) + ); + }); + + it("should extract Subject Alternative Names from CSR", async () => { + const { GeneralNames } = await import("@peculiar/x509"); + + const csr = createMockCSR({ + sans: [ + { type: "dns", value: "test.example.com" }, + { type: "ip", value: "192.168.1.1" } + ] + }); + + (GeneralNames as any).mockImplementation(() => ({ + items: [ + { type: "dns", value: "test.example.com" }, + { type: "ip", value: "192.168.1.1" } + ] + })); + + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + + mockInternalCertificateAuthorityService.signCertFromCa.mockResolvedValue({ + certificate: { rawData: new ArrayBuffer(0) } + }); + + await service.simpleEnrollByProfile({ + csr, + profileId: "profile-123", + sslClientCert: "" + }); + + expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith( + "template-v2-123", + expect.objectContaining({ + subjectAlternativeNames: expect.arrayContaining([ + expect.objectContaining({ + type: "dns_name", + value: "test.example.com" + }), + expect.objectContaining({ + type: "ip_address", + value: "192.168.1.1" + }) + ]) + }) + ); + }); + }); + + describe("Template Validation Enforcement", () => { + const basicCSR = createMockCSR(); + + it("should enforce template validation and reject invalid requests", async () => { + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: false, + errors: ["Common name 'test.example.com' is not allowed", "Key usage 'digital_signature' is denied"], + warnings: [] + }); + + await expect( + service.simpleEnrollByProfile({ + csr: basicCSR, + profileId: "profile-123", + sslClientCert: "" + }) + ).rejects.toThrow(BadRequestError); + + expect(mockInternalCertificateAuthorityService.signCertFromCa).not.toHaveBeenCalled(); + }); + + it("should allow valid requests that pass template validation", async () => { + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + + mockInternalCertificateAuthorityService.signCertFromCa.mockResolvedValue({ + certificate: { rawData: new ArrayBuffer(0) } + }); + + await service.simpleEnrollByProfile({ + csr: basicCSR, + profileId: "profile-123", + sslClientCert: "" + }); + + expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith( + "template-v2-123", + expect.any(Object) + ); + expect(mockInternalCertificateAuthorityService.signCertFromCa).toHaveBeenCalledWith({ + isInternal: true, + caId: "ca-123", + csr: basicCSR, + isFromProfile: true + }); + }); + + it("should validate template for both simpleEnrollByProfile and simpleReenrollByProfile", async () => { + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: false, + errors: ["SAN value 'evil.com' is denied"], + warnings: [] + }); + + await expect( + service.simpleEnrollByProfile({ + csr: basicCSR, + profileId: "profile-123", + sslClientCert: "" + }) + ).rejects.toThrow(BadRequestError); + + expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalled(); + expect(mockInternalCertificateAuthorityService.signCertFromCa).not.toHaveBeenCalled(); + }); + }); + + describe("Policy Bypass Prevention", () => { + const maliciousCSR = createMockCSR({ + subject: "CN=evil.com,O=Evil Corp,C=XX", + keyUsages: ["key_cert_sign"], + sans: [ + { type: "dns", value: "*.example.com" }, + { type: "ip", value: "127.0.0.1" } + ] + }); + + it("should block attempts to bypass subject attribute policies", async () => { + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: false, + errors: ["Organization 'Evil Corp' is denied", "Country 'XX' is not allowed"], + warnings: [] + }); + + await expect( + service.simpleEnrollByProfile({ + csr: maliciousCSR, + profileId: "profile-123", + sslClientCert: "" + }) + ).rejects.toThrow(BadRequestError); + + expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith( + "template-v2-123", + expect.objectContaining({ + commonName: "evil.com", + organization: "Evil Corp", + country: "XX" + }) + ); + }); + + it("should block attempts to bypass key usage policies", async () => { + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: false, + errors: ["Key usage 'key_cert_sign' is denied - certificate authority privileges not allowed"], + warnings: [] + }); + + await expect( + service.simpleEnrollByProfile({ + csr: maliciousCSR, + profileId: "profile-123", + sslClientCert: "" + }) + ).rejects.toThrow(BadRequestError); + + expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith( + "template-v2-123", + expect.objectContaining({ + keyUsages: expect.arrayContaining(["key_cert_sign"]) + }) + ); + }); + + it("should block attempts to bypass SAN policies", async () => { + const { GeneralNames } = await import("@peculiar/x509"); + + (GeneralNames as any).mockImplementation(() => ({ + items: [ + { type: "dns", value: "*.example.com" }, + { type: "ip", value: "127.0.0.1" } + ] + })); + + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: false, + errors: ["SAN value '*.example.com' matches denied wildcard pattern", "SAN value '127.0.0.1' is denied"], + warnings: [] + }); + + await expect( + service.simpleEnrollByProfile({ + csr: maliciousCSR, + profileId: "profile-123", + sslClientCert: "" + }) + ).rejects.toThrow(BadRequestError); + }); + }); + + describe("Error Handling", () => { + const basicCSR = createMockCSR(); + + it("should handle profile not found", async () => { + mockCertificateProfileDAL.findByIdWithConfigs.mockResolvedValue(null); + + await expect( + service.simpleEnrollByProfile({ + csr: basicCSR, + profileId: "nonexistent", + sslClientCert: "" + }) + ).rejects.toThrow(NotFoundError); + }); + + it("should handle non-EST enrollment type", async () => { + mockCertificateProfileDAL.findByIdWithConfigs.mockResolvedValue({ + ...mockProfile, + enrollmentType: EnrollmentType.API + }); + + await expect( + service.simpleEnrollByProfile({ + csr: basicCSR, + profileId: "profile-123", + sslClientCert: "" + }) + ).rejects.toThrow(BadRequestError); + }); + + it("should handle template validation service errors", async () => { + mockCertificateTemplateV2Service.validateCertificateRequest.mockRejectedValue( + new Error("Template validation service unavailable") + ); + + await expect( + service.simpleEnrollByProfile({ + csr: basicCSR, + profileId: "profile-123", + sslClientCert: "" + }) + ).rejects.toThrow("Template validation service unavailable"); + }); + }); + + describe("Integration with existing flow", () => { + const basicCSR = createMockCSR(); + + beforeEach(() => { + mockCertificateTemplateV2Service.validateCertificateRequest.mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + }); + + it("should call internal CA service with correct parameters after validation", async () => { + mockInternalCertificateAuthorityService.signCertFromCa.mockResolvedValue({ + certificate: { rawData: new ArrayBuffer(0) } + }); + + await service.simpleEnrollByProfile({ + csr: basicCSR, + profileId: "profile-123", + sslClientCert: "" + }); + + expect(mockInternalCertificateAuthorityService.signCertFromCa).toHaveBeenCalledWith({ + isInternal: true, + caId: "ca-123", + isFromProfile: true, + csr: basicCSR + }); + }); + + it("should use profile's CA ID instead of template ID to avoid v1/v2 mismatch", async () => { + mockInternalCertificateAuthorityService.signCertFromCa.mockResolvedValue({ + certificate: { rawData: new ArrayBuffer(0) } + }); + + await service.simpleEnrollByProfile({ + csr: basicCSR, + profileId: "profile-123", + sslClientCert: "" + }); + + // Verify it uses caId from profile, not certificateTemplateId + expect(mockInternalCertificateAuthorityService.signCertFromCa).toHaveBeenCalledWith( + expect.objectContaining({ + caId: "ca-123" + }) + ); + + // Verify it does NOT pass certificateTemplateId to avoid v1/v2 confusion + expect(mockInternalCertificateAuthorityService.signCertFromCa).toHaveBeenCalledWith( + expect.not.objectContaining({ + certificateTemplateId: expect.anything() + }) + ); + }); + }); +}); diff --git a/backend/src/services/certificate-est-v3/certificate-est-v3-service.ts b/backend/src/services/certificate-est-v3/certificate-est-v3-service.ts new file mode 100644 index 000000000..f6dbfba52 --- /dev/null +++ b/backend/src/services/certificate-est-v3/certificate-est-v3-service.ts @@ -0,0 +1,408 @@ +import * as x509 from "@peculiar/x509"; + +import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate"; +import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; +import { isCertChainValid } from "@app/services/certificate/certificate-fns"; +import { + CertExtendedKeyUsageOIDToName, + CertKeyUsage, + mapLegacyAltNameType, + TAltNameMapping, + TAltNameType +} from "@app/services/certificate/certificate-types"; +import { TCertificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal"; +import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; +import { + getCaCertChain, + getCaCertChains, + parseDistinguishedName +} from "@app/services/certificate-authority/certificate-authority-fns"; +import { validateAndMapAltNameType } from "@app/services/certificate-authority/certificate-authority-validators"; +import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; +import { + mapLegacyExtendedKeyUsageToStandard, + mapLegacyKeyUsageToStandard +} from "@app/services/certificate-common/certificate-constants"; +import { mapEnumsForValidation } from "@app/services/certificate-common/certificate-utils"; +import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; +import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; +import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; +import { TCertificateRequest } from "@app/services/certificate-template-v2/certificate-template-v2-types"; +import { TEstEnrollmentConfigDALFactory } from "@app/services/enrollment-config/est-enrollment-config-dal"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; + +import { convertRawCertsToPkcs7 } from "../../ee/services/certificate-est/certificate-est-fns"; +import { TLicenseServiceFactory } from "../../ee/services/license/license-service"; + +type TCertificateEstV3ServiceFactoryDep = { + internalCertificateAuthorityService: Pick; + certificateTemplateV2Service: Pick; + certificateAuthorityDAL: Pick; + certificateAuthorityCertDAL: Pick; + projectDAL: Pick; + kmsService: Pick; + licenseService: Pick; + certificateProfileDAL: Pick; + estEnrollmentConfigDAL: Pick; +}; + +export type TCertificateEstV3ServiceFactory = ReturnType; + +export const certificateEstV3ServiceFactory = ({ + internalCertificateAuthorityService, + certificateTemplateV2Service, + certificateAuthorityCertDAL, + certificateAuthorityDAL, + projectDAL, + kmsService, + licenseService, + certificateProfileDAL, + estEnrollmentConfigDAL +}: TCertificateEstV3ServiceFactoryDep) => { + const extractCertificateRequestFromCSR = (csr: string): TCertificateRequest => { + const csrObj = new x509.Pkcs10CertificateRequest(csr); + const subject = parseDistinguishedName(csrObj.subject); + + const certificateRequest: TCertificateRequest = { + commonName: subject.commonName, + organization: subject.organization, + organizationUnit: subject.ou, + locality: subject.locality, + state: subject.province, + country: subject.country + }; + + const csrKeyUsageExtension = csrObj.getExtension("2.5.29.15") as x509.KeyUsagesExtension; + if (csrKeyUsageExtension) { + const csrKeyUsages = Object.values(CertKeyUsage).filter( + // eslint-disable-next-line no-bitwise + (keyUsage) => (x509.KeyUsageFlags[keyUsage] & csrKeyUsageExtension.usages) !== 0 + ); + certificateRequest.keyUsages = csrKeyUsages.map(mapLegacyKeyUsageToStandard); + } + + const csrExtendedKeyUsageExtension = csrObj.getExtension("2.5.29.37") as x509.ExtendedKeyUsageExtension; + if (csrExtendedKeyUsageExtension) { + const csrExtendedKeyUsages = csrExtendedKeyUsageExtension.usages.map( + (ekuOid) => CertExtendedKeyUsageOIDToName[ekuOid as string] + ); + certificateRequest.extendedKeyUsages = csrExtendedKeyUsages.map(mapLegacyExtendedKeyUsageToStandard); + } + + const sanExtension = csrObj.extensions.find((ext) => ext.type === "2.5.29.17"); + if (sanExtension) { + const sanNames = new x509.GeneralNames(sanExtension.value); + const altNamesArray: TAltNameMapping[] = sanNames.items + .filter( + (value) => + value.type === TAltNameType.EMAIL || + value.type === TAltNameType.DNS || + value.type === TAltNameType.IP || + value.type === TAltNameType.URL + ) + .map((name): TAltNameMapping => { + const altNameType = validateAndMapAltNameType(name.value); + if (!altNameType) { + throw new BadRequestError({ message: `Invalid altName from CSR: ${name.value}` }); + } + return altNameType; + }); + + certificateRequest.subjectAlternativeNames = altNamesArray.map((altName) => ({ + type: mapLegacyAltNameType(altName.type), + value: altName.value + })); + } + + return certificateRequest; + }; + const simpleEnrollByProfile = async ({ + csr, + profileId, + sslClientCert + }: { + csr: string; + profileId: string; + sslClientCert: string; + }) => { + const profile = await certificateProfileDAL.findByIdWithConfigs(profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + if (profile.enrollmentType !== EnrollmentType.EST) { + throw new BadRequestError({ message: "Profile is not configured for EST enrollment" }); + } + + if (!profile.estConfigId) { + throw new BadRequestError({ message: "EST enrollment not configured for this profile" }); + } + + const estConfig = await estEnrollmentConfigDAL.findById(profile.estConfigId); + if (!estConfig) { + throw new NotFoundError({ message: "EST configuration not found" }); + } + + const project = await projectDAL.findOne({ id: profile.projectId }); + if (!project) { + throw new NotFoundError({ message: "Project not found" }); + } + + const plan = await licenseService.getPlan(project.orgId); + if (!plan.pkiEst) { + throw new BadRequestError({ + message: + "Failed to perform EST operation - simpleEnroll due to plan restriction. Upgrade to the Enterprise plan." + }); + } + + if (!estConfig.disableBootstrapCaValidation) { + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: profile.projectId, + projectDAL, + kmsService + }); + + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const decryptedCaChain = estConfig.encryptedCaChain + ? ( + await kmsDecryptor({ + cipherTextBlob: estConfig.encryptedCaChain + }) + ).toString() + : ""; + + const caCerts = extractX509CertFromChain(decryptedCaChain)?.map((cert) => { + return new x509.X509Certificate(cert); + }); + + if (!caCerts) { + throw new BadRequestError({ message: "Failed to parse certificate chain" }); + } + + const leafCertificate = extractX509CertFromChain(decodeURIComponent(sslClientCert))?.[0]; + + if (!leafCertificate) { + throw new UnauthorizedError({ message: "Missing client certificate" }); + } + + const certObj = new x509.X509Certificate(leafCertificate); + if (!(await isCertChainValid([certObj, ...caCerts]))) { + throw new BadRequestError({ message: "Invalid certificate chain" }); + } + } + + const certificateRequest = extractCertificateRequestFromCSR(csr); + const mappedCertificateRequest = mapEnumsForValidation(certificateRequest); + const validationResult = await certificateTemplateV2Service.validateCertificateRequest( + profile.certificateTemplateId, + mappedCertificateRequest + ); + + if (!validationResult.isValid) { + throw new BadRequestError({ + message: `Certificate request validation failed: ${validationResult.errors.join(", ")}` + }); + } + + const { certificate } = await internalCertificateAuthorityService.signCertFromCa({ + isInternal: true, + caId: profile.caId, + csr, + isFromProfile: true + }); + + return convertRawCertsToPkcs7([certificate.rawData]); + }; + + const simpleReenrollByProfile = async ({ + csr, + profileId, + sslClientCert + }: { + csr: string; + profileId: string; + sslClientCert: string; + }) => { + const profile = await certificateProfileDAL.findByIdWithConfigs(profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + if (profile.enrollmentType !== EnrollmentType.EST) { + throw new BadRequestError({ message: "Profile is not configured for EST enrollment" }); + } + + if (!profile.estConfigId) { + throw new BadRequestError({ message: "EST enrollment not configured for this profile" }); + } + + const estConfig = await estEnrollmentConfigDAL.findById(profile.estConfigId); + if (!estConfig) { + throw new NotFoundError({ message: "EST configuration not found" }); + } + + const project = await projectDAL.findOne({ id: profile.projectId }); + if (!project) { + throw new NotFoundError({ message: "Project not found" }); + } + + const plan = await licenseService.getPlan(project.orgId); + if (!plan.pkiEst) { + throw new BadRequestError({ + message: + "Failed to perform EST operation - simpleReenroll due to plan restriction. Upgrade to the Enterprise plan." + }); + } + + const leafCertificate = extractX509CertFromChain(decodeURIComponent(sslClientCert))?.[0]; + + if (!leafCertificate) { + throw new UnauthorizedError({ message: "Missing client certificate" }); + } + + const cert = new x509.X509Certificate(leafCertificate); + const caCertChains = await getCaCertChains({ + caId: profile.caId, + certificateAuthorityCertDAL, + certificateAuthorityDAL, + projectDAL, + kmsService + }); + + const verifiedChains = await Promise.all( + caCertChains.map((chain) => { + const caCert = new x509.X509Certificate(chain.certificate); + const caChain = extractX509CertFromChain(chain.certificateChain)?.map((c) => new x509.X509Certificate(c)) || []; + + return isCertChainValid([cert, caCert, ...caChain]); + }) + ); + + if (!verifiedChains.some(Boolean)) { + throw new BadRequestError({ + message: "Invalid client certificate: unable to build a valid certificate chain" + }); + } + + const csrObj = new x509.Pkcs10CertificateRequest(csr); + if (csrObj.subject !== cert.subject) { + throw new BadRequestError({ + message: "Subject mismatch" + }); + } + + let csrSanSet: Set = new Set(); + const csrSanExtension = csrObj.extensions.find((ext) => ext.type === "2.5.29.17"); + if (csrSanExtension) { + const sanNames = new x509.GeneralNames(csrSanExtension.value); + csrSanSet = new Set([...sanNames.items.map((name) => `${name.type}-${name.value}`)]); + } + + let certSanSet: Set = new Set(); + const certSanExtension = cert.extensions.find((ext) => ext.type === "2.5.29.17"); + if (certSanExtension) { + const sanNames = new x509.GeneralNames(certSanExtension.value); + certSanSet = new Set([...sanNames.items.map((name) => `${name.type}-${name.value}`)]); + } + + if (csrSanSet.size !== certSanSet.size || ![...csrSanSet].every((element) => certSanSet.has(element))) { + throw new BadRequestError({ + message: "Subject alternative names mismatch" + }); + } + + const certificateRequest = extractCertificateRequestFromCSR(csr); + const mappedCertificateRequest = mapEnumsForValidation(certificateRequest); + const validationResult = await certificateTemplateV2Service.validateCertificateRequest( + profile.certificateTemplateId, + mappedCertificateRequest + ); + + if (!validationResult.isValid) { + throw new BadRequestError({ + message: `Certificate request validation failed: ${validationResult.errors.join(", ")}` + }); + } + + const { certificate } = await internalCertificateAuthorityService.signCertFromCa({ + isInternal: true, + caId: profile.caId, + csr, + isFromProfile: true + }); + + return convertRawCertsToPkcs7([certificate.rawData]); + }; + + const getCaCertsByProfile = async ({ profileId }: { profileId: string }) => { + const profile = await certificateProfileDAL.findByIdWithConfigs(profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + if (profile.enrollmentType !== EnrollmentType.EST) { + throw new BadRequestError({ message: "Profile is not configured for EST enrollment" }); + } + + if (!profile.estConfigId) { + throw new BadRequestError({ message: "EST enrollment not configured for this profile" }); + } + + const estConfig = await estEnrollmentConfigDAL.findById(profile.estConfigId); + if (!estConfig) { + throw new NotFoundError({ message: "EST configuration not found" }); + } + + const project = await projectDAL.findOne({ id: profile.projectId }); + if (!project) { + throw new NotFoundError({ message: "Project not found" }); + } + + const plan = await licenseService.getPlan(project.orgId); + if (!plan.pkiEst) { + throw new BadRequestError({ + message: "Failed to perform EST operation - caCerts due to plan restriction. Upgrade to the Enterprise plan." + }); + } + + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId); + if (!ca?.internalCa?.id) { + throw new NotFoundError({ + message: `Internal Certificate Authority with ID '${profile.caId}' not found` + }); + } + + const { caCert, caCertChain } = await getCaCertChain({ + caCertId: ca.internalCa.activeCaCertId as string, + certificateAuthorityDAL, + certificateAuthorityCertDAL, + projectDAL, + kmsService + }); + + let certificates: x509.X509Certificate[] = []; + if (caCertChain && caCertChain.trim()) { + try { + certificates = extractX509CertFromChain(caCertChain).map((cert) => new x509.X509Certificate(cert)); + } catch (error) { + certificates = []; + } + } + + const caCertificate = new x509.X509Certificate(caCert); + + return convertRawCertsToPkcs7([caCertificate.rawData, ...certificates.map((cert) => cert.rawData)]); + }; + + return { + simpleEnrollByProfile, + simpleReenrollByProfile, + getCaCertsByProfile + }; +}; diff --git a/backend/src/services/certificate-profile/certificate-profile-dal.ts b/backend/src/services/certificate-profile/certificate-profile-dal.ts new file mode 100644 index 000000000..20cb9f3bc --- /dev/null +++ b/backend/src/services/certificate-profile/certificate-profile-dal.ts @@ -0,0 +1,552 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +import { + EnrollmentType, + TCertificateProfile, + TCertificateProfileCertificate, + TCertificateProfileInsert, + TCertificateProfileMetrics, + TCertificateProfileUpdate, + TCertificateProfileWithConfigs, + TCertificateProfileWithRawMetrics +} from "./certificate-profile-types"; + +export type TCertificateProfileDALFactory = ReturnType; + +export const certificateProfileDALFactory = (db: TDbClient) => { + const certificateProfileOrm = ormify(db, TableName.PkiCertificateProfile); + + const create = async (data: TCertificateProfileInsert, tx?: Knex): Promise => { + try { + const [certificateProfile] = (await (tx || db)(TableName.PkiCertificateProfile).insert(data).returning("*")) as [ + TCertificateProfile + ]; + return certificateProfile; + } catch (error) { + throw new DatabaseError({ error, name: "Create certificate profile" }); + } + }; + + const updateById = async (id: string, data: TCertificateProfileUpdate, tx?: Knex): Promise => { + try { + const [certificateProfile] = (await (tx || db)(TableName.PkiCertificateProfile) + .where({ id }) + .update(data) + .returning("*")) as [TCertificateProfile]; + return certificateProfile; + } catch (error) { + throw new DatabaseError({ error, name: "Update certificate profile" }); + } + }; + + const deleteById = async (id: string, tx?: Knex): Promise => { + try { + const [certificateProfile] = (await (tx || db)(TableName.PkiCertificateProfile) + .where({ id }) + .del() + .returning("*")) as [TCertificateProfile]; + return certificateProfile; + } catch (error) { + throw new DatabaseError({ error, name: "Delete certificate profile" }); + } + }; + + const findById = async (id: string, tx?: Knex): Promise => { + try { + const certificateProfile = (await (tx || db)(TableName.PkiCertificateProfile).where({ id }).first()) as + | TCertificateProfile + | undefined; + return certificateProfile; + } catch (error) { + throw new DatabaseError({ error, name: "Find certificate profile by id" }); + } + }; + + const findByIdWithConfigs = async (id: string, tx?: Knex): Promise => { + try { + const query = (tx || db)(TableName.PkiCertificateProfile) + .leftJoin( + TableName.CertificateAuthority, + `${TableName.PkiCertificateProfile}.caId`, + `${TableName.CertificateAuthority}.id` + ) + .leftJoin( + TableName.PkiCertificateTemplateV2, + `${TableName.PkiCertificateProfile}.certificateTemplateId`, + `${TableName.PkiCertificateTemplateV2}.id` + ) + .leftJoin( + TableName.PkiEstEnrollmentConfig, + `${TableName.PkiCertificateProfile}.estConfigId`, + `${TableName.PkiEstEnrollmentConfig}.id` + ) + .leftJoin( + TableName.PkiApiEnrollmentConfig, + `${TableName.PkiCertificateProfile}.apiConfigId`, + `${TableName.PkiApiEnrollmentConfig}.id` + ) + .select(selectAllTableCols(TableName.PkiCertificateProfile)) + .select( + db.ref("id").withSchema(TableName.CertificateAuthority).as("caId"), + db.ref("projectId").withSchema(TableName.CertificateAuthority).as("caProjectId"), + db.ref("status").withSchema(TableName.CertificateAuthority).as("caStatus"), + db.ref("name").withSchema(TableName.CertificateAuthority).as("caName"), + db.ref("id").withSchema(TableName.PkiCertificateTemplateV2).as("templateId"), + db.ref("projectId").withSchema(TableName.PkiCertificateTemplateV2).as("templateProjectId"), + db.ref("name").withSchema(TableName.PkiCertificateTemplateV2).as("templateName"), + db.ref("description").withSchema(TableName.PkiCertificateTemplateV2).as("templateDescription"), + db.ref("id").withSchema(TableName.PkiEstEnrollmentConfig).as("estConfigId"), + db + .ref("disableBootstrapCaValidation") + .withSchema(TableName.PkiEstEnrollmentConfig) + .as("estConfigDisableBootstrapCaValidation"), + db.ref("hashedPassphrase").withSchema(TableName.PkiEstEnrollmentConfig).as("estConfigHashedPassphrase"), + db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estConfigEncryptedCaChain"), + db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigId"), + db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigAutoRenew"), + db.ref("autoRenewDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigAutoRenewDays") + ) + .where(`${TableName.PkiCertificateProfile}.id`, id) + .first(); + + const result = await query; + + if (!result) return undefined; + + const estConfig = + result.estConfigId && result.estConfigHashedPassphrase + ? ({ + id: result.estConfigId, + disableBootstrapCaValidation: !!result.estConfigDisableBootstrapCaValidation, + passphrase: result.estConfigHashedPassphrase, + caChain: result.estConfigEncryptedCaChain ? result.estConfigEncryptedCaChain.toString("utf8") : "" + } as TCertificateProfileWithConfigs["estConfig"]) + : undefined; + + const apiConfig = result.apiConfigId + ? ({ + id: result.apiConfigId, + autoRenew: !!result.apiConfigAutoRenew, + autoRenewDays: result.apiConfigAutoRenewDays || undefined + } as TCertificateProfileWithConfigs["apiConfig"]) + : undefined; + + const certificateAuthority = + result.caId && result.caProjectId && result.caStatus && result.caName + ? ({ + id: result.caId, + projectId: result.caProjectId, + status: result.caStatus, + name: result.caName + } as TCertificateProfileWithConfigs["certificateAuthority"]) + : undefined; + + const certificateTemplate = + result.templateId && result.templateProjectId && result.templateName + ? ({ + id: result.templateId, + projectId: result.templateProjectId, + name: result.templateName, + description: result.templateDescription || undefined + } as TCertificateProfileWithConfigs["certificateTemplate"]) + : undefined; + + const transformedResult: TCertificateProfileWithConfigs = { + id: result.id, + projectId: result.projectId, + caId: result.caId, + certificateTemplateId: result.certificateTemplateId, + slug: result.slug, + description: result.description, + enrollmentType: result.enrollmentType as EnrollmentType, + estConfigId: result.estConfigId, + apiConfigId: result.apiConfigId, + createdAt: result.createdAt, + updatedAt: result.updatedAt, + estConfig, + apiConfig, + certificateAuthority, + certificateTemplate + }; + + return transformedResult; + } catch (error) { + throw new DatabaseError({ error, name: "Find certificate profile by id with configs" }); + } + }; + + const findBySlugAndProjectId = async ( + slug: string, + projectId: string, + tx?: Knex + ): Promise => { + try { + const certificateProfile = (await (tx || db)(TableName.PkiCertificateProfile) + .where({ slug, projectId }) + .first()) as TCertificateProfile | undefined; + return certificateProfile; + } catch (error) { + throw new DatabaseError({ error, name: "Find certificate profile by slug and project id" }); + } + }; + + const findByProjectId = async ( + projectId: string, + options: { + offset?: number; + limit?: number; + search?: string; + enrollmentType?: EnrollmentType; + caId?: string; + includeMetrics?: boolean; + expiringDays?: number; + } = {}, + tx?: Knex + ): Promise => { + try { + const { + offset = 0, + limit = 20, + search, + enrollmentType, + caId, + includeMetrics = false, + expiringDays = 7 + } = options; + + let baseQuery = (tx || db)(TableName.PkiCertificateProfile).where( + `${TableName.PkiCertificateProfile}.projectId`, + projectId + ); + + if (search) { + baseQuery = baseQuery.where((builder) => { + void builder.where((qb) => { + void qb + .whereILike(`${TableName.PkiCertificateProfile}.slug`, `%${search}%`) + .orWhereILike(`${TableName.PkiCertificateProfile}.description`, `%${search}%`); + }); + }); + } + + if (enrollmentType) { + baseQuery = baseQuery.where(`${TableName.PkiCertificateProfile}.enrollmentType`, enrollmentType); + } + + if (caId) { + baseQuery = baseQuery.where(`${TableName.PkiCertificateProfile}.caId`, caId); + } + + let query = baseQuery + .leftJoin( + TableName.PkiEstEnrollmentConfig, + `${TableName.PkiCertificateProfile}.estConfigId`, + `${TableName.PkiEstEnrollmentConfig}.id` + ) + .leftJoin( + TableName.PkiApiEnrollmentConfig, + `${TableName.PkiCertificateProfile}.apiConfigId`, + `${TableName.PkiApiEnrollmentConfig}.id` + ) + .select(selectAllTableCols(TableName.PkiCertificateProfile)) + .select( + 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("autoRenewDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenewDays") + ); + + 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("autoRenewDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenewDays"), + 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) + .limit(limit)) as Record[]; + + return results.map((result: Record) => { + const estConfig = + result.estId && result.estHashedPassphrase + ? { + id: result.estId as string, + disableBootstrapCaValidation: !!result.estDisableBootstrapCaValidation, + passphrase: result.estConfigHashedPassphrase, + caChain: result.estEncryptedCaChain ? (result.estEncryptedCaChain as Buffer).toString("utf8") : "" + } + : undefined; + + const apiConfig = result.apiId + ? { + id: result.apiId as string, + autoRenew: !!result.apiAutoRenew, + autoRenewDays: (result.apiAutoRenewDays as number) || undefined + } + : undefined; + + const baseProfile = { + id: result.id, + projectId: result.projectId, + caId: result.caId, + certificateTemplateId: result.certificateTemplateId, + slug: result.slug, + description: result.description, + enrollmentType: result.enrollmentType as EnrollmentType, + estConfigId: result.estConfigId, + apiConfigId: result.apiConfigId, + createdAt: result.createdAt, + updatedAt: result.updatedAt, + estConfig, + 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) { + throw new DatabaseError({ error, name: "Find certificate profiles by project id" }); + } + }; + + const countByProjectId = async ( + projectId: string, + options: { + search?: string; + enrollmentType?: EnrollmentType; + caId?: string; + } = {}, + tx?: Knex + ): Promise => { + try { + const { search, enrollmentType, caId } = options; + + let query = (tx || db)(TableName.PkiCertificateProfile).where({ projectId }); + + if (search) { + query = query.where((builder) => { + void builder.where((qb) => { + void qb.whereILike("description", `%${search}%`).orWhereILike("slug", `%${search}%`); + }); + }); + } + + if (enrollmentType) { + query = query.where({ enrollmentType }); + } + + if (caId) { + query = query.where({ caId }); + } + + const result = await query.count("*").first(); + return parseInt((result as unknown as { count: string }).count || "0", 10); + } catch (error) { + throw new DatabaseError({ error, name: "Count certificate profiles by project id" }); + } + }; + + const findByNameAndProjectId = async ( + name: string, + projectId: string, + tx?: Knex + ): Promise => { + try { + const certificateProfile = (await (tx || db)(TableName.PkiCertificateProfile) + .where({ slug: name, projectId }) + .first()) as TCertificateProfile | undefined; + return certificateProfile; + } catch (error) { + throw new DatabaseError({ error, name: "Find certificate profile by name and project id" }); + } + }; + + const getCertificatesByProfile = async ( + profileId: string, + options: { + offset?: number; + limit?: number; + status?: "active" | "expired" | "revoked"; + search?: string; + } = {}, + tx?: Knex + ): Promise => { + try { + const { offset = 0, limit = 20, status, search } = options; + const now = new Date(); + + let query = (tx || db)(TableName.Certificate).where("profileId", profileId); + + if (search) { + query = query.where((builder) => { + void builder.where((qb) => { + void qb.whereILike("cn", `%${search}%`).orWhereILike("serialNumber", `%${search}%`); + }); + }); + } + + if (status) { + switch (status) { + case "active": + query = query.where("notAfter", ">", now).whereNull("revokedAt"); + break; + case "expired": + query = query.where("notAfter", "<=", now).whereNull("revokedAt"); + break; + case "revoked": + query = query.whereNotNull("revokedAt"); + break; + default: + break; + } + } + + const certificates = await query + .select((tx || db).ref("id").withSchema(TableName.Certificate)) + .select((tx || db).ref("serialNumber").withSchema(TableName.Certificate)) + .select((tx || db).ref("cn").withSchema(TableName.Certificate)) + .select((tx || db).ref("status").withSchema(TableName.Certificate)) + .select((tx || db).ref("notBefore").withSchema(TableName.Certificate)) + .select((tx || db).ref("notAfter").withSchema(TableName.Certificate)) + .select((tx || db).ref("revokedAt").withSchema(TableName.Certificate)) + .select((tx || db).ref("createdAt").withSchema(TableName.Certificate)) + .orderBy("createdAt", "desc") + .offset(offset) + .limit(limit); + + return certificates.map((cert) => ({ + ...cert, + revokedAt: cert.revokedAt ?? null + })); + } catch (error) { + throw new DatabaseError({ error, name: "Get certificates by profile" }); + } + }; + + 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(); + + return parseInt((doc as unknown as { count: string }).count || "0", 10); + } catch (error) { + throw new DatabaseError({ error, name: "Check if certificate profile is in use" }); + } + }; + + return { + ...certificateProfileOrm, + create, + updateById, + deleteById, + findById, + findByIdWithConfigs, + findBySlugAndProjectId, + findByProjectId, + 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 new file mode 100644 index 000000000..7b3e2cc57 --- /dev/null +++ b/backend/src/services/certificate-profile/certificate-profile-schemas.ts @@ -0,0 +1,134 @@ +import RE2 from "re2"; +import { z } from "zod"; + +import { EnrollmentType } from "./certificate-profile-types"; + +export const createCertificateProfileSchema = z + .object({ + projectId: z.string().uuid("Project ID must be valid"), + caId: z.string().uuid(), + certificateTemplateId: z.string().uuid(), + slug: z + .string() + .min(1) + .max(255) + .regex(new RE2("^[a-z0-9-]+$"), "Slug must contain only lowercase letters, numbers, and hyphens"), + description: z.string().max(1000).optional(), + enrollmentType: z.nativeEnum(EnrollmentType), + estConfig: z + .object({ + disableBootstrapCaValidation: z.boolean().default(false), + passphrase: z.string().min(1), + encryptedCaChain: z.string() + }) + .optional(), + apiConfig: z + .object({ + autoRenew: z.boolean().default(false), + autoRenewDays: z.number().min(1).max(365).optional() + }) + .optional() + }) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.EST) { + if (!data.estConfig) { + return false; + } + if (data.apiConfig) { + return false; + } + } + if (data.enrollmentType === EnrollmentType.API) { + if (!data.apiConfig) { + return false; + } + if (data.estConfig) { + return false; + } + } + return true; + }, + { + message: + "EST enrollment type requires EST configuration and cannot have API configuration. API enrollment type requires API configuration and cannot have EST configuration." + } + ); + +export const updateCertificateProfileSchema = z + .object({ + slug: z + .string() + .min(1) + .max(255) + .regex(new RE2("^[a-z0-9-]+$"), "Slug must contain only lowercase letters, numbers, and hyphens") + .optional(), + description: z.string().max(1000).optional(), + enrollmentType: z.nativeEnum(EnrollmentType).optional(), + estConfig: z + .object({ + disableBootstrapCaValidation: z.boolean().default(false), + passphrase: z.string().min(1), + encryptedCaChain: z.string() + }) + .optional(), + apiConfig: z + .object({ + autoRenew: z.boolean().default(false), + autoRenewDays: z.number().min(1).max(365).optional() + }) + .optional() + }) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.EST) { + if (data.apiConfig) { + return false; + } + } + if (data.enrollmentType === EnrollmentType.API) { + if (data.estConfig) { + return false; + } + } + return true; + }, + { + message: "Cannot have EST config with API enrollment type or API config with EST enrollment type." + } + ); + +export const getCertificateProfileByIdSchema = z.object({ + id: z.string().uuid() +}); + +export const getCertificateProfileBySlugSchema = z.object({ + projectId: z.string().uuid("Project ID must be valid"), + slug: z.string().min(1) +}); + +export const listCertificateProfilesSchema = z.object({ + projectId: z.string().uuid("Project ID must be valid"), + offset: z.coerce.number().min(0).default(0), + limit: z.coerce.number().min(1).max(100).default(20), + search: z.string().optional(), + enrollmentType: z.nativeEnum(EnrollmentType).optional(), + caId: z.string().uuid().optional() +}); + +export const deleteCertificateProfileSchema = z.object({ + id: z.string().uuid() +}); + +export const listCertificatesByProfileSchema = z.object({ + profileId: z.string().uuid(), + offset: z.coerce.number().min(0).default(0), + limit: z.coerce.number().min(1).max(100).default(20), + 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 new file mode 100644 index 000000000..dd2d7d2d6 --- /dev/null +++ b/backend/src/services/certificate-profile/certificate-profile-service.test.ts @@ -0,0 +1,1181 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import { ForbiddenError } from "@casl/ability"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; + +import { ActorType, AuthMethod } from "../auth/auth-type"; +import type { TCertificateTemplateV2DALFactory } from "../certificate-template-v2/certificate-template-v2-dal"; +import type { TApiEnrollmentConfigDALFactory } from "../enrollment-config/api-enrollment-config-dal"; +import type { TEstEnrollmentConfigDALFactory } from "../enrollment-config/est-enrollment-config-dal"; +import type { TKmsServiceFactory } from "../kms/kms-service"; +import type { TProjectDALFactory } from "../project/project-dal"; +import type { TCertificateProfileDALFactory } from "./certificate-profile-dal"; +import { certificateProfileServiceFactory, TCertificateProfileServiceFactory } from "./certificate-profile-service"; +import { EnrollmentType, TCertificateProfile, TCertificateProfileWithConfigs } from "./certificate-profile-types"; + +vi.mock("@app/lib/crypto/cryptography", () => ({ + crypto: { + hashing: () => ({ + createHash: vi.fn().mockResolvedValue("mocked-hash") + }), + generateRandomPassword: vi.fn().mockReturnValue("mocked-password") + } +})); + +vi.mock("@app/lib/config/env", () => ({ + getConfig: () => ({ + SALT_ROUNDS: 12 + }) +})); + +describe("CertificateProfileService", () => { + let service: TCertificateProfileServiceFactory; + + const mockCertificateProfileDAL = { + create: vi.fn(), + findById: vi.fn(), + updateById: vi.fn(), + deleteById: vi.fn(), + findBySlugAndProjectId: vi.fn(), + findByProjectId: vi.fn(), + countByProjectId: vi.fn(), + findByNameAndProjectId: vi.fn(), + findByIdWithConfigs: vi.fn(), + getCertificatesByProfile: vi.fn(), + getProfileMetrics: vi.fn(), + isProfileInUse: vi.fn(), + transaction: vi.fn(), + find: vi.fn(), + findOne: vi.fn(), + update: vi.fn(), + delete: vi.fn() + } as unknown as TCertificateProfileDALFactory; + + const mockCertificateTemplateV2DAL = { + findById: vi.fn(), + create: vi.fn(), + updateById: vi.fn(), + deleteById: vi.fn(), + findByProjectId: vi.fn(), + countByProjectId: vi.fn(), + isTemplateInUse: vi.fn(), + findByNameAndProjectId: vi.fn(), + transaction: vi.fn(), + find: vi.fn(), + findOne: vi.fn(), + update: vi.fn(), + delete: vi.fn() + } as unknown as TCertificateTemplateV2DALFactory; + + const mockActor = { + actor: ActorType.USER, + actorId: "user-123", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "org-123" + }; + + const sampleProfile: TCertificateProfile = { + id: "profile-123", + projectId: "project-123", + description: "Test certificate profile", + slug: "test-profile", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + apiConfigId: "api-config-123", + estConfigId: null, + createdAt: new Date(), + updatedAt: new Date() + }; + + const sampleProfileWithConfigs: TCertificateProfileWithConfigs = { + ...sampleProfile, + certificateAuthority: { + id: "ca-123", + projectId: "project-123", + status: "active", + name: "Test CA" + }, + certificateTemplate: { + id: "template-123", + projectId: "project-123", + name: "Test Template", + description: "Test template" + }, + apiConfig: { + id: "api-config-123", + autoRenew: true, + autoRenewDays: 30 + } + }; + + const sampleTemplate = { + id: "template-123", + projectId: "project-123", + name: "Test Template" + }; + + const mockApiEnrollmentConfigDAL = { + create: vi.fn().mockResolvedValue({ id: "api-config-123" }), + findById: vi.fn(), + updateById: vi.fn(), + findProfilesForAutoRenewal: vi.fn(), + transaction: vi.fn(), + find: vi.fn(), + findOne: vi.fn(), + update: vi.fn(), + delete: vi.fn() + } as unknown as TApiEnrollmentConfigDALFactory; + + const mockEstEnrollmentConfigDAL = { + create: vi.fn().mockResolvedValue({ id: "est-config-123" }), + findById: vi.fn(), + updateById: vi.fn(), + transaction: vi.fn(), + find: vi.fn(), + findOne: vi.fn(), + update: vi.fn(), + delete: vi.fn() + } as unknown as TEstEnrollmentConfigDALFactory; + + const mockPermissionService = { + getProjectPermission: vi.fn().mockResolvedValue({ + permission: { + throwUnlessCan: vi.fn() + } + }) + } as unknown as Pick; + + const mockKmsService = { + encryptWithKmsKey: vi + .fn() + .mockResolvedValue(() => Promise.resolve({ cipherTextBlob: Buffer.from("encrypted-data") })), + decryptWithKmsKey: vi.fn().mockResolvedValue(() => Promise.resolve(Buffer.from("decrypted-ca-chain"))), + generateKmsKey: vi.fn() + } as unknown as Pick; + + const mockProjectDAL = { + findById: vi.fn(), + findOne: vi.fn(), + updateById: vi.fn(), + findProjectBySlug: vi.fn(), + transaction: vi.fn() + } as unknown as Pick; + + beforeEach(() => { + vi.spyOn(ForbiddenError, "from").mockReturnValue({ + throwUnlessCan: vi.fn() + } as any); + + // Mock the transaction method to execute the callback and return the result + (mockCertificateProfileDAL.transaction as any).mockImplementation(async (fn: any) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/return-await + return await fn(); + }); + + service = certificateProfileServiceFactory({ + certificateProfileDAL: mockCertificateProfileDAL, + certificateTemplateV2DAL: mockCertificateTemplateV2DAL, + apiEnrollmentConfigDAL: mockApiEnrollmentConfigDAL, + estEnrollmentConfigDAL: mockEstEnrollmentConfigDAL, + permissionService: mockPermissionService, + kmsService: mockKmsService, + projectDAL: mockProjectDAL + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("createProfile", () => { + const validProfileData = { + slug: "new-profile", + description: "New test profile", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + apiConfig: { + autoRenew: true, + autoRenewDays: 30 + } + }; + + beforeEach(() => { + (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate); + (mockCertificateProfileDAL.findByNameAndProjectId as any).mockResolvedValue(null); + (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null); + (mockCertificateProfileDAL.create as any).mockResolvedValue({ + ...sampleProfile, + enrollmentType: EnrollmentType.API // Ensure enrollmentType is explicitly included + }); + }); + + it("should create profile successfully", async () => { + const result = await service.createProfile({ + ...mockActor, + projectId: "project-123", + data: validProfileData + }); + + expect(result).toEqual(sampleProfile); + expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("template-123"); + expect(mockCertificateProfileDAL.findBySlugAndProjectId).toHaveBeenCalledWith("new-profile", "project-123"); + expect(mockCertificateProfileDAL.create).toHaveBeenCalledWith( + { + slug: "new-profile", + description: "New test profile", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + apiConfigId: "api-config-123", + estConfigId: null, + projectId: "project-123" + }, + undefined + ); + }); + + it("should throw NotFoundError when certificate template not found", async () => { + (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(null); + + await expect( + service.createProfile({ + ...mockActor, + projectId: "project-123", + data: validProfileData + }) + ).rejects.toThrow(NotFoundError); + }); + + it("should throw ForbiddenRequestError when template belongs to different project", async () => { + (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue({ + ...sampleTemplate, + projectId: "different-project" + }); + + await expect( + service.createProfile({ + ...mockActor, + projectId: "project-123", + data: validProfileData + }) + ).rejects.toThrow(ForbiddenRequestError); + }); + + it("should throw ForbiddenRequestError when profile slug already exists", async () => { + (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(sampleProfile); + + await expect( + service.createProfile({ + ...mockActor, + projectId: "project-123", + data: validProfileData + }) + ).rejects.toThrow(ForbiddenRequestError); + }); + + it("should throw ForbiddenRequestError for EST enrollment without EST config", async () => { + const invalidData = { + ...validProfileData, + enrollmentType: EnrollmentType.EST, + estConfigId: null + }; + + await expect( + service.createProfile({ + ...mockActor, + projectId: "project-123", + data: invalidData + }) + ).rejects.toThrow(ForbiddenRequestError); + }); + + it("should throw ForbiddenRequestError for API enrollment without API config", async () => { + const invalidData = { + slug: "invalid-profile", + description: "Invalid test profile", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123" + }; + + await expect( + service.createProfile({ + ...mockActor, + projectId: "project-123", + data: invalidData + }) + ).rejects.toThrow(ForbiddenRequestError); + }); + + it("should create profile with API enrollment", async () => { + const apiProfileData = { + slug: "api-profile", + description: "Profile with API enrollment", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + apiConfig: { + autoRenew: true, + autoRenewDays: 30 + } + }; + + const result = await service.createProfile({ + ...mockActor, + projectId: "project-123", + data: apiProfileData + }); + + expect(result).toEqual(sampleProfile); + expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("template-123"); + }); + }); + + describe("updateProfile", () => { + const updateData = { + slug: "updated-profile", + description: "Updated description" + }; + + beforeEach(() => { + (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile); + (mockCertificateProfileDAL.updateById as any).mockResolvedValue({ + ...sampleProfile, + ...updateData, + enrollmentType: EnrollmentType.API // Ensure enrollmentType is explicitly included + }); + }); + + it("should update profile successfully", async () => { + const result = await service.updateProfile({ + ...mockActor, + profileId: "profile-123", + data: updateData + }); + + expect(result.slug).toBe("updated-profile"); + expect(mockCertificateProfileDAL.findById).toHaveBeenCalledWith("profile-123"); + expect(mockCertificateProfileDAL.updateById).toHaveBeenCalledWith("profile-123", updateData, undefined); + }); + + it("should throw NotFoundError when profile not found", async () => { + (mockCertificateProfileDAL.findById as any).mockResolvedValue(null); + + await expect( + service.updateProfile({ + ...mockActor, + profileId: "profile-123", + data: updateData + }) + ).rejects.toThrow(NotFoundError); + }); + + it("should validate certificate template when updating", async () => { + (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate); + + const updateWithTemplate = { + ...updateData, + certificateTemplateId: "template-123" + }; + + await service.updateProfile({ + ...mockActor, + profileId: "profile-123", + data: updateWithTemplate + }); + + expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("template-123"); + }); + }); + + describe("getProfileById", () => { + it("should return profile successfully", async () => { + (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile); + + const result = await service.getProfileById({ + ...mockActor, + profileId: "profile-123" + }); + + expect(result).toEqual(sampleProfile); + expect(mockCertificateProfileDAL.findById).toHaveBeenCalledWith("profile-123"); + }); + + it("should throw NotFoundError when profile not found", async () => { + (mockCertificateProfileDAL.findById as any).mockResolvedValue(null); + + await expect( + service.getProfileById({ + ...mockActor, + profileId: "profile-123" + }) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe("getProfileByIdWithConfigs", () => { + it("should return profile with configs successfully", async () => { + (mockCertificateProfileDAL.findByIdWithConfigs as any).mockResolvedValue(sampleProfileWithConfigs); + + const result = await service.getProfileByIdWithConfigs({ + ...mockActor, + profileId: "profile-123" + }); + + expect(result).toEqual(sampleProfileWithConfigs); + expect(mockCertificateProfileDAL.findByIdWithConfigs).toHaveBeenCalledWith("profile-123"); + }); + + it("should throw NotFoundError when profile not found", async () => { + (mockCertificateProfileDAL.findByIdWithConfigs as any).mockResolvedValue(null); + + await expect( + service.getProfileByIdWithConfigs({ + ...mockActor, + profileId: "profile-123" + }) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe("getProfileBySlug", () => { + it("should return profile by slug successfully", async () => { + (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(sampleProfile); + + const result = await service.getProfileBySlug({ + ...mockActor, + projectId: "project-123", + slug: "test-profile" + }); + + expect(result).toEqual(sampleProfile); + expect(mockCertificateProfileDAL.findBySlugAndProjectId).toHaveBeenCalledWith("test-profile", "project-123"); + }); + + it("should throw NotFoundError when profile not found", async () => { + (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null); + + await expect( + service.getProfileBySlug({ + ...mockActor, + projectId: "project-123", + slug: "nonexistent" + }) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe("listProfiles", () => { + const mockProfiles = [sampleProfile]; + + beforeEach(() => { + (mockCertificateProfileDAL.findByProjectId as any).mockResolvedValue(mockProfiles); + (mockCertificateProfileDAL.countByProjectId as any).mockResolvedValue(1); + }); + + it("should list profiles successfully", async () => { + const result = await service.listProfiles({ + ...mockActor, + projectId: "project-123" + }); + + expect(result.profiles).toEqual(mockProfiles); + expect(result.totalCount).toBe(1); + expect(mockCertificateProfileDAL.findByProjectId).toHaveBeenCalledWith("project-123", { + offset: 0, + limit: 20, + search: undefined, + enrollmentType: undefined, + caId: undefined, + includeMetrics: false, + expiringDays: 30 + }); + }); + + it("should list profiles with filters", async () => { + await service.listProfiles({ + ...mockActor, + projectId: "project-123", + offset: 10, + limit: 5, + search: "test", + enrollmentType: EnrollmentType.API, + caId: "ca-123" + }); + + expect(mockCertificateProfileDAL.findByProjectId).toHaveBeenCalledWith("project-123", { + offset: 10, + 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 + }); + }); + }); + + describe("deleteProfile", () => { + beforeEach(() => { + (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile); + (mockCertificateProfileDAL.isProfileInUse as any).mockResolvedValue(false); + (mockCertificateProfileDAL.deleteById as any).mockResolvedValue(sampleProfile); + }); + + it("should delete profile successfully", async () => { + const result = await service.deleteProfile({ + ...mockActor, + profileId: "profile-123" + }); + + expect(result).toEqual(sampleProfile); + expect(mockCertificateProfileDAL.findById).toHaveBeenCalledWith("profile-123"); + expect(mockCertificateProfileDAL.deleteById).toHaveBeenCalledWith("profile-123"); + }); + + it("should throw NotFoundError when profile not found", async () => { + (mockCertificateProfileDAL.findById as any).mockResolvedValue(null); + + await expect( + service.deleteProfile({ + ...mockActor, + profileId: "profile-123" + }) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe("getProfileCertificates", () => { + const mockCertificates = [ + { + id: "cert-123", + serialNumber: "123456", + cn: "example.com", + status: "active", + notBefore: new Date(), + notAfter: new Date(), + isRevoked: false, + createdAt: new Date() + } + ]; + + beforeEach(() => { + (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile); + (mockCertificateProfileDAL.getCertificatesByProfile as any).mockResolvedValue(mockCertificates); + }); + + it("should get profile certificates successfully", async () => { + const result = await service.getProfileCertificates({ + ...mockActor, + profileId: "profile-123" + }); + + expect(result).toEqual(mockCertificates); + expect(mockCertificateProfileDAL.findById).toHaveBeenCalledWith("profile-123"); + expect(mockCertificateProfileDAL.getCertificatesByProfile).toHaveBeenCalledWith("profile-123", { + offset: 0, + limit: 20, + status: undefined, + search: undefined + }); + }); + + it("should get profile certificates with filters", async () => { + await service.getProfileCertificates({ + ...mockActor, + profileId: "profile-123", + offset: 10, + limit: 5, + status: "active", + search: "example" + }); + + expect(mockCertificateProfileDAL.getCertificatesByProfile).toHaveBeenCalledWith("profile-123", { + offset: 10, + limit: 5, + status: "active", + search: "example" + }); + }); + + it("should throw NotFoundError when profile not found", async () => { + (mockCertificateProfileDAL.findById as any).mockResolvedValue(null); + + await expect( + service.getProfileCertificates({ + ...mockActor, + profileId: "profile-123" + }) + ).rejects.toThrow(NotFoundError); + }); + }); + + 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 () => { + const estProfileData = { + slug: "est-profile", + description: "Profile with EST enrollment", + enrollmentType: EnrollmentType.EST, + caId: "ca-123", + certificateTemplateId: "template-123", + estConfig: { + disableBootstrapCaValidation: false, + passphrase: "secret-passphrase", + caChain: + "-----BEGIN CERTIFICATE-----\nMIIC+DCCAeCgAwIBAgIUBmCvLQ7l6CmNYjGeGXqIaS9LPuUwDQYJKoZIhvcNAQEL\nBQAwFDESMBAGA1UEChMJSW5maXNpY2FsMB4XDTI1MTAxNzE1MjczMFoXDTM1MTAx\nNzAwMDAwMFowFDESMBAGA1UEChMJSW5maXNpY2FsMIIBIjANBgkqhkiG9w0BAQEF\nAAOCAQ8AMIIBCgKCAQEAqRS0ZKh44Y1GHvD4/ryduaelVtfvqkdCmhxpCp7OTjIA\n/gPuVoBA31gxqMVcpDgIAk8dfqds0WFzFe2byhbBalNm3+FSYJkEKa1mdCnqM/mL\nt6O0V/dPv2dcepDluwWbHJIuFf5elH1F8eeyqZV5w6c980lOyDO0DVNqB6pjGlPq\njEVcvEdEtGSfIX3B2tmODilwUvl/lGjhnK6ghfots7i1Xno9VAY/YTqR0T+lyPx4\n23r+22gstJ7XCLA7aqfRyFyYaVKqubHPBwz2qKiBTc3Shc3ii/OHc5KjTpADNRDv\nvH7X5kOXYtdpGbMsJ1uY+MPwfbOVkxy4tg4HFejmyQIDAQABo0IwQDAPBgNVHRMB\nAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUpshrlfvvw+zkoLKf\nxNUYD92/YxIwDQYJKoZIhvcNAQELBQADggEBAAWDMNe8HnoOPHF1sIUcCvJjBeUz\neB++l5Er9P+UPpkSr7+KpD+9DQGWmaOT57Vp7nBYXd42828h+cq7KEG2w5Uf6fYD\nBuitrzj2IzNznvKwOMh/qAePC17tH4mnkSnsJCMg6cvG99GG+vQoMQW7+D6VshIH\nm5hNThNGSPznk+eNk+NlIIVzD4autRn+U5geYzDaZIWfmx95gwCPK2VVw1IDExA+\naQiZi4g1JviUB97E92rZzX+Ai4GYk+CKQTxAiZPZ2M9gRFLrjKIGbRu7FaL+9lwU\nWnax4HJZ/cdVUtVp8VgaAOy7qvl5WGZ4eLopLhMkW3RyPiFr4+M3vNJocqU=\n-----END CERTIFICATE-----" + } + }; + + (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate); + (mockCertificateProfileDAL.findByNameAndProjectId as any).mockResolvedValue(null); + (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null); + (mockCertificateProfileDAL.create as any).mockResolvedValue({ + ...sampleProfile, + enrollmentType: EnrollmentType.EST, + estConfigId: "est-config-123" + }); + + const result = await service.createProfile({ + ...mockActor, + projectId: "project-123", + data: estProfileData + }); + + expect(result.enrollmentType).toBe(EnrollmentType.EST); + expect(mockEstEnrollmentConfigDAL.create).toHaveBeenCalledWith( + { + disableBootstrapCaValidation: estProfileData.estConfig.disableBootstrapCaValidation, + hashedPassphrase: "mocked-hash", + encryptedCaChain: Buffer.from("encrypted-data") + }, + undefined + ); + }); + + it("should handle profile slug uniqueness validation", async () => { + vi.clearAllMocks(); + + const duplicateSlugData = { + slug: "different-profile-name", + description: "Profile with duplicate slug", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + apiConfig: { + autoRenew: true, + autoRenewDays: 30 + } + }; + + (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate); + (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(sampleProfile); + + await expect( + service.createProfile({ + ...mockActor, + projectId: "project-123", + data: duplicateSlugData + }) + ).rejects.toThrow(ForbiddenRequestError); + }); + + it("should validate auto-renewal configuration", async () => { + const autoRenewData = { + slug: "auto-renew-profile", + description: "Profile with auto-renewal", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + apiConfig: { + autoRenew: true, + autoRenewDays: 7 + } + }; + + (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate); + (mockCertificateProfileDAL.findByNameAndProjectId as any).mockResolvedValue(null); + (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null); + (mockCertificateProfileDAL.create as any).mockResolvedValue({ + ...sampleProfile, + apiConfigId: "api-config-123", + enrollmentType: EnrollmentType.API + }); + + const result = await service.createProfile({ + ...mockActor, + projectId: "project-123", + data: autoRenewData + }); + + expect(mockApiEnrollmentConfigDAL.create).toHaveBeenCalledWith( + { + autoRenew: true, + autoRenewDays: 7 + }, + undefined + ); + expect(result).toBeDefined(); + }); + }); + + describe("profile lifecycle management", () => { + it("should handle profile updates with enrollment type changes", async () => { + const currentProfile = { + ...sampleProfile, + enrollmentType: EnrollmentType.API, + apiConfigId: "api-config-123", + estConfigId: null + }; + + const updateToEst = { + enrollmentType: EnrollmentType.EST, + estConfigId: "est-config-123", + apiConfigId: null + }; + + (mockCertificateProfileDAL.findById as any).mockResolvedValue(currentProfile); + (mockCertificateProfileDAL.updateById as any).mockResolvedValue({ + ...currentProfile, + enrollmentType: EnrollmentType.EST, + estConfigId: "est-config-123", + apiConfigId: null + }); + + const result = await service.updateProfile({ + ...mockActor, + profileId: "profile-123", + data: updateToEst + }); + + expect(mockEstEnrollmentConfigDAL.create).not.toHaveBeenCalled(); + expect(result.enrollmentType).toBe(EnrollmentType.EST); + }); + + it("should allow deletion of profiles", async () => { + (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile); + (mockCertificateProfileDAL.deleteById as any).mockResolvedValue(sampleProfile); + + const result = await service.deleteProfile({ + ...mockActor, + profileId: "profile-123" + }); + + expect(result).toEqual(sampleProfile); + expect(mockCertificateProfileDAL.deleteById).toHaveBeenCalledWith("profile-123"); + }); + }); + + describe("certificate management", () => { + it("should filter certificates by status", async () => { + const activeCerts = [ + { + id: "cert-1", + serialNumber: "123456", + cn: "example.com", + status: "active", + notBefore: new Date(), + notAfter: new Date(), + isRevoked: false, + createdAt: new Date() + } + ]; + + (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile); + (mockCertificateProfileDAL.getCertificatesByProfile as any).mockResolvedValue(activeCerts); + + const result = await service.getProfileCertificates({ + ...mockActor, + profileId: "profile-123", + status: "active" + }); + + expect(result).toEqual(activeCerts); + expect(mockCertificateProfileDAL.getCertificatesByProfile).toHaveBeenCalledWith("profile-123", { + offset: 0, + limit: 20, + status: "active", + search: undefined + }); + }); + + it("should search certificates by common name", async () => { + const searchResults = [ + { + id: "cert-1", + serialNumber: "123456", + cn: "api.example.com", + status: "active", + notBefore: new Date(), + notAfter: new Date(), + isRevoked: false, + createdAt: new Date() + } + ]; + + (mockCertificateProfileDAL.findById as any).mockResolvedValue(sampleProfile); + (mockCertificateProfileDAL.getCertificatesByProfile as any).mockResolvedValue(searchResults); + + const result = await service.getProfileCertificates({ + ...mockActor, + profileId: "profile-123", + search: "api.example" + }); + + expect(result).toEqual(searchResults); + expect(mockCertificateProfileDAL.getCertificatesByProfile).toHaveBeenCalledWith("profile-123", { + offset: 0, + limit: 20, + status: undefined, + search: "api.example" + }); + }); + }); + + 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")); + + await expect( + service.getProfileById({ + ...mockActor, + profileId: "profile-123" + }) + ).rejects.toThrow("Database connection failed"); + }); + + it("should handle invalid template reference during profile creation", async () => { + const profileData = { + slug: "invalid-template-profile", + description: "Profile with invalid template", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "nonexistent-template", + apiConfig: { + autoRenew: false + } + }; + + (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(null); + + await expect( + service.createProfile({ + ...mockActor, + projectId: "project-123", + data: profileData + }) + ).rejects.toThrow(NotFoundError); + + expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("nonexistent-template"); + }); + + it("should handle concurrent profile creation conflicts", async () => { + const conflictingData = { + slug: "concurrent-profile", + description: "Profile created concurrently", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + apiConfig: { + autoRenew: false + } + }; + + (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate); + (mockCertificateProfileDAL.findByNameAndProjectId as any).mockResolvedValue(null); + (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null); + (mockCertificateProfileDAL.create as any).mockRejectedValue(new Error("Unique constraint violation")); + + await expect( + service.createProfile({ + ...mockActor, + projectId: "project-123", + data: conflictingData + }) + ).rejects.toThrow("Unique constraint violation"); + }); + }); + + describe("permission and security", () => { + it("should validate project ownership for cross-project template access", async () => { + const crossProjectData = { + slug: "cross-project-profile", + description: "Profile using template from different project", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-456", + apiConfig: { + autoRenew: false + } + }; + + const foreignTemplate = { + id: "template-456", + projectId: "different-project-456", + slug: "foreign-template" + }; + + (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(foreignTemplate); + + await expect( + service.createProfile({ + ...mockActor, + projectId: "project-123", + data: crossProjectData + }) + ).rejects.toThrow(ForbiddenRequestError); + }); + + it("should validate slug format constraints", async () => { + const invalidSlugData = { + slug: "invalid-slug-profile", + description: "Profile with invalid slug format", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + apiConfig: { + autoRenew: false + } + }; + + (mockCertificateTemplateV2DAL.findById as any).mockResolvedValue(sampleTemplate); + (mockCertificateProfileDAL.findByNameAndProjectId as any).mockResolvedValue(null); + (mockCertificateProfileDAL.findBySlugAndProjectId as any).mockResolvedValue(null); + (mockCertificateProfileDAL.create as any).mockResolvedValue({ + ...sampleProfile, + slug: invalidSlugData.slug, + enrollmentType: EnrollmentType.API + }); + + const result = await service.createProfile({ + ...mockActor, + projectId: "project-123", + data: invalidSlugData + }); + + expect(result.slug).toBe(invalidSlugData.slug); + }); + }); + }); + + describe("getEstConfigurationByProfile", () => { + it("should return EST configuration for valid EST profile", async () => { + const profileId = "profile-123"; + const mockProfile = { + ...sampleProfileWithConfigs, + id: profileId, + enrollmentType: EnrollmentType.EST, + estConfig: { + id: "est-config-123", + disableBootstrapCaValidation: false, + passphrase: "", + caChain: "mock-ca-chain" + } + } as TCertificateProfileWithConfigs; + + (mockCertificateProfileDAL.findByIdWithConfigs as any).mockResolvedValue(mockProfile); + + const result = await service.getEstConfigurationByProfile({ ...mockActor, profileId }); + + expect(result).toEqual({ + orgId: "project-123", + isEnabled: true, + caChain: "mock-ca-chain", + disableBootstrapCertValidation: false, + hashedPassphrase: "" + }); + }); + + it("should throw NotFoundError when profile doesn't exist", async () => { + const profileId = "non-existent-profile"; + (mockCertificateProfileDAL.findByIdWithConfigs as any).mockResolvedValue(null); + + await expect(service.getEstConfigurationByProfile({ ...mockActor, profileId })).rejects.toThrow(NotFoundError); + }); + + it("should throw ForbiddenRequestError when profile is not configured for EST enrollment", async () => { + const profileId = "profile-123"; + const mockProfile = { + ...sampleProfileWithConfigs, + id: profileId, + enrollmentType: EnrollmentType.API, // Wrong enrollment type + estConfig: { + id: "est-config-123", + disableBootstrapCaValidation: false, + passphrase: "", + caChain: "mock-ca-chain" + } + } as TCertificateProfileWithConfigs; + + (mockCertificateProfileDAL.findByIdWithConfigs as any).mockResolvedValue(mockProfile); + + await expect(service.getEstConfigurationByProfile({ ...mockActor, profileId })).rejects.toThrow( + ForbiddenRequestError + ); + await expect(service.getEstConfigurationByProfile({ ...mockActor, profileId })).rejects.toThrow( + "Profile is not configured for EST enrollment" + ); + }); + + it("should throw NotFoundError when EST configuration is missing", async () => { + const profileId = "profile-123"; + const mockProfile = { + ...sampleProfileWithConfigs, + id: profileId, + enrollmentType: EnrollmentType.EST, + estConfig: undefined // Missing EST config + } as TCertificateProfileWithConfigs; + + (mockCertificateProfileDAL.findByIdWithConfigs as any).mockResolvedValue(mockProfile); + + await expect(service.getEstConfigurationByProfile({ ...mockActor, profileId })).rejects.toThrow(NotFoundError); + await expect(service.getEstConfigurationByProfile({ ...mockActor, profileId })).rejects.toThrow( + "EST configuration not found for this profile" + ); + }); + }); +}); diff --git a/backend/src/services/certificate-profile/certificate-profile-service.ts b/backend/src/services/certificate-profile/certificate-profile-service.ts new file mode 100644 index 000000000..c43dee889 --- /dev/null +++ b/backend/src/services/certificate-profile/certificate-profile-service.ts @@ -0,0 +1,824 @@ +import { ForbiddenError } from "@casl/ability"; +import * as x509 from "@peculiar/x509"; + +import { ActionProjectType } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { + ProjectPermissionCertificateProfileActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate"; +import { getConfig } from "@app/lib/config/env"; +import { crypto } from "@app/lib/crypto/cryptography"; +import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; + +import { ActorAuthMethod, ActorType } from "../auth/auth-type"; +import { isCertChainValid } from "../certificate/certificate-fns"; +import { TCertificateTemplateV2DALFactory } from "../certificate-template-v2/certificate-template-v2-dal"; +import { TApiEnrollmentConfigDALFactory } from "../enrollment-config/api-enrollment-config-dal"; +import { TApiConfigData, TEstConfigData } from "../enrollment-config/enrollment-config-types"; +import { TEstEnrollmentConfigDALFactory } from "../enrollment-config/est-enrollment-config-dal"; +import { TKmsServiceFactory } from "../kms/kms-service"; +import { TProjectDALFactory } from "../project/project-dal"; +import { getProjectKmsCertificateKeyId } from "../project/project-fns"; +import { TCertificateProfileDALFactory } from "./certificate-profile-dal"; +import { + EnrollmentType, + TCertificateProfile, + TCertificateProfileCertificate, + TCertificateProfileInsert, + TCertificateProfileMetrics, + TCertificateProfileUpdate, + TCertificateProfileWithConfigs, + TCertificateProfileWithRawMetrics +} from "./certificate-profile-types"; + +const validateAndEncryptPemCaChain = async ( + caChain: string, + projectId: string, + kmsService: Pick, + projectDAL: Pick +) => { + try { + const certificates = extractX509CertFromChain(caChain)?.map((cert) => new x509.X509Certificate(cert)); + + if (!certificates || certificates.length === 0) { + throw new BadRequestError({ message: "Failed to parse certificate chain" }); + } + + if (!(await isCertChainValid(certificates))) { + throw new BadRequestError({ message: "Invalid certificate chain" }); + } + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId, + projectDAL, + kmsService + }); + + const kmsEncryptor = await kmsService.encryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const { cipherTextBlob } = await kmsEncryptor({ + plainText: Buffer.from(caChain) + }); + + return { encryptedCaChain: cipherTextBlob }; + } catch (error) { + throw new BadRequestError({ message: `Failed to process certificate chain: ${(error as Error).message}` }); + } +}; + +const decryptCaChain = async ( + encryptedCaChain: Buffer, + projectId: string, + kmsService: Pick, + projectDAL: Pick +): Promise => { + try { + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId, + projectDAL, + kmsService + }); + + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const decryptedCaChain = await kmsDecryptor({ + cipherTextBlob: encryptedCaChain + }); + + return decryptedCaChain.toString(); + } catch (error) { + throw new BadRequestError({ message: `Failed to decrypt certificate chain: ${(error as Error).message}` }); + } +}; + +export type TCertificateProfileCreateData = Omit & { + estConfig?: TEstConfigData; + apiConfig?: TApiConfigData; +}; + +type TCertificateProfileServiceFactoryDep = { + certificateProfileDAL: TCertificateProfileDALFactory; + certificateTemplateV2DAL: TCertificateTemplateV2DALFactory; + apiEnrollmentConfigDAL: TApiEnrollmentConfigDALFactory; + estEnrollmentConfigDAL: TEstEnrollmentConfigDALFactory; + permissionService: Pick; + kmsService: Pick; + projectDAL: Pick; +}; + +export type TCertificateProfileServiceFactory = ReturnType; + +const convertDalToService = (dalResult: Record): TCertificateProfile => { + return { + ...dalResult, + enrollmentType: dalResult.enrollmentType as EnrollmentType + } as TCertificateProfile; +}; + +export const certificateProfileServiceFactory = ({ + certificateProfileDAL, + certificateTemplateV2DAL, + apiEnrollmentConfigDAL, + estEnrollmentConfigDAL, + permissionService, + kmsService, + projectDAL +}: TCertificateProfileServiceFactoryDep) => { + const createProfile = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId, + data + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + projectId: string; + data: Omit; + }): Promise => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.Create, + ProjectPermissionSub.CertificateProfiles + ); + + // Validate that certificate template exists and belongs to the same project + if (data.certificateTemplateId) { + const template = await certificateTemplateV2DAL.findById(data.certificateTemplateId); + if (!template) { + throw new NotFoundError({ message: "Certificate template not found" }); + } + if (template.projectId !== projectId) { + throw new ForbiddenRequestError({ + message: "Certificate template must belong to the same project" + }); + } + } + + // Check for slug uniqueness within project + const existingSlugProfile = await certificateProfileDAL.findBySlugAndProjectId(data.slug, projectId); + if (existingSlugProfile) { + throw new ForbiddenRequestError({ + message: "Certificate profile with this name already exists in project" + }); + } + + // Validate enrollment configuration requirements + if (data.enrollmentType === EnrollmentType.EST && !data.estConfig) { + throw new ForbiddenRequestError({ + message: "EST enrollment requires EST configuration" + }); + } + if (data.enrollmentType === EnrollmentType.API && !data.apiConfig) { + throw new ForbiddenRequestError({ + message: "API enrollment requires API configuration" + }); + } + + // Create enrollment configs and profile + const profile = await certificateProfileDAL.transaction(async (tx) => { + let estConfigId: string | null = null; + let apiConfigId: string | null = null; + + if (data.enrollmentType === EnrollmentType.EST && data.estConfig) { + const appCfg = getConfig(); + // Hash the passphrase + const hashedPassphrase = await crypto.hashing().createHash(data.estConfig.passphrase, appCfg.SALT_ROUNDS); + + let encryptedCaChainBuffer: Buffer | null = null; + if (!data.estConfig.disableBootstrapCaValidation && data.estConfig.caChain) { + const { encryptedCaChain } = await validateAndEncryptPemCaChain( + data.estConfig.caChain, + projectId, + kmsService, + projectDAL + ); + encryptedCaChainBuffer = encryptedCaChain; + } + + const estConfig = await estEnrollmentConfigDAL.create( + { + disableBootstrapCaValidation: data.estConfig.disableBootstrapCaValidation, + hashedPassphrase, + encryptedCaChain: encryptedCaChainBuffer + }, + tx + ); + estConfigId = estConfig.id; + } else if (data.enrollmentType === EnrollmentType.API && data.apiConfig) { + const apiConfig = await apiEnrollmentConfigDAL.create( + { + autoRenew: data.apiConfig.autoRenew, + autoRenewDays: data.apiConfig.autoRenewDays + }, + tx + ); + apiConfigId = apiConfig.id; + } + + // Create the profile with the created config IDs + const { estConfig, apiConfig, ...profileData } = data; + const profileResult = await certificateProfileDAL.create( + { + ...profileData, + projectId, + estConfigId, + apiConfigId + }, + tx + ); + + return profileResult; + }); + + return convertDalToService(profile); + }; + + const updateProfile = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + profileId, + data + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + profileId: string; + data: TCertificateProfileUpdate; + }): Promise => { + const existingProfile = await certificateProfileDAL.findById(profileId); + if (!existingProfile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: existingProfile.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.Edit, + ProjectPermissionSub.CertificateProfiles + ); + + if (data.certificateTemplateId) { + const template = await certificateTemplateV2DAL.findById(data.certificateTemplateId); + if (!template) { + throw new NotFoundError({ message: "Certificate template not found" }); + } + if (template.projectId !== existingProfile.projectId) { + throw new ForbiddenRequestError({ + message: "Certificate template must belong to the same project" + }); + } + } + + if (data.slug && data.slug !== existingProfile.slug) { + const conflictingProfile = await certificateProfileDAL.findBySlugAndProjectId( + data.slug, + existingProfile.projectId + ); + if (conflictingProfile && conflictingProfile.id !== profileId) { + throw new ForbiddenRequestError({ + message: "Certificate profile with this name already exists in project" + }); + } + } + + const { estConfig, apiConfig, ...profileUpdateData } = data; + + const updatedProfile = await certificateProfileDAL.transaction(async (tx) => { + if (estConfig && existingProfile.estConfigId) { + const updateData: { + disableBootstrapCaValidation: boolean; + hashedPassphrase?: string; + encryptedCaChain?: Buffer; + } = { + disableBootstrapCaValidation: estConfig.disableBootstrapCaValidation ?? false + }; + + if (estConfig.passphrase) { + updateData.hashedPassphrase = await crypto + .hashing() + .createHash(estConfig.passphrase, getConfig().SALT_ROUNDS); + } + + if (estConfig.caChain) { + const { encryptedCaChain } = await validateAndEncryptPemCaChain( + estConfig.caChain, + existingProfile.projectId, + kmsService, + projectDAL + ); + updateData.encryptedCaChain = encryptedCaChain; + } + + await estEnrollmentConfigDAL.updateById(existingProfile.estConfigId, updateData, tx); + } + + if (apiConfig && existingProfile.apiConfigId) { + await apiEnrollmentConfigDAL.updateById( + existingProfile.apiConfigId, + { + autoRenew: apiConfig.autoRenew, + autoRenewDays: apiConfig.autoRenewDays + }, + tx + ); + } + + const profileResult = await certificateProfileDAL.updateById(profileId, profileUpdateData, tx); + return profileResult; + }); + + return convertDalToService(updatedProfile); + }; + + const getProfileById = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + profileId, + includeMetrics = false, + expiringDays = 30 + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + profileId: string; + includeMetrics?: boolean; + 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 converted = convertDalToService(profile); + + if (includeMetrics) { + const metrics = await certificateProfileDAL.getProfileMetrics(profileId, expiringDays); + return { + ...converted, + metrics + }; + } + + return converted; + }; + + const getProfileByIdWithConfigs = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + profileId + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + profileId: string; + }): Promise => { + const profile = await certificateProfileDAL.findByIdWithConfigs(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 + ); + + if (profile.estConfig && profile.estConfig.caChain) { + try { + const estConfig = await estEnrollmentConfigDAL.findById(profile.estConfigId!); + if (estConfig && estConfig.encryptedCaChain) { + const decryptedCaChain = await decryptCaChain( + estConfig.encryptedCaChain, + profile.projectId, + kmsService, + projectDAL + ); + profile.estConfig.caChain = decryptedCaChain; + } else { + profile.estConfig.caChain = ""; + } + } catch (error) { + profile.estConfig.caChain = ""; + } + } + + return { + ...profile, + enrollmentType: profile.enrollmentType as EnrollmentType + }; + }; + + const getProfileBySlug = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId, + slug + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + projectId: string; + slug: string; + }): Promise => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.Read, + ProjectPermissionSub.CertificateProfiles + ); + + const profile = await certificateProfileDAL.findBySlugAndProjectId(slug, projectId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + return convertDalToService(profile); + }; + + const listProfiles = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId, + offset = 0, + limit = 20, + search, + enrollmentType, + caId, + includeMetrics = false, + expiringDays = 30 + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + projectId: string; + offset?: number; + limit?: number; + search?: string; + enrollmentType?: EnrollmentType; + caId?: string; + includeMetrics?: boolean; + expiringDays?: number; + }): Promise<{ + profiles: (TCertificateProfileWithConfigs & { metrics?: TCertificateProfileMetrics })[]; + totalCount: number; + }> => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.Read, + ProjectPermissionSub.CertificateProfiles + ); + + const profiles = await certificateProfileDAL.findByProjectId(projectId, { + offset, + limit, + search, + enrollmentType, + caId, + includeMetrics, + expiringDays + }); + + const totalCount = await certificateProfileDAL.countByProjectId(projectId, { + search, + enrollmentType, + caId + }); + + const convertedProfiles = await Promise.all( + profiles.map(async (profile) => { + const profileWithConfigs = profile as TCertificateProfileWithConfigs; + + let decryptedEstConfig = profileWithConfigs.estConfig; + if (decryptedEstConfig && profileWithConfigs.estConfigId) { + try { + const estConfig = await estEnrollmentConfigDAL.findById(profileWithConfigs.estConfigId); + if (estConfig && estConfig.encryptedCaChain) { + const decryptedCaChain = await decryptCaChain( + estConfig.encryptedCaChain, + projectId, + kmsService, + projectDAL + ); + decryptedEstConfig = { + ...decryptedEstConfig, + caChain: decryptedCaChain + }; + } else if (decryptedEstConfig) { + decryptedEstConfig = { + ...decryptedEstConfig, + caChain: "" + }; + } + } catch (error) { + if (decryptedEstConfig) { + decryptedEstConfig = { + ...decryptedEstConfig, + caChain: "" + }; + } + } + } + + const converted = convertDalToService(profileWithConfigs); + let result: TCertificateProfileWithConfigs & { metrics?: TCertificateProfileMetrics } = { + ...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; + }) + ); + + return { + profiles: convertedProfiles, + totalCount + }; + }; + + const deleteProfile = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + profileId + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + profileId: string; + }): 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.Delete, + ProjectPermissionSub.CertificateProfiles + ); + + const deletedProfile = await certificateProfileDAL.deleteById(profileId); + if (!deletedProfile) { + throw new NotFoundError({ message: "Failed to delete certificate profile" }); + } + return convertDalToService(deletedProfile); + }; + + const getProfileCertificates = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + profileId, + offset = 0, + limit = 20, + status, + search + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + profileId: string; + offset?: number; + limit?: number; + status?: "active" | "expired" | "revoked"; + search?: string; + }): 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 certificates = await certificateProfileDAL.getCertificatesByProfile(profileId, { + offset, + limit, + status, + search + }); + + 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: + | { + profileId: string; + isInternal: true; + } + | { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string | undefined; + profileId: string; + isInternal?: false; + } + ) => { + const { profileId, isInternal = false } = params; + const profile = await certificateProfileDAL.findByIdWithConfigs(profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + if (!isInternal) { + const { actor, actorId, actorAuthMethod, actorOrgId } = params as { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string | undefined; + }; + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: profile.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.Read, + ProjectPermissionSub.CertificateProfiles + ); + } + + if (profile.enrollmentType !== EnrollmentType.EST) { + throw new ForbiddenRequestError({ + message: "Profile is not configured for EST enrollment" + }); + } + + if (!profile.estConfig) { + throw new NotFoundError({ message: "EST configuration not found for this profile" }); + } + + return { + orgId: profile.projectId, + isEnabled: true, + caChain: profile.estConfig.caChain, + disableBootstrapCertValidation: profile.estConfig.disableBootstrapCaValidation, + hashedPassphrase: profile.estConfig.passphrase + }; + }; + + return { + createProfile, + updateProfile, + getProfileById, + getProfileByIdWithConfigs, + getProfileBySlug, + 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 new file mode 100644 index 000000000..a6d53a0f3 --- /dev/null +++ b/backend/src/services/certificate-profile/certificate-profile-types.ts @@ -0,0 +1,86 @@ +import { + TPkiCertificateProfiles, + TPkiCertificateProfilesInsert, + TPkiCertificateProfilesUpdate +} from "@app/db/schemas/pki-certificate-profiles"; + +export enum EnrollmentType { + API = "api", + EST = "est" +} + +export type TCertificateProfile = Omit & { + enrollmentType: EnrollmentType; +}; + +export type TCertificateProfileInsert = Omit & { + enrollmentType: EnrollmentType; +}; + +export type TCertificateProfileUpdate = Omit & { + enrollmentType?: EnrollmentType; + estConfig?: { + disableBootstrapCaValidation?: boolean; + passphrase?: string; + caChain?: string; + }; + apiConfig?: { + autoRenew?: boolean; + autoRenewDays?: number; + }; +}; + +export type TCertificateProfileWithConfigs = TCertificateProfile & { + certificateAuthority?: { + id: string; + projectId: string; + status: string; + name: string; + }; + certificateTemplate?: { + id: string; + projectId: string; + name: string; + description?: string; + }; + estConfig?: { + id: string; + disableBootstrapCaValidation: boolean; + passphrase: string; + caChain: string; + }; + apiConfig?: { + id: string; + autoRenew: boolean; + autoRenewDays?: 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; + cn: string; + status: string; + notBefore: Date; + notAfter: Date; + 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-template-v2/certificate-template-v2-dal.ts b/backend/src/services/certificate-template-v2/certificate-template-v2-dal.ts new file mode 100644 index 000000000..3b935f26a --- /dev/null +++ b/backend/src/services/certificate-template-v2/certificate-template-v2-dal.ts @@ -0,0 +1,244 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { TPkiCertificateTemplatesV2Insert } from "@app/db/schemas/pki-certificate-templates-v2"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +import { + TCertificateTemplateV2, + TCertificateTemplateV2Insert, + TCertificateTemplateV2Update +} from "./certificate-template-v2-types"; + +export type TCertificateTemplateV2DALFactory = ReturnType; + +interface CountResult { + count: string; +} + +export const certificateTemplateV2DALFactory = (db: TDbClient) => { + const certificateTemplateV2Orm = ormify(db, TableName.PkiCertificateTemplateV2); + + const serializeJsonFields = (data: TCertificateTemplateV2Insert | TCertificateTemplateV2Update) => { + const serialized = { ...data } as Record; + + const jsonFields = ["subject", "sans", "keyUsages", "extendedKeyUsages", "algorithms", "validity"]; + + jsonFields.forEach((field) => { + const value = serialized[field]; + if (value !== undefined && typeof value !== "string") { + serialized[field] = JSON.stringify(value); + } + }); + + return serialized; + }; + + const parseJsonFields = (raw: Record): TCertificateTemplateV2 => { + const jsonFields = ["subject", "sans", "keyUsages", "extendedKeyUsages", "algorithms", "validity"]; + const parsed = { ...raw } as Record; + + jsonFields.forEach((field) => { + const value = raw[field]; + if (value !== null && value !== undefined) { + if (typeof value === "string") { + try { + parsed[field] = JSON.parse(value); + } catch (error) { + throw new Error( + `Invalid JSON in field '${field}': ${error instanceof Error ? error.message : "Parse error"}` + ); + } + } else { + parsed[field] = value; + } + } else { + parsed[field] = undefined; + } + }); + + return parsed as TCertificateTemplateV2; + }; + + const create = async (data: TCertificateTemplateV2Insert, tx?: Knex) => { + try { + const serializedData = serializeJsonFields(data); + const [certificateTemplateV2] = (await (tx || db)(TableName.PkiCertificateTemplateV2) + .insert(serializedData as TPkiCertificateTemplatesV2Insert) + .returning("*")) as Record[]; + + if (!certificateTemplateV2) { + throw new Error("Failed to create certificate template v2"); + } + + return parseJsonFields(certificateTemplateV2); + } catch (error) { + throw new DatabaseError({ error, name: "Create certificate template v2" }); + } + }; + + const updateById = async (id: string, data: TCertificateTemplateV2Update, tx?: Knex) => { + try { + const serializedData = serializeJsonFields(data); + const [certificateTemplateV2] = (await (tx || db)(TableName.PkiCertificateTemplateV2) + .where({ id }) + .update(serializedData) + .returning("*")) as Record[]; + + if (!certificateTemplateV2) { + return null; + } + + return parseJsonFields(certificateTemplateV2); + } catch (error) { + throw new DatabaseError({ error, name: "Update certificate template v2" }); + } + }; + + const deleteById = async (id: string, tx?: Knex) => { + try { + const [certificateTemplateV2] = (await (tx || db)(TableName.PkiCertificateTemplateV2) + .where({ id }) + .del() + .returning("*")) as Record[]; + + return certificateTemplateV2; + } catch (error) { + throw new DatabaseError({ error, name: "Delete certificate template v2" }); + } + }; + + const findById = async (id: string, tx?: Knex) => { + try { + const certificateTemplateV2 = (await (tx || db)(TableName.PkiCertificateTemplateV2).where({ id }).first()) as + | Record + | undefined; + + if (!certificateTemplateV2) { + return null; + } + + return parseJsonFields(certificateTemplateV2); + } catch (error) { + throw new DatabaseError({ error, name: "Find certificate template v2 by id" }); + } + }; + + const findByProjectId = async ( + projectId: string, + options: { + offset?: number; + limit?: number; + search?: string; + } = {}, + tx?: Knex + ) => { + try { + const { offset = 0, limit = 20, search } = options; + + let query = (tx || db)(TableName.PkiCertificateTemplateV2).where({ projectId }); + + if (search) { + query = query.where((builder) => { + void builder.whereILike("name", `%${search}%`).orWhereILike("description", `%${search}%`); + }); + } + + const certificateTemplatesV2 = await query.orderBy("createdAt", "desc").offset(offset).limit(limit); + + return certificateTemplatesV2.map((template: Record) => parseJsonFields(template)); + } catch (error) { + throw new DatabaseError({ error, name: "Find certificate templates v2 by project id" }); + } + }; + + const countByProjectId = async ( + projectId: string, + options: { + search?: string; + } = {}, + tx?: Knex + ) => { + try { + const { search } = options; + + let query = (tx || db)(TableName.PkiCertificateTemplateV2).where({ projectId }); + + if (search) { + query = query.where((builder) => { + void builder.whereILike("name", `%${search}%`).orWhereILike("description", `%${search}%`); + }); + } + + const result = await query.count("*").first(); + return parseInt((result as unknown as { count: string }).count || "0", 10); + } catch (error) { + throw new DatabaseError({ error, name: "Count certificate templates v2 by project id" }); + } + }; + + const findByNameAndProjectId = async (name: string, projectId: string, tx?: Knex) => { + try { + const certificateTemplateV2 = (await (tx || db)(TableName.PkiCertificateTemplateV2) + .where({ name, projectId }) + .first()) as Record | undefined; + + if (!certificateTemplateV2) { + return null; + } + + return parseJsonFields(certificateTemplateV2); + } catch (error) { + throw new DatabaseError({ error, name: "Find certificate template v2 by name and project id" }); + } + }; + + const isTemplateInUse = async (templateId: string, tx?: Knex) => { + try { + const profileCount = await (tx || db)(TableName.PkiCertificateProfile) + .where({ certificateTemplateId: templateId }) + .count("*") + .first(); + + const profileUsage = parseInt((profileCount as unknown as CountResult).count || "0", 10) > 0; + + const certCount = await (tx || db)(TableName.Certificate) + .where({ certificateTemplateId: templateId }) + .count("*") + .first(); + + const certUsage = parseInt((certCount as unknown as CountResult).count || "0", 10) > 0; + + return profileUsage || certUsage; + } catch (error) { + throw new DatabaseError({ error, name: "Check if certificate template v2 is in use" }); + } + }; + + const getProfilesUsingTemplate = async (templateId: string, tx?: Knex) => { + try { + const profiles = await (tx || db)(TableName.PkiCertificateProfile) + .select("id", "slug", "description") + .where({ certificateTemplateId: templateId }); + + return profiles as Array<{ id: string; slug: string; description?: string }>; + } catch (error) { + throw new DatabaseError({ error, name: "Get profiles using certificate template v2" }); + } + }; + + return { + ...certificateTemplateV2Orm, + create, + updateById, + deleteById, + findById, + findByProjectId, + countByProjectId, + findByNameAndProjectId, + isTemplateInUse, + getProfilesUsingTemplate + }; +}; diff --git a/backend/src/services/certificate-template-v2/certificate-template-v2-schemas.ts b/backend/src/services/certificate-template-v2/certificate-template-v2-schemas.ts new file mode 100644 index 000000000..9fcc51a57 --- /dev/null +++ b/backend/src/services/certificate-template-v2/certificate-template-v2-schemas.ts @@ -0,0 +1,181 @@ +import RE2 from "re2"; +import { z } from "zod"; + +import { + CertExtendedKeyUsageType, + CertKeyUsageType, + CertSubjectAlternativeNameType, + CertSubjectAttributeType +} from "@app/services/certificate-common/certificate-constants"; + +const attributeTypeSchema = z.nativeEnum(CertSubjectAttributeType); +const sanTypeSchema = z.nativeEnum(CertSubjectAlternativeNameType); + +const templateV2SubjectSchema = z + .object({ + type: attributeTypeSchema, + allowed: z.array(z.string().trim().min(1, "Value cannot be empty")).optional(), + required: z.array(z.string().trim().min(1, "Value cannot be empty")).optional(), + denied: z.array(z.string().trim().min(1, "Value cannot be empty")).optional() + }) + .refine( + (data) => { + if (!data.allowed && !data.required && !data.denied) { + return false; + } + return true; + }, + { + message: "Subject attribute must have at least one allowed, required, or denied value" + } + ); + +const templateV2KeyUsagesSchema = z + .object({ + allowed: z.array(z.nativeEnum(CertKeyUsageType)).optional(), + required: z.array(z.nativeEnum(CertKeyUsageType)).optional(), + denied: z.array(z.nativeEnum(CertKeyUsageType)).optional() + }) + .refine( + (data) => { + if (!data.allowed && !data.required && !data.denied) { + return false; + } + return true; + }, + { + message: "Key usages must have at least one allowed, required, or denied value" + } + ); + +const templateV2ExtendedKeyUsagesSchema = z + .object({ + allowed: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(), + required: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional(), + denied: z.array(z.nativeEnum(CertExtendedKeyUsageType)).optional() + }) + .refine( + (data) => { + if (!data.allowed && !data.required && !data.denied) { + return false; + } + return true; + }, + { + message: "Extended key usages must have at least one allowed, required, or denied value" + } + ); + +const templateV2SanSchema = z + .object({ + type: sanTypeSchema, + allowed: z.array(z.string().trim().min(1, "Value cannot be empty")).optional(), + required: z.array(z.string().trim().min(1, "Value cannot be empty")).optional(), + denied: z.array(z.string().trim().min(1, "Value cannot be empty")).optional() + }) + .refine( + (data) => { + if (!data.allowed && !data.required && !data.denied) { + return false; + } + return true; + }, + { + message: "SAN must have at least one allowed, required, or denied value" + } + ); + +const templateV2ValiditySchema = z.object({ + max: z + .string() + .regex(new RE2("^\\d+[dhmy]$"), { + message: "Max validity must be in format like '365d', '12m', '1y', or '24h'" + }) + .optional() +}); + +const templateV2AlgorithmsSchema = z.object({ + signature: z + .array(z.string().trim().min(1, "Algorithm cannot be empty")) + .min(1, "At least one signature algorithm must be provided") + .optional(), + keyAlgorithm: z + .array(z.string().trim().min(1, "Algorithm cannot be empty")) + .min(1, "At least one key algorithm must be provided") + .optional() +}); + +export const certificateTemplateV2ResponseSchema = z.object({ + id: z.string().uuid(), + projectId: z.string().uuid("Project ID must be valid"), + name: z + .string() + .trim() + .min(1, "Template name is required") + .max(255, "Template name must be less than 255 characters") + .regex(new RE2("^[a-zA-Z0-9-_]+$"), "Template name must contain only letters, numbers, hyphens, and underscores"), + description: z.string().trim().max(1000, "Description must be less than 1000 characters").nullable().optional(), + subject: z.array(templateV2SubjectSchema).optional(), + sans: z.array(templateV2SanSchema).optional(), + keyUsages: templateV2KeyUsagesSchema.optional(), + extendedKeyUsages: templateV2ExtendedKeyUsagesSchema.optional(), + algorithms: templateV2AlgorithmsSchema.optional(), + validity: templateV2ValiditySchema.optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export const certificateRequestSchema = z.object({ + commonName: z + .string() + .trim() + .min(1, "Common name cannot be empty") + .max(64, "Common name must be less than 64 characters") + .optional(), + organization: z + .string() + .trim() + .min(1, "Organization cannot be empty") + .max(64, "Organization must be less than 64 characters") + .optional(), + country: z + .string() + .trim() + .min(2, "Country code must be 2 characters") + .max(2, "Country code must be 2 characters") + .optional(), + keyUsages: z.array(z.nativeEnum(CertKeyUsageType)).min(1, "At least one key usage must be provided").optional(), + extendedKeyUsages: z + .array(z.nativeEnum(CertExtendedKeyUsageType)) + .min(1, "At least one extended key usage must be provided") + .optional(), + subjectAlternativeNames: z + .array( + z.object({ + type: sanTypeSchema, + value: z + .string() + .trim() + .min(1, "SAN value cannot be empty") + .max(255, "SAN value must be less than 255 characters") + }) + ) + .min(1, "At least one SAN must be provided") + .optional(), + validity: z + .object({ + ttl: z + .string() + .trim() + .min(1, "TTL cannot be empty") + .regex(new RE2("^\\d+[dhmy]$"), "TTL must be in format like '365d', '12m', '1y', or '24h'") + }) + .optional(), + signatureAlgorithm: z.string().trim().min(1, "Signature algorithm cannot be empty").optional(), + keyAlgorithm: z.string().trim().min(1, "Key algorithm cannot be empty").optional() +}); + +export const validateCertificateRequestSchema = z.object({ + templateId: z.string().uuid(), + request: certificateRequestSchema +}); diff --git a/backend/src/services/certificate-template-v2/certificate-template-v2-service.test.ts b/backend/src/services/certificate-template-v2/certificate-template-v2-service.test.ts new file mode 100644 index 000000000..daacc5dde --- /dev/null +++ b/backend/src/services/certificate-template-v2/certificate-template-v2-service.test.ts @@ -0,0 +1,1745 @@ +/* eslint-disable no-await-in-loop */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { ForbiddenError } from "@casl/ability"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; + +import { ActorType, AuthMethod } from "../auth/auth-type"; +import { + CertExtendedKeyUsageType, + CertKeyUsageType, + CertSubjectAlternativeNameType, + CertSubjectAttributeType +} from "../certificate-common/certificate-constants"; +import { TCertificateTemplateV2DALFactory } from "./certificate-template-v2-dal"; +import { + certificateTemplateV2ServiceFactory, + TCertificateTemplateV2ServiceFactory +} from "./certificate-template-v2-service"; +import { + TCertificateRequest, + TCertificateTemplateV2, + TCertificateTemplateV2Insert, + TTemplateV2Policy +} from "./certificate-template-v2-types"; + +enum CertAttributeRule { + ALLOW = "allow", + DENY = "deny", + REQUIRE = "require" +} + +describe("CertificateTemplateV2Service", () => { + let service: TCertificateTemplateV2ServiceFactory; + + const mockCertificateTemplateV2DAL = { + findBySlugAndProjectId: vi.fn(), + create: vi.fn(), + findById: vi.fn(), + updateById: vi.fn(), + deleteById: vi.fn(), + findByProjectId: vi.fn(), + countByProjectId: vi.fn(), + isTemplateInUse: vi.fn(), + getProfilesUsingTemplate: vi.fn(), + findByNameAndProjectId: vi.fn(), + transaction: vi.fn(), + find: vi.fn(), + findOne: vi.fn(), + findMany: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + insertMany: vi.fn(), + batchInsert: vi.fn(), + upsert: vi.fn(), + countDocuments: vi.fn() + } as any; + + const mockActor = { + actor: ActorType.USER, + actorId: "user-123", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "org-123" + }; + + const samplePolicy: TTemplateV2Policy = { + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["*.example.com", "example.com"] + }, + { + type: CertSubjectAttributeType.ORGANIZATION, + allowed: ["Example Inc", "Example Corp"], + denied: ["Malicious Corp"] + } + ], + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*.example.com", "*.api.example.com"], + required: ["api.example.com"] + }, + { + type: CertSubjectAlternativeNameType.EMAIL, + required: ["admin@example.com"], + denied: ["blocked@example.com"] + } + ], + keyUsages: { + required: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + allowed: [CertKeyUsageType.DATA_ENCIPHERMENT] + }, + extendedKeyUsages: { + required: [CertExtendedKeyUsageType.SERVER_AUTH], + allowed: [CertExtendedKeyUsageType.CLIENT_AUTH] + }, + validity: { + max: "90d" + }, + algorithms: { + signature: ["SHA256-RSA", "SHA256-ECDSA"], + keyAlgorithm: ["RSA-2048", "RSA-4096", "ECDSA-P256"] + } + }; + + const sampleTemplate: TCertificateTemplateV2 = { + id: "template-123", + projectId: "project-123", + name: "web-server-template", + description: "Template for web server certificates", + ...samplePolicy, + createdAt: new Date(), + updatedAt: new Date() + }; + + const mockPermission = { + can: vi.fn().mockReturnValue(true), + cannot: vi.fn().mockReturnValue(false), + relevantRuleFor: vi.fn().mockReturnValue(null), + rulesFor: vi.fn().mockReturnValue([]), + rules: [], + detectSubjectType: vi.fn().mockReturnValue("certificate-templates-v2"), + modelName: "certificate-templates-v2", + throwUnlessCan: vi.fn(), + unlessCan: vi.fn().mockReturnValue({ throwUnlessCan: vi.fn() }) + }; + + const mockPermissionService = { + getProjectPermission: vi.fn().mockResolvedValue({ + permission: mockPermission + }) + }; + + beforeEach(() => { + vi.clearAllMocks(); + + vi.spyOn(ForbiddenError, "from").mockReturnValue({ + throwUnlessCan: vi.fn() + } as any); + + mockPermissionService.getProjectPermission.mockResolvedValue({ + permission: mockPermission + }); + + mockCertificateTemplateV2DAL.findByNameAndProjectId.mockResolvedValue(null); + mockCertificateTemplateV2DAL.findBySlugAndProjectId.mockResolvedValue(null); + + service = certificateTemplateV2ServiceFactory({ + certificateTemplateV2DAL: mockCertificateTemplateV2DAL as TCertificateTemplateV2DALFactory, + permissionService: mockPermissionService + }); + }); + + afterEach(() => { + vi.resetAllMocks(); + }); + + describe("createTemplateV2", () => { + const createData: Omit = { + name: "test-template", + description: "Test description", + ...samplePolicy + }; + + it("should create template with valid policy", async () => { + mockCertificateTemplateV2DAL.create.mockResolvedValue(sampleTemplate); + + const result = await service.createTemplateV2({ + ...mockActor, + projectId: "project-123", + data: createData + }); + + expect(mockCertificateTemplateV2DAL.create).toHaveBeenCalledWith({ + ...createData, + projectId: "project-123", + name: expect.any(String) + }); + expect(result).toEqual(sampleTemplate); + }); + + // Previously tested service-level validations that are now schema-level: + // - Missing attributes validation (now mandatory in schema) + // - Missing key usages validation (now mandatory in schema) + // - Default signature algorithm not in allowed list (now schema-level validation) + // - Default key algorithm not in allowed list (now schema-level validation) + }); + + describe("updateTemplateV2", () => { + it("should update template with valid data", async () => { + const updateData = { name: "updated-template-name" }; + const updatedTemplate = { ...sampleTemplate, ...updateData }; + + mockCertificateTemplateV2DAL.findById.mockResolvedValue(sampleTemplate); + mockCertificateTemplateV2DAL.updateById.mockResolvedValue(updatedTemplate); + + const result = await service.updateTemplateV2({ + ...mockActor, + templateId: "template-123", + data: updateData + }); + + expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("template-123"); + expect(mockCertificateTemplateV2DAL.updateById).toHaveBeenCalledWith("template-123", { + ...updateData, + name: expect.any(String) + }); + expect(result).toEqual(updatedTemplate); + }); + + it("should throw NotFoundError when template does not exist", async () => { + mockCertificateTemplateV2DAL.findById.mockResolvedValue(null); + + await expect( + service.updateTemplateV2({ + ...mockActor, + templateId: "nonexistent-template", + data: { name: "updated-name" } + }) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe("getTemplateV2ById", () => { + it("should return template when found", async () => { + mockCertificateTemplateV2DAL.findById.mockResolvedValue(sampleTemplate); + + const result = await service.getTemplateV2ById({ + ...mockActor, + templateId: "template-123" + }); + + expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("template-123"); + expect(result).toEqual(sampleTemplate); + }); + + it("should throw NotFoundError when template does not exist", async () => { + mockCertificateTemplateV2DAL.findById.mockResolvedValue(null); + + await expect( + service.getTemplateV2ById({ + ...mockActor, + templateId: "nonexistent-template" + }) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe("listTemplatesV2", () => { + it("should return templates list with pagination", async () => { + const templates = [sampleTemplate]; + const totalCount = 1; + + mockCertificateTemplateV2DAL.findByProjectId.mockResolvedValue(templates); + mockCertificateTemplateV2DAL.countByProjectId.mockResolvedValue(totalCount); + + const result = await service.listTemplatesV2({ + ...mockActor, + projectId: "project-123", + offset: 0, + limit: 20 + }); + + expect(mockCertificateTemplateV2DAL.findByProjectId).toHaveBeenCalledWith("project-123", { + offset: 0, + limit: 20, + search: undefined + }); + expect(mockCertificateTemplateV2DAL.countByProjectId).toHaveBeenCalledWith("project-123", { + search: undefined + }); + expect(result).toEqual({ templates, totalCount }); + }); + + it("should handle search parameter", async () => { + const templates = [sampleTemplate]; + const totalCount = 1; + + mockCertificateTemplateV2DAL.findByProjectId.mockResolvedValue(templates); + mockCertificateTemplateV2DAL.countByProjectId.mockResolvedValue(totalCount); + + await service.listTemplatesV2({ + ...mockActor, + projectId: "project-123", + search: "web server" + }); + + expect(mockCertificateTemplateV2DAL.findByProjectId).toHaveBeenCalledWith("project-123", { + offset: 0, + limit: 20, + search: "web server" + }); + expect(mockCertificateTemplateV2DAL.countByProjectId).toHaveBeenCalledWith("project-123", { + search: "web server" + }); + }); + }); + + describe("deleteTemplateV2", () => { + it("should delete template when not in use", async () => { + mockCertificateTemplateV2DAL.findById.mockResolvedValue(sampleTemplate); + mockCertificateTemplateV2DAL.isTemplateInUse.mockResolvedValue(false); + mockCertificateTemplateV2DAL.deleteById.mockResolvedValue(sampleTemplate); + + const result = await service.deleteTemplateV2({ + ...mockActor, + templateId: "template-123" + }); + + expect(mockCertificateTemplateV2DAL.findById).toHaveBeenCalledWith("template-123"); + expect(mockCertificateTemplateV2DAL.isTemplateInUse).toHaveBeenCalledWith("template-123"); + expect(mockCertificateTemplateV2DAL.deleteById).toHaveBeenCalledWith("template-123"); + expect(result).toEqual(sampleTemplate); + }); + + it("should throw NotFoundError when template does not exist", async () => { + mockCertificateTemplateV2DAL.findById.mockResolvedValue(null); + + await expect( + service.deleteTemplateV2({ + ...mockActor, + templateId: "nonexistent-template" + }) + ).rejects.toThrow(NotFoundError); + }); + + it("should throw ForbiddenRequestError when template is in use", async () => { + const mockProfiles = [ + { id: "profile-1", slug: "web-server-profile", description: "Web server certificate profile" }, + { id: "profile-2", slug: "api-gateway-profile", description: "API gateway certificate profile" } + ]; + + mockCertificateTemplateV2DAL.findById.mockResolvedValue(sampleTemplate); + mockCertificateTemplateV2DAL.isTemplateInUse.mockResolvedValue(true); + mockCertificateTemplateV2DAL.getProfilesUsingTemplate.mockResolvedValue(mockProfiles); + + await expect( + service.deleteTemplateV2({ + ...mockActor, + templateId: "template-123" + }) + ).rejects.toThrow(ForbiddenRequestError); + + expect(mockCertificateTemplateV2DAL.getProfilesUsingTemplate).toHaveBeenCalledWith("template-123"); + expect(mockCertificateTemplateV2DAL.deleteById).not.toHaveBeenCalled(); + }); + }); + + describe("validateCertificateRequest", () => { + const validRequest: TCertificateRequest = { + commonName: "api.example.com", + organization: "Example Inc", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }, + { type: CertSubjectAlternativeNameType.EMAIL, value: "admin@example.com" } + ], + validity: { ttl: "30d" }, + signatureAlgorithm: "RSA-SHA256", + keyAlgorithm: "RSA_2048" + }; + + beforeEach(() => { + mockCertificateTemplateV2DAL.findById.mockResolvedValue(sampleTemplate); + }); + + it("should validate valid certificate request", async () => { + const result = await service.validateCertificateRequest("template-123", validRequest); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + expect(result.warnings).toHaveLength(0); + }); + + it("should throw NotFoundError when template does not exist", async () => { + mockCertificateTemplateV2DAL.findById.mockResolvedValue(null); + + await expect(service.validateCertificateRequest("nonexistent-template", validRequest)).rejects.toThrow( + NotFoundError + ); + }); + + it("should validate allowed attribute values against pattern", async () => { + const result = await service.validateCertificateRequest("template-123", validRequest); + + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it("should detect attribute values that don't match allowed patterns", async () => { + const invalidRequest = { ...validRequest, commonName: "forbidden.com" }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain( + "common_name value 'forbidden.com' does not match allowed patterns: *.example.com, example.com" + ); + }); + + it("should detect denied attribute values", async () => { + const templateWithDeny = { + ...sampleTemplate, + subject: [ + ...sampleTemplate.subject!, + { + type: CertSubjectAttributeType.ORGANIZATION, + denied: ["Forbidden Corp"] + } + ] + }; + + mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithDeny); + + const invalidRequest = { ...validRequest, organization: "Forbidden Corp" }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("organization value 'Forbidden Corp' is denied by template policy"); + }); + + it("should detect missing required key usages", async () => { + const invalidRequest = { ...validRequest, keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE] }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Missing required key usages: key_encipherment"); + }); + + it("should detect invalid key usages", async () => { + const invalidRequest = { + ...validRequest, + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT, "invalid_usage"] as any + }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Invalid key usages: invalid_usage"); + }); + + it("should detect missing required extended key usages", async () => { + const invalidRequest = { ...validRequest, extendedKeyUsages: [] }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Missing required extended key usages: server_auth"); + }); + + it("should detect invalid extended key usages", async () => { + const invalidRequest = { + ...validRequest, + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH, "invalid_eku"] as any + }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Invalid extended key usages: invalid_eku"); + }); + + it("should detect missing required SAN entries", async () => { + const invalidRequest = { + ...validRequest, + subjectAlternativeNames: [{ type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }] + }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Required email SAN matching pattern 'admin@example.com' not found in request"); + }); + + it("should validate SAN values against allowed patterns", async () => { + const invalidRequest: TCertificateRequest = { + ...validRequest, + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "forbidden.com" }, + { type: CertSubjectAlternativeNameType.EMAIL, value: "admin@example.com" } + ] + }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain( + "dns_name SAN value 'forbidden.com' does not match allowed patterns: *.example.com, *.api.example.com" + ); + }); + + it("should detect denied SAN values", async () => { + const templateWithDenySan = { + ...sampleTemplate, + sans: [ + ...sampleTemplate.sans!, + { + type: CertSubjectAlternativeNameType.EMAIL, + denied: ["forbidden@example.com"] + } + ] + }; + + mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithDenySan); + + const invalidRequest: TCertificateRequest = { + ...validRequest, + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }, + { type: CertSubjectAlternativeNameType.EMAIL, value: "forbidden@example.com" } + ] + }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("email SAN matching denied pattern 'forbidden@example.com' found in request"); + }); + + it("should detect invalid signature algorithm", async () => { + const invalidRequest = { ...validRequest, signatureAlgorithm: "MD5-RSA" }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Signature algorithm 'MD5-RSA' is not allowed by template policy"); + }); + + it("should detect invalid key algorithm", async () => { + const invalidRequest = { ...validRequest, keyAlgorithm: "RSA-1024" }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Key algorithm 'RSA-1024' is not allowed by template policy"); + }); + + it("should detect TTL exceeding maximum duration", async () => { + const invalidRequest = { ...validRequest, validity: { ttl: "180d" } }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Requested validity period exceeds maximum allowed duration"); + }); + + it("should detect TTL exceeding maximum duration", async () => { + const templateWithMaxDuration = { + ...sampleTemplate, + validity: { + max: "90d" + } + }; + + mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithMaxDuration); + + const invalidRequest = { ...validRequest, validity: { ttl: "100d" } }; + + const result = await service.validateCertificateRequest("template-123", invalidRequest); + + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Requested validity period exceeds maximum allowed duration"); + }); + + it("should handle various TTL formats", async () => { + const testCases = [ + { ttl: "24h", shouldBeValid: true }, + { ttl: "30d", shouldBeValid: true }, + { ttl: "90d", shouldBeValid: true }, + { ttl: "3m", shouldBeValid: true }, + { ttl: "1y", shouldBeValid: false }, + { ttl: "invalid", shouldThrow: true } + ]; + + await Promise.all( + testCases.map(async (testCase) => { + const request = { ...validRequest, validity: { ttl: testCase.ttl } }; + + if (testCase.shouldThrow) { + await expect(service.validateCertificateRequest("template-123", request)).rejects.toThrow( + `Invalid TTL format: ${testCase.ttl}` + ); + } else { + const result = await service.validateCertificateRequest("template-123", request); + expect(result.isValid).toBe(testCase.shouldBeValid); + } + }) + ); + }); + + it("should allow optional key usages and extended key usages", async () => { + const requestWithOptionalUsages = { + ...validRequest, + keyUsages: [ + CertKeyUsageType.DIGITAL_SIGNATURE, + CertKeyUsageType.KEY_ENCIPHERMENT, + CertKeyUsageType.DATA_ENCIPHERMENT + ], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH, CertExtendedKeyUsageType.CLIENT_AUTH] + }; + + const result = await service.validateCertificateRequest("template-123", requestWithOptionalUsages); + + expect(result.isValid).toBe(true); + }); + + it("should handle camelCase key usage mapping correctly", async () => { + const templateWithOptionalUsages = { + ...sampleTemplate, + keyUsages: { + requiredUsages: { + all: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.NON_REPUDIATION, CertKeyUsageType.KEY_AGREEMENT] + }, + optionalUsages: { all: [CertKeyUsageType.CRL_SIGN, CertKeyUsageType.DECIPHER_ONLY] } + }, + extendedKeyUsages: { + requiredUsages: { all: [CertExtendedKeyUsageType.CLIENT_AUTH, CertExtendedKeyUsageType.CODE_SIGNING] }, + optionalUsages: { all: [CertExtendedKeyUsageType.SERVER_AUTH, CertExtendedKeyUsageType.OCSP_SIGNING] } + } + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithOptionalUsages); + + const requestWithCamelCaseUsages = { + ...validRequest, + keyUsages: [ + CertKeyUsageType.DIGITAL_SIGNATURE, + CertKeyUsageType.NON_REPUDIATION, + CertKeyUsageType.KEY_AGREEMENT, + CertKeyUsageType.CRL_SIGN, + CertKeyUsageType.DECIPHER_ONLY + ], + extendedKeyUsages: [CertExtendedKeyUsageType.CLIENT_AUTH, CertExtendedKeyUsageType.CODE_SIGNING] + }; + + const result = await service.validateCertificateRequest("template-123", requestWithCamelCaseUsages); + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it("should validate wildcard patterns in allow attributes", async () => { + const wildcardTemplate = { + ...sampleTemplate, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + rule: CertAttributeRule.ALLOW, + value: "*.example.com" + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(wildcardTemplate); + + const requestWithWildcard = { + commonName: "api.example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }, + { type: CertSubjectAlternativeNameType.EMAIL, value: "admin@example.com" } + ], + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", requestWithWildcard); + expect(result.isValid).toBe(true); + }); + + it("should reject wildcard patterns that don't match", async () => { + const wildcardTemplate = { + ...sampleTemplate, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + rule: CertAttributeRule.ALLOW, + value: "*.example.com" + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(wildcardTemplate); + + const requestWithNonMatchingWildcard = { + commonName: "api.notexample.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }, + { type: CertSubjectAlternativeNameType.EMAIL, value: "admin@example.com" } + ], + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", requestWithNonMatchingWildcard); + expect(result.isValid).toBe(false); + expect(result.errors).toContain( + "common_name value 'api.notexample.com' does not match allowed patterns: *.example.com, example.com" + ); + }); + + it("should require attribute value when allow rule exists", async () => { + const emptyAllowTemplate = { + ...sampleTemplate, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + rule: CertAttributeRule.ALLOW, + value: "example.com" + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(emptyAllowTemplate); + + const requestWithoutCommonName = { + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }, + { type: CertSubjectAlternativeNameType.EMAIL, value: "admin@example.com" } + ], + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", requestWithoutCommonName); + expect(result.isValid).toBe(true); + }); + + it("should prevent certificates from including denied SANs", async () => { + const denyTemplate = { + ...sampleTemplate, + sans: [ + ...sampleTemplate.sans!, + { + type: CertSubjectAlternativeNameType.EMAIL, + denied: ["*@example.com"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(denyTemplate); + + const requestWithProhibitedSan = { + ...validRequest, + subjectAlternativeNames: [{ type: CertSubjectAlternativeNameType.EMAIL as const, value: "test@example.com" }] + }; + + const result = await service.validateCertificateRequest("template-123", requestWithProhibitedSan); + expect(result.isValid).toBe(false); + expect(result.errors).toContain("email SAN matching denied pattern 'test@example.com' found in request"); + }); + + describe("comprehensive template validation scenarios", () => { + it("should handle template with minimal required fields only", async () => { + const minimalTemplate = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["*"] + } + ], + keyUsages: { + required: [CertKeyUsageType.DIGITAL_SIGNATURE] + }, + extendedKeyUsages: { + allowed: [CertExtendedKeyUsageType.SERVER_AUTH] + }, + sans: [], + validity: { + max: "30d" + }, + algorithms: undefined + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(minimalTemplate); + + const minimalReq = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE], + validity: { ttl: "15d" } + }; + + const result = await service.validateCertificateRequest("template-123", minimalReq); + expect(result.isValid).toBe(true); + }); + + it("should handle template with all fields set to allow", async () => { + const allowTemplate = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["*"] + }, + { + type: CertSubjectAttributeType.ORGANIZATION, + allowed: ["*"] + } + ], + keyUsages: { + allowed: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT] + }, + extendedKeyUsages: { + allowed: [CertExtendedKeyUsageType.SERVER_AUTH, CertExtendedKeyUsageType.CLIENT_AUTH] + }, + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(allowTemplate); + + const emptyRequest = { + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", emptyRequest); + expect(result.isValid).toBe(true); + }); + + it("should handle template with SAN fields denied", async () => { + const denyTemplate = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["example.com"] + } + ], + keyUsages: { + required: [CertKeyUsageType.DIGITAL_SIGNATURE] + }, + extendedKeyUsages: { + required: [CertExtendedKeyUsageType.SERVER_AUTH] + }, + sans: [ + { + type: CertSubjectAlternativeNameType.EMAIL, + denied: ["*"] + }, + { + type: CertSubjectAlternativeNameType.URI, + denied: ["*"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(denyTemplate); + + const requestWithProhibited = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.EMAIL as const, value: "test@example.com" }, + { type: CertSubjectAlternativeNameType.URI as const, value: "https://example.com" } + ], + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", requestWithProhibited); + expect(result.isValid).toBe(false); + expect(result.errors).toContain("email SAN matching denied pattern 'test@example.com' found in request"); + expect(result.errors).toContain("uri SAN matching denied pattern 'https://example.com' found in request"); + }); + + it("should validate complex attribute value constraints", async () => { + const constrainedTemplate = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["example.com", "test.com"] + } + ], + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*.example.com"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(constrainedTemplate); + + const validConstrainedRequest = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const validResult = await service.validateCertificateRequest("template-123", validConstrainedRequest); + expect(validResult.isValid).toBe(true); + + const invalidConstrainedRequest = { + commonName: "forbidden.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const invalidResult = await service.validateCertificateRequest("template-123", invalidConstrainedRequest); + expect(invalidResult.isValid).toBe(false); + expect(invalidResult.errors).toContain("common_name value 'forbidden.com' is not in allowed values list"); + }); + + it("should validate SAN value constraints with multiple types", async () => { + const sanTemplate = { + ...sampleTemplate, + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + required: ["*.example.com"] + }, + { + type: CertSubjectAlternativeNameType.IP_ADDRESS, + allowed: ["192.168.1.*"] + }, + { + type: CertSubjectAlternativeNameType.EMAIL, + required: ["*@example.com"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(sanTemplate); + + const validSanRequest = { + commonName: "api.example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME as const, value: "api.example.com" }, + { type: CertSubjectAlternativeNameType.IP_ADDRESS as const, value: "192.168.1.100" }, + { type: CertSubjectAlternativeNameType.EMAIL as const, value: "admin@example.com" } + ], + validity: { ttl: "30d" } + }; + + const validResult = await service.validateCertificateRequest("template-123", validSanRequest); + expect(validResult.isValid).toBe(true); + + const missingSanRequest = { + commonName: "api.example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME as const, value: "api.example.com" } + ], + validity: { ttl: "30d" } + }; + + const missingResult = await service.validateCertificateRequest("template-123", missingSanRequest); + expect(missingResult.isValid).toBe(false); + expect(missingResult.errors).toContain( + "Required email SAN matching pattern '*@example.com' not found in request" + ); + }); + + it("should validate key usage combinations thoroughly", async () => { + const keyUsageTemplate = { + ...sampleTemplate, + keyUsages: { + required: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + allowed: [ + CertKeyUsageType.DIGITAL_SIGNATURE, + CertKeyUsageType.KEY_ENCIPHERMENT, + CertKeyUsageType.DATA_ENCIPHERMENT, + CertKeyUsageType.KEY_AGREEMENT + ] + }, + extendedKeyUsages: { + required: [CertExtendedKeyUsageType.SERVER_AUTH], + allowed: [ + CertExtendedKeyUsageType.SERVER_AUTH, + CertExtendedKeyUsageType.CLIENT_AUTH, + CertExtendedKeyUsageType.EMAIL_PROTECTION + ] + }, + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*.example.com"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(keyUsageTemplate); + + const minimalUsageRequest = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const minimalResult = await service.validateCertificateRequest("template-123", minimalUsageRequest); + expect(minimalResult.isValid).toBe(true); + + const extendedUsageRequest = { + commonName: "example.com", + keyUsages: [ + CertKeyUsageType.DIGITAL_SIGNATURE, + CertKeyUsageType.KEY_ENCIPHERMENT, + CertKeyUsageType.DATA_ENCIPHERMENT + ], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH, CertExtendedKeyUsageType.CLIENT_AUTH], + validity: { ttl: "30d" } + }; + + const extendedResult = await service.validateCertificateRequest("template-123", extendedUsageRequest); + expect(extendedResult.isValid).toBe(true); + + const forbiddenUsageRequest = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT, CertKeyUsageType.CRL_SIGN], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const forbiddenResult = await service.validateCertificateRequest("template-123", forbiddenUsageRequest); + expect(forbiddenResult.isValid).toBe(false); + expect(forbiddenResult.errors).toContain("Invalid key usages: crl_sign"); + }); + + it("should validate algorithm constraints thoroughly", async () => { + const algorithmTemplate = { + ...sampleTemplate, + algorithms: { + signature: ["RSA-SHA256", "RSA-SHA512"], + keyAlgorithm: ["RSA-2048", "RSA-4096"] + }, + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(algorithmTemplate); + + const validAlgoRequest = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + signatureAlgorithm: "RSA-SHA512", + keyAlgorithm: "RSA_4096", + validity: { ttl: "30d" } + }; + + const validResult = await service.validateCertificateRequest("template-123", validAlgoRequest); + expect(validResult.isValid).toBe(true); + + const invalidSigRequest = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + signatureAlgorithm: "ECDSA-SHA256", + keyAlgorithm: "RSA_2048", + validity: { ttl: "30d" } + }; + + const invalidSigResult = await service.validateCertificateRequest("template-123", invalidSigRequest); + expect(invalidSigResult.isValid).toBe(false); + expect(invalidSigResult.errors).toContain( + "Signature algorithm 'ECDSA-SHA256' is not allowed by template policy" + ); + + const invalidKeyRequest = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + signatureAlgorithm: "RSA-SHA256", + keyAlgorithm: "EC_prime256v1", + validity: { ttl: "30d" } + }; + + const invalidKeyResult = await service.validateCertificateRequest("template-123", invalidKeyRequest); + expect(invalidKeyResult.isValid).toBe(false); + expect(invalidKeyResult.errors).toContain("Key algorithm 'EC_prime256v1' is not allowed by template policy"); + }); + + it("should validate validity period edge cases", async () => { + const validityTemplate = { + ...sampleTemplate, + validity: { + max: "365d" + }, + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(validityTemplate); + + const testCases = [ + { ttl: "1d", shouldBeValid: true, description: "minimum duration" }, + { ttl: "365d", shouldBeValid: true, description: "maximum duration" }, + { ttl: "366d", shouldBeValid: false, description: "exceeds maximum" }, + { ttl: "23h", shouldBeValid: true, description: "valid duration under max" }, + { ttl: "24h", shouldBeValid: true, description: "exactly 1 day in hours" }, + { ttl: "8760h", shouldBeValid: true, description: "exactly 365 days in hours" }, + { ttl: "12m", shouldBeValid: true, description: "exactly 365 days in months" }, + { ttl: "1y", shouldBeValid: true, description: "exactly 365 days in years" } + ]; + + await Promise.all( + testCases.map(async (testCase) => { + const request = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: testCase.ttl } + }; + + const result = await service.validateCertificateRequest("template-123", request); + expect(result.isValid).toBe(testCase.shouldBeValid); + + if (!testCase.shouldBeValid) { + expect(result.errors.length).toBeGreaterThan(0); + } + }) + ); + }); + }); + + describe("unlisted field validation", () => { + it("should reject requests with unlisted subject attributes", async () => { + const templateWithLimitedAttributes = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["*"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithLimitedAttributes); + + const requestWithUnlistedKeyUsage = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.ENCIPHER_ONLY], // ENCIPHER_ONLY not allowed + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", requestWithUnlistedKeyUsage); + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Invalid key usages: encipher_only"); + }); + + it("should reject requests with unlisted SAN types", async () => { + const templateWithLimitedSans = { + ...sampleTemplate, + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithLimitedSans); + + const requestWithUnlistedSan = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.EMAIL as const, value: "test@example.com" } // This should be rejected + ], + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", requestWithUnlistedSan); + expect(result.isValid).toBe(false); + expect(result.errors).toContain("email SAN is not allowed by template policy (not defined in template)"); + }); + + it("should reject requests with unlisted key usages when template doesn't define any", async () => { + const templateWithoutKeyUsages = { + ...sampleTemplate, + keyUsages: undefined + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithoutKeyUsages); + + const requestWithKeyUsages = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE], // This should be rejected + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", requestWithKeyUsages); + expect(result.isValid).toBe(false); + expect(result.errors).toContain("Key usages are not allowed by template policy (not defined in template)"); + }); + + it("should reject requests with algorithms when template doesn't define any", async () => { + const templateWithoutAlgorithms = { + ...sampleTemplate, + algorithms: undefined + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithoutAlgorithms); + + const requestWithAlgorithms = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + signatureAlgorithm: "RSA-SHA256", // This should be rejected + keyAlgorithm: "RSA-2048", // This should be rejected + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", requestWithAlgorithms); + expect(result.isValid).toBe(false); + expect(result.errors).toContain( + "Signature algorithm 'RSA-SHA256' is not allowed by template policy (not defined in template)" + ); + expect(result.errors).toContain( + "Key algorithm 'RSA-2048' is not allowed by template policy (not defined in template)" + ); + }); + }); + + describe("comprehensive subject attribute validation", () => { + it("should validate all subject attribute types", async () => { + const comprehensiveTemplate = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + required: ["*"] + } + ], + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(comprehensiveTemplate); + + const validComprehensiveRequest = { + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const validResult = await service.validateCertificateRequest("template-123", validComprehensiveRequest); + expect(validResult.isValid).toBe(true); + + // Test missing mandatory field + const missingCommonNameRequest = { + ...validComprehensiveRequest, + commonName: undefined + }; + + const missingCommonNameResult = await service.validateCertificateRequest( + "template-123", + missingCommonNameRequest + ); + expect(missingCommonNameResult.isValid).toBe(false); + expect(missingCommonNameResult.errors).toContain("Missing required common_name attribute"); + }); + }); + + describe("improved wildcard pattern validation", () => { + it("should handle complex wildcard patterns", async () => { + const wildcardTemplate = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["v1.api.example.com", "service-auth.internal.com", "exact-match.com"] + } + ], + sans: [] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(wildcardTemplate); + + const testCases = [ + // Valid patterns + { commonName: "v1.api.example.com", shouldBeValid: true }, + { commonName: "service-auth.internal.com", shouldBeValid: true }, + { commonName: "exact-match.com", shouldBeValid: true }, + // Invalid patterns + { commonName: "api.example.com", shouldBeValid: false }, // Missing subdomain for *.api.example.com + { commonName: "service.internal.com", shouldBeValid: false }, // Missing dash and wildcard part + { commonName: "not-exact-match.com", shouldBeValid: false } + ]; + + for (const testCase of testCases) { + const request = { + commonName: testCase.commonName, + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", request); + expect(result.isValid).toBe(testCase.shouldBeValid); + + if (!testCase.shouldBeValid) { + expect( + result.errors.some( + (error) => error.includes("does not match allowed patterns") || error.includes("not in allowed values") + ) + ).toBe(true); + } + } + }); + + it("should handle special regex characters in wildcard patterns", async () => { + const specialCharTemplate = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["*.test-site.com", "service[1-9].example.com", "api.{prod,staging}.com"] + } + ], + sans: [] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(specialCharTemplate); + + const testCases = [ + { commonName: "app.test-site.com", shouldBeValid: true }, + { commonName: "service[1-9].example.com", shouldBeValid: true }, // Should match exactly, not as regex + { commonName: "service1.example.com", shouldBeValid: false }, // Should not match as regex pattern + { commonName: "api.{prod,staging}.com", shouldBeValid: true }, // Should match exactly + { commonName: "api.prod.com", shouldBeValid: false } // Should not match as regex pattern + ]; + + for (const testCase of testCases) { + const request = { + commonName: testCase.commonName, + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", request); + expect(result.isValid).toBe(testCase.shouldBeValid); + } + }); + }); + + describe("algorithm validation", () => { + it("should validate signature algorithm constraints", async () => { + const algorithmTemplate = { + ...sampleTemplate, + algorithms: { + signature: ["RSA-SHA256", "RSA-SHA512", "ECDSA-SHA256"], + keyAlgorithm: ["RSA_2048", "RSA_4096", "EC_prime256v1"] + }, + sans: [ + { + type: CertSubjectAlternativeNameType.IP_ADDRESS, + allowed: ["*"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(algorithmTemplate); + + const testCases = [ + { + signatureAlgorithm: "RSA-SHA256", + keyAlgorithm: "RSA_2048", + shouldBeValid: true, + description: "allowed algorithms" + }, + { + signatureAlgorithm: "RSA-SHA512", + keyAlgorithm: "RSA_4096", + shouldBeValid: true, + description: "different allowed algorithms" + }, + { + signatureAlgorithm: "ECDSA-SHA256", + keyAlgorithm: "EC_prime256v1", + shouldBeValid: true, + description: "ECDSA algorithms" + }, + { + signatureAlgorithm: "MD5-RSA", + keyAlgorithm: "RSA_2048", + shouldBeValid: false, + description: "disallowed signature algorithm" + }, + { + signatureAlgorithm: "RSA-SHA256", + keyAlgorithm: "RSA_1024", + shouldBeValid: false, + description: "disallowed key algorithm" + }, + { + signatureAlgorithm: undefined, + keyAlgorithm: undefined, + shouldBeValid: true, + description: "no algorithms specified (should use defaults)" + } + ]; + + for (const testCase of testCases) { + const request = { + commonName: "example.com", + validity: { ttl: "30d" }, + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.IP_ADDRESS as const, value: "192.168.1.1" } + ], + signatureAlgorithm: testCase.signatureAlgorithm, + keyAlgorithm: testCase.keyAlgorithm + }; + + const result = await service.validateCertificateRequest("template-123", request); + expect(result.isValid).toBe(testCase.shouldBeValid); + + if (!testCase.shouldBeValid) { + expect(result.errors.length).toBeGreaterThan(0); + expect(result.errors.some((error) => error.includes("algorithm") || error.includes("Algorithm"))).toBe( + true + ); + } + } + }); + + it("should validate when no algorithm constraints are defined but no algorithms in request", async () => { + const templateWithoutAlgorithms = { + ...sampleTemplate, + algorithms: undefined, + sans: undefined + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithoutAlgorithms); + + const request = { + commonName: "example.com", + validity: { ttl: "30d" }, + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH] + }; + + const result = await service.validateCertificateRequest("template-123", request); + expect(result.isValid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it("should reject algorithms when template has no algorithm constraints", async () => { + const templateWithoutAlgorithms = { + ...sampleTemplate, + algorithms: undefined + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(templateWithoutAlgorithms); + + const requestWithAlgorithms = { + commonName: "example.com", + validity: { ttl: "30d" }, + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + subjectAlternativeNames: [{ type: CertSubjectAlternativeNameType.IP_ADDRESS as const, value: "192.168.1.1" }], + signatureAlgorithm: "RSA-SHA256", + keyAlgorithm: "RSA_2048" + }; + + const result = await service.validateCertificateRequest("template-123", requestWithAlgorithms); + expect(result.isValid).toBe(false); + expect(result.errors).toContain( + "Signature algorithm 'RSA-SHA256' is not allowed by template policy (not defined in template)" + ); + expect(result.errors).toContain( + "Key algorithm 'RSA_2048' is not allowed by template policy (not defined in template)" + ); + }); + + it("should allow requests that match any of multiple attribute policies of same type", async () => { + const multipleAttributePoliciesTemplate = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["*.infisical.com", "*.infisical2.com"] + } + ], + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*.infisical.com", "*.infisical2.com"] + } + ] + }; + mockCertificateTemplateV2DAL.findById.mockResolvedValue(multipleAttributePoliciesTemplate); + + // Test case that matches first policy + const requestMatchingFirstPolicy = { + commonName: "test.infisical.com", + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME as const, value: "api.infisical.com" } + ], + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const result1 = await service.validateCertificateRequest("template-123", requestMatchingFirstPolicy); + expect(result1.isValid).toBe(true); + + // Test case that matches second policy + const requestMatchingSecondPolicy = { + commonName: "test.infisical2.com", + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME as const, value: "api.infisical2.com" } + ], + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const result2 = await service.validateCertificateRequest("template-123", requestMatchingSecondPolicy); + expect(result2.isValid).toBe(true); + + // Test case that matches neither policy + const requestMatchingNeitherPolicy = { + commonName: "test.example.com", + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME as const, value: "api.example.com" } + ], + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const result3 = await service.validateCertificateRequest("template-123", requestMatchingNeitherPolicy); + expect(result3.isValid).toBe(false); + expect(result3.errors).toContain( + "common_name value 'test.example.com' does not match allowed patterns: *.infisical.com, *.infisical2.com" + ); + }); + }); + + describe("New validation logic with allow/deny/require", () => { + it("should validate complex attribute value constraints", async () => { + const complexTemplate = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["*.example.com"] + }, + { + type: CertSubjectAttributeType.ORGANIZATION, + allowed: ["Example*"] + }, + { + type: CertSubjectAttributeType.COUNTRY, + denied: ["XX"] + } + ], + sans: [] + }; + + mockCertificateTemplateV2DAL.findById.mockResolvedValue(complexTemplate); + + const validComplexRequest = { + commonName: "api.example.com", + organization: "Example Corp", + country: "US", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const result1 = await service.validateCertificateRequest("template-123", validComplexRequest); + expect(result1.isValid).toBe(true); + + const invalidCountryRequest = { ...validComplexRequest, country: "XX" }; + const result2 = await service.validateCertificateRequest("template-123", invalidCountryRequest); + expect(result2.isValid).toBe(false); + expect(result2.errors).toContain("country value 'XX' is denied by template policy"); + const invalidOrgRequest = { ...validComplexRequest, organization: "Different Corp" }; + const result3 = await service.validateCertificateRequest("template-123", invalidOrgRequest); + expect(result3.isValid).toBe(false); + expect(result3.errors).toContain( + "organization value 'Different Corp' does not match allowed patterns: Example*" + ); + }); + + it("should handle SAN allow/deny/require logic", async () => { + const sanTemplate = { + ...sampleTemplate, + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*.example.com"] + }, + { + type: CertSubjectAlternativeNameType.EMAIL, + required: ["*@example.com"] + }, + { + type: CertSubjectAlternativeNameType.IP_ADDRESS, + denied: ["192.168.1.*"] + } + ] + }; + + mockCertificateTemplateV2DAL.findById.mockResolvedValue(sanTemplate); + + const validSanRequest = { + commonName: "api.example.com", + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }, + { type: CertSubjectAlternativeNameType.EMAIL, value: "admin@example.com" } + ], + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const result1 = await service.validateCertificateRequest("template-123", validSanRequest); + expect(result1.isValid).toBe(true); + + const missingEmailRequest = { + ...validSanRequest, + subjectAlternativeNames: [{ type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }] + }; + + const result2 = await service.validateCertificateRequest("template-123", missingEmailRequest); + expect(result2.isValid).toBe(false); + expect(result2.errors).toContain("Required email SAN matching pattern '*@example.com' not found in request"); + + const deniedIpRequest = { + ...validSanRequest, + subjectAlternativeNames: [ + ...validSanRequest.subjectAlternativeNames, + { type: CertSubjectAlternativeNameType.IP_ADDRESS, value: "192.168.1.100" } + ] + }; + + const result3 = await service.validateCertificateRequest("template-123", deniedIpRequest); + expect(result3.isValid).toBe(false); + expect(result3.errors).toContain("ip_address SAN matching denied pattern '192.168.1.100' found in request"); + }); + + it("should validate wildcard patterns correctly", async () => { + const wildcardTemplate = { + ...sampleTemplate, + subject: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + allowed: ["*.acme.com"] + } + ], + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + allowed: ["*.api.acme.com"] + } + ] + }; + + mockCertificateTemplateV2DAL.findById.mockResolvedValue(wildcardTemplate); + + const testCases = [ + { cn: "api.acme.com", san: "v1.api.acme.com", shouldPass: true }, + { cn: "www.acme.com", san: "beta.api.acme.com", shouldPass: true }, + { cn: "acme.com", san: "api.acme.com", shouldPass: false }, // Missing subdomain + { cn: "api.notacme.com", san: "v1.api.acme.com", shouldPass: false }, // Wrong domain + { cn: "api.acme.com", san: "api.acme.com", shouldPass: false } // SAN missing required subdomain + ]; + + for (const testCase of testCases) { + const request = { + commonName: testCase.cn, + subjectAlternativeNames: [{ type: CertSubjectAlternativeNameType.DNS_NAME, value: testCase.san }], + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const result = await service.validateCertificateRequest("template-123", request); + expect(result.isValid).toBe(testCase.shouldPass); + } + }); + + it("should enforce multiple required SAN types", async () => { + const multiRequiredTemplate = { + ...sampleTemplate, + sans: [ + { + type: CertSubjectAlternativeNameType.DNS_NAME, + required: ["*.example.com"] + }, + { + type: CertSubjectAlternativeNameType.EMAIL, + required: ["*@example.com"] + }, + { + type: CertSubjectAlternativeNameType.URI, + required: ["https://*.example.com/*"] + } + ] + }; + + mockCertificateTemplateV2DAL.findById.mockResolvedValue(multiRequiredTemplate); + + const completeRequest = { + commonName: "api.example.com", + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }, + { type: CertSubjectAlternativeNameType.EMAIL, value: "admin@example.com" }, + { type: CertSubjectAlternativeNameType.URI, value: "https://api.example.com/webhook" } + ], + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE, CertKeyUsageType.KEY_ENCIPHERMENT], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" } + }; + + const result1 = await service.validateCertificateRequest("template-123", completeRequest); + expect(result1.isValid).toBe(true); + + const incompleteRequest = { + ...completeRequest, + subjectAlternativeNames: [ + { type: CertSubjectAlternativeNameType.DNS_NAME, value: "api.example.com" }, + { type: CertSubjectAlternativeNameType.EMAIL, value: "admin@example.com" } + ] + }; + + const result2 = await service.validateCertificateRequest("template-123", incompleteRequest); + expect(result2.isValid).toBe(false); + expect(result2.errors).toContain( + "Required uri SAN matching pattern 'https://*.example.com/*' not found in request" + ); + }); + }); + }); +}); diff --git a/backend/src/services/certificate-template-v2/certificate-template-v2-service.ts b/backend/src/services/certificate-template-v2/certificate-template-v2-service.ts new file mode 100644 index 000000000..c1942b793 --- /dev/null +++ b/backend/src/services/certificate-template-v2/certificate-template-v2-service.ts @@ -0,0 +1,959 @@ +import { ForbiddenError } from "@casl/ability"; +import slugify from "@sindresorhus/slugify"; +import RE2 from "re2"; + +import { ActionProjectType } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { + ProjectPermissionPkiTemplateActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { alphaNumericNanoId } from "@app/lib/nanoid"; + +import { ActorAuthMethod, ActorType } from "../auth/auth-type"; +import { CertSubjectAttributeType } from "../certificate-common/certificate-constants"; +import { TCertificateTemplateV2DALFactory } from "./certificate-template-v2-dal"; +import { + TCertificateRequest, + TCertificateTemplateV2, + TCertificateTemplateV2Insert, + TCertificateTemplateV2Update, + TTemplateValidationResult +} from "./certificate-template-v2-types"; + +type TCertificateTemplateV2ServiceFactoryDep = { + certificateTemplateV2DAL: TCertificateTemplateV2DALFactory; + permissionService: Pick; +}; + +export const certificateTemplateV2ServiceFactory = ({ + certificateTemplateV2DAL, + permissionService +}: TCertificateTemplateV2ServiceFactoryDep) => { + const consolidateAttributeArray = < + T extends { type: string; allowed?: string[]; required?: string[]; denied?: string[] } + >( + attributes: T[] + ): T[] => { + const consolidated = new Map(); + + attributes.forEach((attr) => { + const existing = consolidated.get(attr.type); + if (existing) { + throw new ForbiddenRequestError({ + message: `Duplicate attribute type '${attr.type}' found in request. Each attribute type must appear only once.` + }); + } else { + consolidated.set(attr.type, attr); + } + }); + + return Array.from(consolidated.values()); + }; + + const parseTTL = (ttl: string): number => { + const regex = new RE2("^(\\d+)([dmyh])$"); + const match = regex.exec(ttl); + if (!match) { + throw new Error(`Invalid TTL format: ${ttl}`); + } + + const value = parseInt(match[1], 10); + const unit = match[2]; + + switch (unit) { + case "h": + return value * 60 * 60 * 1000; + case "d": + return value * 24 * 60 * 60 * 1000; + case "m": + return value * 30 * 24 * 60 * 60 * 1000; + case "y": + return value * 365 * 24 * 60 * 60 * 1000; + default: + throw new Error(`Unsupported TTL unit: ${unit}`); + } + }; + + const validateSubjectAttributePolicy = ( + subject: Array<{ type: string; allowed?: string[]; required?: string[]; denied?: string[] }> + ) => { + if (!subject || subject.length === 0) return; + + // Validate each subject attribute policy + for (const attr of subject) { + // Ensure at least one field is provided + if (!attr.allowed && !attr.required && !attr.denied) { + throw new ForbiddenRequestError({ + message: `Subject attribute type '${attr.type}' must have at least one allowed, required, or denied value` + }); + } + + // Check for duplicate values within arrays + const arrays = [ + { name: "allowed", values: attr.allowed }, + { name: "required", values: attr.required }, + { name: "denied", values: attr.denied } + ]; + + for (const { name, values } of arrays) { + if (values && values.length > 0) { + const uniqueValues = new Set(values); + if (uniqueValues.size !== values.length) { + throw new ForbiddenRequestError({ + message: `Duplicate values found in ${name} list for subject attribute type '${attr.type}'` + }); + } + } + } + } + }; + + const validateSanPolicy = ( + sans: Array<{ type: string; allowed?: string[]; required?: string[]; denied?: string[] }> + ) => { + if (!sans || sans.length === 0) return; + + // Validate each SAN policy + for (const san of sans) { + if (!san.allowed && !san.required && !san.denied) { + throw new ForbiddenRequestError({ + message: `SAN type '${san.type}' must have at least one allowed, required, or denied value` + }); + } + + const arrays = [ + { name: "allowed", values: san.allowed }, + { name: "required", values: san.required }, + { name: "denied", values: san.denied } + ]; + + for (const { name, values } of arrays) { + if (values && values.length > 0) { + const uniqueValues = new Set(values); + if (uniqueValues.size !== values.length) { + throw new ForbiddenRequestError({ + message: `Duplicate values found in ${name} list for SAN type '${san.type}'` + }); + } + } + } + } + }; + + const generateTemplateSlug = (baseName?: string): string => { + if (baseName) { + return slugify(baseName); + } + return slugify(alphaNumericNanoId(12)); + }; + + const ensureUniqueSlug = async (projectId: string, desiredSlug: string, templateId?: string): Promise => { + const existingTemplate = await certificateTemplateV2DAL.findByNameAndProjectId(desiredSlug, projectId); + if (!existingTemplate || (templateId && existingTemplate.id === templateId)) { + return desiredSlug; + } + const alternativeSlug = `${desiredSlug}-${alphaNumericNanoId(8)}`; + const existingAlternative = await certificateTemplateV2DAL.findByNameAndProjectId(alternativeSlug, projectId); + if (!existingAlternative) { + return alternativeSlug; + } + + const randomSlug = slugify(alphaNumericNanoId(12)); + return randomSlug; + }; + + const isWildcardPattern = (value: string): boolean => { + return value.includes("*"); + }; + + const createWildcardRegex = (pattern: string): RegExp => { + const wildcardRegex = new RE2(/\*/g); + const withPlaceholder = pattern.replace(wildcardRegex, "__WILDCARD__"); + const escapeRegex = new RE2(/[.+?^${}()|[\]\\]/g); + const escaped = withPlaceholder.replace(escapeRegex, "\\$&"); + const placeholderRegex = new RE2(/__WILDCARD__/g); + const regexPattern = escaped.replace(placeholderRegex, ".*"); + return new RE2(`^${regexPattern}$`); + }; + + const mapTemplateSignatureAlgorithmToApi = (templateFormat: string): string => { + const mapping: Record = { + "SHA256-RSA": "RSA-SHA256", + "SHA384-RSA": "RSA-SHA384", + "SHA512-RSA": "RSA-SHA512", + "SHA256-ECDSA": "ECDSA-SHA256", + "SHA384-ECDSA": "ECDSA-SHA384", + "SHA512-ECDSA": "ECDSA-SHA512" + }; + return mapping[templateFormat] || templateFormat; + }; + + const mapTemplateKeyAlgorithmToApi = (templateFormat: string): string => { + const mapping: Record = { + "RSA-2048": "RSA_2048", + "RSA-3072": "RSA_3072", + "RSA-4096": "RSA_4096", + "ECDSA-P256": "EC_prime256v1", + "ECDSA-P384": "EC_secp384r1", + "ECDSA-P521": "EC_secp521r1" + }; + return mapping[templateFormat] || templateFormat; + }; + + const validateKeyUsagePolicy = (keyUsages: { allowed?: string[]; required?: string[]; denied?: string[] }) => { + if (!keyUsages) return; + + if (!keyUsages.allowed && !keyUsages.required && !keyUsages.denied) { + throw new ForbiddenRequestError({ + message: "Key usages must have at least one allowed, required, or denied value" + }); + } + + const arrays = [ + { name: "allowed", values: keyUsages.allowed }, + { name: "required", values: keyUsages.required }, + { name: "denied", values: keyUsages.denied } + ]; + + for (const { name, values } of arrays) { + if (values && values.length > 0) { + const uniqueValues = new Set(values); + if (uniqueValues.size !== values.length) { + throw new ForbiddenRequestError({ + message: `Duplicate values found in ${name} key usages list` + }); + } + } + } + }; + + const validateExtendedKeyUsagePolicy = (extendedKeyUsages: { + allowed?: string[]; + required?: string[]; + denied?: string[]; + }) => { + if (!extendedKeyUsages) return; + + if (!extendedKeyUsages.allowed && !extendedKeyUsages.required && !extendedKeyUsages.denied) { + throw new ForbiddenRequestError({ + message: "Extended key usages must have at least one allowed, required, or denied value" + }); + } + + const arrays = [ + { name: "allowed", values: extendedKeyUsages.allowed }, + { name: "required", values: extendedKeyUsages.required }, + { name: "denied", values: extendedKeyUsages.denied } + ]; + + for (const { name, values } of arrays) { + if (values && values.length > 0) { + const uniqueValues = new Set(values); + if (uniqueValues.size !== values.length) { + throw new ForbiddenRequestError({ + message: `Duplicate values found in ${name} extended key usages list` + }); + } + } + } + }; + + const validateValueAgainstConstraints = ( + value: string, + allowedValues: string[], + fieldName: string + ): { isValid: boolean; error?: string } => { + if (!allowedValues || allowedValues.length === 0) { + return { isValid: true }; + } + + const hasWildcards = allowedValues.some(isWildcardPattern); + + for (const allowedValue of allowedValues) { + if (isWildcardPattern(allowedValue)) { + try { + const regex = createWildcardRegex(allowedValue); + if (regex.test(value)) { + return { isValid: true }; + } + } catch (error) { + if (allowedValue === value) { + return { isValid: true }; + } + } + } else if (allowedValue === value) { + return { isValid: true }; + } + } + + if (hasWildcards) { + return { + isValid: false, + error: `${fieldName} value '${value}' does not match allowed patterns: ${allowedValues.join(", ")}` + }; + } + return { + isValid: false, + error: `${fieldName} value '${value}' is not in allowed values list` + }; + }; + + const validateRequestAgainstPolicy = ( + template: TCertificateTemplateV2, + request: TCertificateRequest + ): TTemplateValidationResult => { + const errors: string[] = []; + const warnings: string[] = []; + + // Validate subject attributes + const subjectPolicies = template.subject; + const requestAttributes = new Map(); + if (request.commonName) requestAttributes.set(CertSubjectAttributeType.COMMON_NAME, request.commonName); + if (request.organization) { + requestAttributes.set(CertSubjectAttributeType.ORGANIZATION, request.organization); + } + if (request.country) requestAttributes.set(CertSubjectAttributeType.COUNTRY, request.country); + + if (subjectPolicies && subjectPolicies.length > 0) { + for (const attrPolicy of subjectPolicies) { + const requestValue = requestAttributes.get(attrPolicy.type); + + if (attrPolicy.required && attrPolicy.required.length > 0) { + if (!requestValue) { + errors.push(`Missing required ${attrPolicy.type} attribute`); + } else { + // Validate that the request value matches the required pattern + const hasMatchingRequired = attrPolicy.required.some((requiredValue) => { + const validation = validateValueAgainstConstraints(requestValue, [requiredValue], attrPolicy.type); + return validation.isValid; + }); + if (!hasMatchingRequired) { + errors.push( + `${attrPolicy.type} value '${requestValue}' does not match any required patterns: ${attrPolicy.required.join(", ")}` + ); + } + } + } + + if (requestValue) { + let isValueDenied = false; + if (attrPolicy.denied && attrPolicy.denied.length > 0) { + const validation = validateValueAgainstConstraints(requestValue, attrPolicy.denied, attrPolicy.type); + if (validation.isValid) { + errors.push(`${attrPolicy.type} value '${requestValue}' is denied by template policy`); + isValueDenied = true; + } + } + + if (!isValueDenied && attrPolicy.allowed && attrPolicy.allowed.length > 0) { + let satisfiesRequired = false; + if (attrPolicy.required && attrPolicy.required.length > 0) { + satisfiesRequired = attrPolicy.required.some((requiredValue) => { + const validation = validateValueAgainstConstraints(requestValue, [requiredValue], attrPolicy.type); + return validation.isValid; + }); + } + + if (!satisfiesRequired) { + const allowedValidation = validateValueAgainstConstraints( + requestValue, + attrPolicy.allowed, + attrPolicy.type + ); + if (!allowedValidation.isValid && allowedValidation.error) { + errors.push(allowedValidation.error); + } + } + } + } + } + + // Check if any request attributes are not covered by template policies + for (const [attrType] of requestAttributes) { + const hasPolicy = subjectPolicies.some((policy) => policy.type === attrType); + if (!hasPolicy) { + errors.push(`${attrType} is not allowed by template policy (not defined in template)`); + } + } + } else if (requestAttributes.size > 0) { + // No subject policies defined but request has subject attributes - deny all + for (const [attrType] of requestAttributes) { + errors.push(`${attrType} is not allowed by template policy (no subject policies defined)`); + } + } + + // Validate Subject Alternative Names + const sansPolicies = template.sans; + if (sansPolicies && sansPolicies.length > 0) { + const requestSansByType = new Map(); + + // Group request SANs by type + if (request.subjectAlternativeNames) { + for (const san of request.subjectAlternativeNames) { + if (!requestSansByType.has(san.type)) { + requestSansByType.set(san.type, []); + } + requestSansByType.get(san.type)!.push(san.value); + } + } + + // Validate each SAN policy + for (const sanPolicy of sansPolicies) { + const requestSans = requestSansByType.get(sanPolicy.type) || []; + + // Check REQUIRED values - at least one SAN must match each required pattern + if (sanPolicy.required && sanPolicy.required.length > 0) { + for (const requiredValue of sanPolicy.required) { + const hasMatchingRequiredSan = requestSans.some((sanValue) => { + const validation = validateValueAgainstConstraints(sanValue, [requiredValue], `${sanPolicy.type} SAN`); + return validation.isValid; + }); + + if (!hasMatchingRequiredSan) { + errors.push(`Required ${sanPolicy.type} SAN matching pattern '${requiredValue}' not found in request`); + } + } + } + + // Check DENIED values - no SAN should match denied patterns + if (sanPolicy.denied && sanPolicy.denied.length > 0) { + for (const sanValue of requestSans) { + const validation = validateValueAgainstConstraints(sanValue, sanPolicy.denied, `${sanPolicy.type} SAN`); + if (validation.isValid) { + errors.push(`${sanPolicy.type} SAN matching denied pattern '${sanValue}' found in request`); + } + } + } + + // Check ALLOWED values - if present, all SANs must match at least one allowed pattern + if (sanPolicy.allowed && sanPolicy.allowed.length > 0 && requestSans.length > 0) { + for (const sanValue of requestSans) { + let satisfiesRequired = false; + if (sanPolicy.required && sanPolicy.required.length > 0) { + satisfiesRequired = sanPolicy.required.some((requiredValue) => { + const validation = validateValueAgainstConstraints(sanValue, [requiredValue], `${sanPolicy.type} SAN`); + return validation.isValid; + }); + } + + if (!satisfiesRequired) { + const validation = validateValueAgainstConstraints(sanValue, sanPolicy.allowed, `${sanPolicy.type} SAN`); + if (!validation.isValid && validation.error) { + errors.push(validation.error); + } + } + } + } + } + + // Check if any request SANs are for types not covered by template policies + for (const [requestSanType] of requestSansByType) { + const hasPolicy = sansPolicies.some((policy) => policy.type === requestSanType); + if (!hasPolicy) { + errors.push(`${requestSanType} SAN is not allowed by template policy (not defined in template)`); + } + } + } else if (request.subjectAlternativeNames && request.subjectAlternativeNames.length > 0) { + // No SAN policies defined but request has SANs - deny all + for (const san of request.subjectAlternativeNames) { + errors.push(`${san.type} SAN is not allowed by template policy (no SAN policies defined)`); + } + } + + // Validate key usages + const keyUsagePolicy = template.keyUsages; + if (keyUsagePolicy) { + // Check REQUIRED key usages - must have all required usages + if (keyUsagePolicy.required && keyUsagePolicy.required.length > 0) { + const missingRequired = keyUsagePolicy.required.filter((usage) => !request.keyUsages?.includes(usage)); + if (missingRequired.length > 0) { + errors.push(`Missing required key usages: ${missingRequired.join(", ")}`); + } + } + + // Check DENIED key usages - must not have any denied usages + if (request.keyUsages && keyUsagePolicy.denied && keyUsagePolicy.denied.length > 0) { + const deniedUsages = request.keyUsages.filter((usage) => keyUsagePolicy?.denied?.includes(usage)); + if (deniedUsages.length > 0) { + errors.push(`Denied key usages found in request: ${deniedUsages.join(", ")}`); + } + } + + // Check ALLOWED key usages - if present, all usages must be in allowed list + if (request.keyUsages && keyUsagePolicy && keyUsagePolicy.allowed && keyUsagePolicy.allowed.length > 0) { + const allAllowedUsages = [...(keyUsagePolicy.required || []), ...(keyUsagePolicy.allowed || [])]; + const invalidUsages = request.keyUsages.filter((usage) => !allAllowedUsages.includes(usage)); + if (invalidUsages.length > 0) { + errors.push(`Invalid key usages: ${invalidUsages.join(", ")}`); + } + } + } else if (request.keyUsages && request.keyUsages.length > 0) { + errors.push(`Key usages are not allowed by template policy (not defined in template)`); + } + + // Validate extended key usages + const extendedKeyUsagePolicy = template.extendedKeyUsages; + if (extendedKeyUsagePolicy) { + // Check REQUIRED extended key usages - must have all required usages + if (extendedKeyUsagePolicy.required && extendedKeyUsagePolicy.required.length > 0) { + const missingRequired = extendedKeyUsagePolicy.required.filter( + (usage) => !request.extendedKeyUsages?.includes(usage) + ); + if (missingRequired.length > 0) { + errors.push(`Missing required extended key usages: ${missingRequired.join(", ")}`); + } + } + + // Check DENIED extended key usages - must not have any denied usages + if (request.extendedKeyUsages && extendedKeyUsagePolicy.denied && extendedKeyUsagePolicy.denied.length > 0) { + const deniedUsages = request.extendedKeyUsages.filter((usage) => + extendedKeyUsagePolicy?.denied?.includes(usage) + ); + if (deniedUsages.length > 0) { + errors.push(`Denied extended key usages found in request: ${deniedUsages.join(", ")}`); + } + } + + // Check ALLOWED extended key usages - if present, all usages must be in allowed list + if ( + request.extendedKeyUsages && + extendedKeyUsagePolicy && + extendedKeyUsagePolicy.allowed && + extendedKeyUsagePolicy.allowed.length > 0 + ) { + const allAllowedExtendedUsages = [ + ...(extendedKeyUsagePolicy.required || []), + ...(extendedKeyUsagePolicy.allowed || []) + ]; + const invalidExtendedUsages = request.extendedKeyUsages.filter( + (usage) => !allAllowedExtendedUsages.includes(usage) + ); + if (invalidExtendedUsages.length > 0) { + errors.push(`Invalid extended key usages: ${invalidExtendedUsages.join(", ")}`); + } + } + } else if (request.extendedKeyUsages && request.extendedKeyUsages.length > 0) { + errors.push(`Extended key usages are not allowed by template policy (not defined in template)`); + } + + // Validate algorithms with new structure + if (request.signatureAlgorithm) { + if (template.algorithms?.signature && template.algorithms.signature.length > 0) { + const mappedTemplateAlgorithms = template.algorithms.signature.map(mapTemplateSignatureAlgorithmToApi); + if (!mappedTemplateAlgorithms.includes(request.signatureAlgorithm)) { + errors.push(`Signature algorithm '${request.signatureAlgorithm}' is not allowed by template policy`); + } + } else if (!template.algorithms?.signature) { + errors.push( + `Signature algorithm '${request.signatureAlgorithm}' is not allowed by template policy (not defined in template)` + ); + } + } + + if (request.keyAlgorithm) { + if (template.algorithms?.keyAlgorithm && template.algorithms.keyAlgorithm.length > 0) { + const mappedTemplateKeyTypes = template.algorithms.keyAlgorithm.map(mapTemplateKeyAlgorithmToApi); + if (!mappedTemplateKeyTypes.includes(request.keyAlgorithm)) { + errors.push(`Key algorithm '${request.keyAlgorithm}' is not allowed by template policy`); + } + } else if (!template.algorithms?.keyAlgorithm) { + errors.push( + `Key algorithm '${request.keyAlgorithm}' is not allowed by template policy (not defined in template)` + ); + } + } + + // Validate validity with new structure + if (request.validity?.ttl && (request.notBefore || request.notAfter)) { + errors.push( + "Cannot specify both TTL and notBefore/notAfter. Use either TTL for duration-based validity or notBefore/notAfter for explicit date range." + ); + } + + if (request.notBefore && request.notAfter && request.notBefore >= request.notAfter) { + errors.push("notBefore must be earlier than notAfter"); + } + + // Validate TTL against template validity constraints + if (request.validity?.ttl && template.validity) { + const requestDurationMs = parseTTL(request.validity.ttl); + + // Check maximum duration using max field + if (template.validity.max) { + const maxDurationMs = parseTTL(template.validity.max); + + if (requestDurationMs > maxDurationMs) { + errors.push("Requested validity period exceeds maximum allowed duration"); + } + } + } + // Validate explicit date range against max duration + if ((request.notBefore || request.notAfter) && template.validity?.max) { + const notBefore = request.notBefore || new Date(); + const { notAfter } = request; + + if (notAfter && notBefore && notAfter instanceof Date && notBefore instanceof Date) { + const requestDuration = notAfter.getTime() - notBefore.getTime(); + const maxDurationMs = parseTTL(template.validity.max); + + if (requestDuration > maxDurationMs) { + errors.push( + `Requested validity period (notBefore to notAfter) exceeds maximum allowed duration of ${template.validity.max}` + ); + } + } + } + + return { + isValid: errors.length === 0, + errors, + warnings + }; + }; + + const createTemplateV2 = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId, + data + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + projectId: string; + data: Omit; + }): Promise => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.Create, + ProjectPermissionSub.CertificateTemplates + ); + + if (!data) { + throw new Error("Template data is required"); + } + + const consolidatedData = { + ...data, + subject: data.subject ? consolidateAttributeArray(data.subject) : undefined, + sans: data.sans ? consolidateAttributeArray(data.sans) : undefined + }; + + if (consolidatedData.subject) { + validateSubjectAttributePolicy(consolidatedData.subject); + } + + if (consolidatedData.sans) { + validateSanPolicy(consolidatedData.sans); + } + + if (consolidatedData.keyUsages) { + validateKeyUsagePolicy(consolidatedData.keyUsages); + } + + if (consolidatedData.extendedKeyUsages) { + validateExtendedKeyUsagePolicy(consolidatedData.extendedKeyUsages); + } + + // Generate slug from name and ensure it's unique within project + if (!data.name) { + throw new ForbiddenRequestError({ message: "Template name is required" }); + } + + const slug = generateTemplateSlug(data.name); + const uniqueSlug = await ensureUniqueSlug(projectId, slug); + + const template = await certificateTemplateV2DAL.create({ + ...consolidatedData, + name: uniqueSlug, + projectId + }); + + return template; + }; + + const updateTemplateV2 = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + templateId, + data + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + templateId: string; + data: TCertificateTemplateV2Update; + }): Promise => { + const existingTemplate = await certificateTemplateV2DAL.findById(templateId); + if (!existingTemplate) { + throw new NotFoundError({ message: "Certificate template not found" }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: existingTemplate.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.Edit, + ProjectPermissionSub.CertificateTemplates + ); + + const consolidatedData = { + ...data, + subject: data.subject ? consolidateAttributeArray(data.subject) : undefined, + sans: data.sans ? consolidateAttributeArray(data.sans) : undefined + }; + + if (consolidatedData.subject) { + validateSubjectAttributePolicy(consolidatedData.subject); + } + + if (consolidatedData.sans) { + validateSanPolicy(consolidatedData.sans); + } + + if (consolidatedData.keyUsages) { + validateKeyUsagePolicy(consolidatedData.keyUsages); + } + + if (consolidatedData.extendedKeyUsages) { + validateExtendedKeyUsagePolicy(consolidatedData.extendedKeyUsages); + } + + const updateData = { ...consolidatedData }; + if (data.name && typeof data.name === "string") { + const newSlug = generateTemplateSlug(data.name); + if (newSlug !== existingTemplate.name) { + const uniqueSlug = await ensureUniqueSlug(existingTemplate.projectId, newSlug, templateId); + updateData.name = uniqueSlug; + } + } + + const updatedTemplate = await certificateTemplateV2DAL.updateById(templateId, updateData); + if (!updatedTemplate) { + throw new NotFoundError({ message: "Failed to update certificate template" }); + } + return updatedTemplate; + }; + + const getTemplateV2ById = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + templateId + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + templateId: string; + }): Promise => { + const template = await certificateTemplateV2DAL.findById(templateId); + if (!template) { + throw new NotFoundError({ message: "Certificate template not found" }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: template.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.Read, + ProjectPermissionSub.CertificateTemplates + ); + + return template; + }; + + const getTemplateV2BySlug = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId, + slug + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + projectId: string; + slug: string; + }): Promise => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.Read, + ProjectPermissionSub.CertificateTemplates + ); + + const template = await certificateTemplateV2DAL.findByNameAndProjectId(slug, projectId); + if (!template) { + throw new NotFoundError({ message: "Certificate template not found" }); + } + + return template; + }; + + const listTemplatesV2 = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + projectId, + offset = 0, + limit = 20, + search + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + projectId: string; + offset?: number; + limit?: number; + search?: string; + }): Promise<{ + templates: TCertificateTemplateV2[]; + totalCount: number; + }> => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.Read, + ProjectPermissionSub.CertificateTemplates + ); + + const templates = await certificateTemplateV2DAL.findByProjectId(projectId, { + offset, + limit, + search + }); + + const totalCount = await certificateTemplateV2DAL.countByProjectId(projectId, { search }); + + return { + templates, + totalCount + }; + }; + + const deleteTemplateV2 = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + templateId + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + templateId: string; + }): Promise => { + const template = await certificateTemplateV2DAL.findById(templateId); + if (!template) { + throw new NotFoundError({ message: "Certificate template not found" }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: template.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionPkiTemplateActions.Delete, + ProjectPermissionSub.CertificateTemplates + ); + + const isInUse = await certificateTemplateV2DAL.isTemplateInUse(templateId); + if (isInUse) { + const profilesUsingTemplate = await certificateTemplateV2DAL.getProfilesUsingTemplate(templateId); + const profileNames = profilesUsingTemplate + .map((profile: { slug?: string; id: string }) => profile.slug || profile.id) + .join(", "); + + throw new ForbiddenRequestError({ + message: + profilesUsingTemplate.length > 0 + ? `Cannot delete template '${template.name}' as it is currently in use by the following certificate profiles: ${profileNames}. Please remove this template from these profiles before deleting it.` + : `Cannot delete template '${template.name}' as it is currently in use by one or more certificates. Please ensure no certificates are using this template before deleting it.` + }); + } + + const deletedTemplate = await certificateTemplateV2DAL.deleteById(templateId); + if (!deletedTemplate) { + throw new NotFoundError({ message: "Failed to delete certificate template" }); + } + return deletedTemplate as TCertificateTemplateV2; + }; + + const validateCertificateRequest = async ( + templateId: string, + request: TCertificateRequest + ): Promise => { + const template = await certificateTemplateV2DAL.findById(templateId); + if (!template) { + throw new NotFoundError({ message: "Certificate template not found" }); + } + + return validateRequestAgainstPolicy(template, request); + }; + + return { + createTemplateV2, + updateTemplateV2, + getTemplateV2ById, + getTemplateV2BySlug, + listTemplatesV2, + deleteTemplateV2, + validateCertificateRequest + }; +}; + +export type TCertificateTemplateV2ServiceFactory = ReturnType; diff --git a/backend/src/services/certificate-template-v2/certificate-template-v2-types.ts b/backend/src/services/certificate-template-v2/certificate-template-v2-types.ts new file mode 100644 index 000000000..de2691d9a --- /dev/null +++ b/backend/src/services/certificate-template-v2/certificate-template-v2-types.ts @@ -0,0 +1,98 @@ +import { + TPkiCertificateTemplatesV2, + TPkiCertificateTemplatesV2Insert +} from "@app/db/schemas/pki-certificate-templates-v2"; +import { + CertExtendedKeyUsageType, + CertKeyUsageType, + CertSubjectAlternativeNameType, + CertSubjectAttributeType +} from "@app/services/certificate-common/certificate-constants"; + +export interface TTemplateV2Policy { + subject?: Array<{ + type: CertSubjectAttributeType; + allowed?: string[]; + required?: string[]; + denied?: string[]; + }>; + sans?: Array<{ + type: CertSubjectAlternativeNameType; + allowed?: string[]; + required?: string[]; + denied?: string[]; + }>; + keyUsages?: { + allowed?: CertKeyUsageType[]; + required?: CertKeyUsageType[]; + denied?: CertKeyUsageType[]; + }; + extendedKeyUsages?: { + allowed?: CertExtendedKeyUsageType[]; + required?: CertExtendedKeyUsageType[]; + denied?: CertExtendedKeyUsageType[]; + }; + algorithms?: { + signature?: string[]; + keyAlgorithm?: string[]; + }; + validity?: { + max?: string; + }; +} + +export type TCertificateTemplateV2 = TPkiCertificateTemplatesV2 & { + subject?: TTemplateV2Policy["subject"]; + sans?: TTemplateV2Policy["sans"]; + keyUsages?: TTemplateV2Policy["keyUsages"]; + extendedKeyUsages?: TTemplateV2Policy["extendedKeyUsages"]; + algorithms?: TTemplateV2Policy["algorithms"]; + validity?: TTemplateV2Policy["validity"]; +}; + +export type TCertificateTemplateV2Insert = TPkiCertificateTemplatesV2Insert & { + subject?: TTemplateV2Policy["subject"]; + sans?: TTemplateV2Policy["sans"]; + keyUsages?: TTemplateV2Policy["keyUsages"]; + extendedKeyUsages?: TTemplateV2Policy["extendedKeyUsages"]; + algorithms?: TTemplateV2Policy["algorithms"]; + validity?: TTemplateV2Policy["validity"]; +}; + +export type TCertificateTemplateV2Update = Partial< + Pick< + TCertificateTemplateV2, + "name" | "description" | "subject" | "sans" | "keyUsages" | "extendedKeyUsages" | "algorithms" | "validity" + > +>; + +export interface TCertificateRequest { + commonName?: string; + organization?: string; + organizationUnit?: string; + locality?: string; + state?: string; + country?: string; + email?: string; + streetAddress?: string; + postalCode?: string; + keyUsages?: CertKeyUsageType[]; + extendedKeyUsages?: CertExtendedKeyUsageType[]; + subjectAlternativeNames?: Array<{ + type: CertSubjectAlternativeNameType; + value: string; + }>; + validity?: { + ttl: string; + }; + notBefore?: Date; + notAfter?: Date; + signatureAlgorithm?: string; + keyAlgorithm?: string; +} + +export interface TTemplateValidationResult { + isValid: boolean; + errors: string[]; + warnings: string[]; +} diff --git a/backend/src/services/certificate-template/certificate-template-service.ts b/backend/src/services/certificate-template/certificate-template-service.ts index 20c061bf7..20c0ffd88 100644 --- a/backend/src/services/certificate-template/certificate-template-service.ts +++ b/backend/src/services/certificate-template/certificate-template-service.ts @@ -420,7 +420,7 @@ export const certificateTemplateServiceFactory = ({ }; const getEstConfiguration = async (dto: TGetEstConfigurationDTO) => { - const { certificateTemplateId } = dto; + const { certificateTemplateId, isInternal } = dto; const certTemplate = await certificateTemplateDAL.getById(certificateTemplateId); if (!certTemplate) { @@ -429,7 +429,7 @@ export const certificateTemplateServiceFactory = ({ }); } - if (!dto.isInternal) { + if (!isInternal) { const { permission } = await permissionService.getProjectPermission({ actor: dto.actor, actorId: dto.actorId, @@ -440,7 +440,7 @@ export const certificateTemplateServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionPkiTemplateActions.Edit, + ProjectPermissionPkiTemplateActions.Read, subject(ProjectPermissionSub.CertificateTemplates, { name: certTemplate.name }) ); } diff --git a/backend/src/services/certificate-template/certificate-template-validators.ts b/backend/src/services/certificate-template/certificate-template-validators.ts index 60694b598..e33ddb047 100644 --- a/backend/src/services/certificate-template/certificate-template-validators.ts +++ b/backend/src/services/certificate-template/certificate-template-validators.ts @@ -3,26 +3,40 @@ import z from "zod"; import { CharacterType, characterValidator } from "@app/lib/validator/validate-string"; -export const validateTemplateRegexField = z - .string() - .min(1) - .max(100) - .refine( - (val) => - characterValidator([ - CharacterType.AlphaNumeric, - CharacterType.Spaces, // (space) - CharacterType.Asterisk, // * - CharacterType.At, // @ - CharacterType.Hyphen, // - - CharacterType.Period, // . - CharacterType.Backslash // \ - ])(val), - { - message: "Invalid pattern: only alphanumeric characters, spaces, *, ., @, -, and \\ are allowed." - } - ) - // we ensure that the inputted pattern is computationally safe by limiting star height to 1 - .refine((v) => safe(v), { - message: "Unsafe REGEX pattern" - }); +export const createTemplateFieldValidator = (options?: { + minLength?: number; + maxLength?: number; + allowedCharacters?: CharacterType[]; + customMessage?: string; +}) => { + const { + minLength = 1, + maxLength = 100, + allowedCharacters = [ + CharacterType.AlphaNumeric, + CharacterType.Spaces, // (space) + CharacterType.Asterisk, // * + CharacterType.At, // @ + CharacterType.Hyphen, // - + CharacterType.Period, // . + CharacterType.Backslash // \ + ], + customMessage = "Invalid pattern: only alphanumeric characters, spaces, *, ., @, -, and \\ are allowed." + } = options || {}; + + return ( + z + .string() + .min(minLength) + .max(maxLength) + .refine((val) => characterValidator(allowedCharacters)(val), { + message: customMessage + }) + // we ensure that the inputted pattern is computationally safe by limiting star height to 1 + .refine((v) => safe(v), { + message: "Unsafe REGEX pattern" + }) + ); +}; + +export const validateTemplateRegexField = createTemplateFieldValidator(); diff --git a/backend/src/services/certificate-v3/certificate-v3-service.test.ts b/backend/src/services/certificate-v3/certificate-v3-service.test.ts new file mode 100644 index 000000000..95f9a5077 --- /dev/null +++ b/backend/src/services/certificate-v3/certificate-v3-service.test.ts @@ -0,0 +1,1463 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { ForbiddenError } from "@casl/ability"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { ACMESANType, CertificateOrderStatus } from "@app/services/certificate/certificate-types"; +import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; +import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; +import { + CertExtendedKeyUsageType, + CertIncludeType, + CertKeyUsageType, + CertSubjectAttributeType +} from "@app/services/certificate-common/certificate-constants"; +import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; +import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; +import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; + +import { ActorType, AuthMethod } from "../auth/auth-type"; +import { certificateV3ServiceFactory, TCertificateV3ServiceFactory } from "./certificate-v3-service"; + +describe("CertificateV3Service", () => { + let service: TCertificateV3ServiceFactory; + + const mockCertificateDAL: Pick = { + findOne: vi.fn(), + updateById: vi.fn() + }; + + const mockCertificateAuthorityDAL: Pick = { + findByIdWithAssociatedCa: vi.fn() + }; + + const mockCertificateProfileDAL: Pick = { + findByIdWithConfigs: vi.fn() + }; + + const mockCertificateTemplateV2Service: Pick< + TCertificateTemplateV2ServiceFactory, + "validateCertificateRequest" | "getTemplateV2ById" + > = { + validateCertificateRequest: vi.fn(), + getTemplateV2ById: vi.fn() + }; + + const mockInternalCaService: Pick = + { + signCertFromCa: vi.fn(), + issueCertFromCa: vi.fn() + }; + + const mockPermissionService: Pick = { + getProjectPermission: vi.fn().mockResolvedValue({ + permission: { + throwUnlessCan: vi.fn(), + can: vi.fn().mockReturnValue(true), + cannot: vi.fn().mockReturnValue(false), + relevantRuleFor: vi.fn(), + rules: [] + } + }) + }; + + const mockActor = { + actor: ActorType.USER, + actorId: "user-123", + actorAuthMethod: AuthMethod.EMAIL, + actorOrgId: "org-123" + }; + + beforeEach(() => { + // Reset all mocks before each test + vi.clearAllMocks(); + + // Mock ForbiddenError.from static method + vi.spyOn(ForbiddenError, "from").mockReturnValue({ + throwUnlessCan: vi.fn() + } as any); + + // Ensure the permission service mock is properly set up + (mockPermissionService.getProjectPermission as any).mockResolvedValue({ + permission: { + throwUnlessCan: vi.fn(), + can: vi.fn().mockReturnValue(true), + cannot: vi.fn().mockReturnValue(false), + relevantRuleFor: vi.fn(), + rules: [], + detectSubjectType: vi.fn() + } + }); + + service = certificateV3ServiceFactory({ + certificateDAL: mockCertificateDAL, + certificateAuthorityDAL: mockCertificateAuthorityDAL, + certificateProfileDAL: mockCertificateProfileDAL, + certificateTemplateV2Service: mockCertificateTemplateV2Service, + internalCaService: mockInternalCaService, + permissionService: mockPermissionService + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); // Ensure static method mocks are properly restored + }); + + describe("issueCertificateFromProfile", () => { + const mockCertificateRequest = { + commonName: "test.example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + validity: { ttl: "30d" }, + signatureAlgorithm: "RSA-SHA256", + keyAlgorithm: "RSA_2048" + }; + + it("should issue certificate successfully for API enrollment profile", async () => { + const profileId = "profile-123"; + const mockProfile = { + id: profileId, + projectId: "project-123", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + createdAt: new Date(), + updatedAt: new Date(), + slug: "test-profile", + description: "Test profile" + }; + + const mockCA = { + id: "ca-123", + projectId: "project-123", + externalCa: undefined, + internalCa: { + id: "internal-ca-123", + parentCaId: null, + type: "ROOT", + friendlyName: "Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "Test CA", + dn: "CN=Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "RSA_2048", + notBefore: undefined, + notAfter: undefined, + activeCaCertId: "cert-123", + caId: "ca-123" + }, + name: "Test CA", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true + }; + + const mockTemplate = { + id: "template-123", + name: "Test Template", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-123", + description: "Test template", + signatureAlgorithm: { defaultAlgorithm: "RSA-SHA256" }, + keyAlgorithm: { defaultKeyType: "RSA_2048" }, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.OPTIONAL, + value: ["example.com"] + } + ] + }; + + const mockCertificateResult = { + certificate: "cert", + certificateChain: "chain", + issuingCaCertificate: "issuing-ca", + privateKey: "key", + serialNumber: "123456", + ca: { + id: "ca-123", + projectId: "project-123", + name: "Test CA", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true, + externalCa: undefined, + internalCa: { + id: "internal-ca-123", + parentCaId: null, + type: "ROOT", + friendlyName: "Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "Test CA", + dn: "CN=Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "RSA_2048", + notBefore: null, + notAfter: null, + activeCaCertId: "cert-123", + caId: "ca-123" + } + } + }; + + const mockCertRecord = { + id: "cert-123", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-123", + commonName: "test.example.com", + friendlyName: "Test Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-123", + certificateTemplateId: "template-123", + revokedAt: null, + revokedBy: null + }; + + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); + vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate); + vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue(mockCertificateResult as any); + vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(mockCertRecord); + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCertRecord); + + const result = await service.issueCertificateFromProfile({ + profileId, + certificateRequest: mockCertificateRequest, + ...mockActor + }); + + expect(result).toHaveProperty("certificate"); + expect(result).toHaveProperty("issuingCaCertificate"); + expect(result).toHaveProperty("certificateChain"); + expect(result).toHaveProperty("privateKey"); + expect(result).toHaveProperty("serialNumber", "123456"); + expect(result).toHaveProperty("certificateId", "cert-123"); + }); + + it("should correctly map camelCase key usages to snake_case before validation", async () => { + const profileId = "profile-123"; + const mockProfile = { + id: profileId, + projectId: "project-123", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + createdAt: new Date(), + updatedAt: new Date(), + slug: "test-profile-camel", + description: "Test camelCase profile", + estConfigId: null, + apiConfigId: null + }; + + const mockCA = { + id: "ca-123", + projectId: "project-123", + externalCa: undefined, + internalCa: { + id: "internal-ca-123", + parentCaId: null, + type: "ROOT", + friendlyName: "Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "Test CA", + dn: "CN=Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "RSA_2048", + notBefore: undefined, + notAfter: undefined, + activeCaCertId: "cert-123", + caId: "ca-123" + }, + name: "Test CA", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true + }; + + const mockTemplate = { + id: "template-123", + name: "Test Template for CamelCase", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-123", + description: "Test template for camelCase validation", + signatureAlgorithm: { defaultAlgorithm: "RSA-SHA256" }, + keyAlgorithm: { defaultKeyType: "RSA_2048" }, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.OPTIONAL, + value: ["example.com"] + } + ], + subject: undefined, + sans: undefined, + keyUsages: undefined, + extendedKeyUsages: undefined, + algorithms: undefined, + validity: undefined + }; + + const mockCertRecord = { + id: "cert-123", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-123", + commonName: "test.example.com", + friendlyName: "Test Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-123", + certificateTemplateId: "template-123", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }; + + const camelCaseRequest = { + commonName: "test.example.com", + keyUsages: [ + CertKeyUsageType.DIGITAL_SIGNATURE, + CertKeyUsageType.NON_REPUDIATION, + CertKeyUsageType.KEY_AGREEMENT, + CertKeyUsageType.CRL_SIGN, + CertKeyUsageType.DECIPHER_ONLY + ], + extendedKeyUsages: [ + CertExtendedKeyUsageType.CLIENT_AUTH, + CertExtendedKeyUsageType.CODE_SIGNING, + CertExtendedKeyUsageType.OCSP_SIGNING, + CertExtendedKeyUsageType.SERVER_AUTH + ], + validity: { ttl: "10d" } + }; + + const mockCertificateResultWithCa = { + certificate: "cert", + certificateChain: "chain", + issuingCaCertificate: "issuing-ca", + privateKey: "key", + serialNumber: "123456", + ca: { + id: "ca-123", + projectId: "project-123", + name: "Test CA", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true, + externalCa: undefined, + internalCa: { + id: "internal-ca-123", + parentCaId: null, + type: "ROOT", + friendlyName: "Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "Test CA", + dn: "CN=Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "RSA_2048", + notBefore: null, + notAfter: null, + activeCaCertId: "cert-123", + caId: "ca-123" + } + } + }; + + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); + vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate); + vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue(mockCertificateResultWithCa as any); + vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(mockCertRecord); + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCertRecord); + + await service.issueCertificateFromProfile({ + profileId, + certificateRequest: camelCaseRequest, + ...mockActor + }); + + // Verify that the template validation service was called with mapped snake_case values + expect(mockCertificateTemplateV2Service.validateCertificateRequest).toHaveBeenCalledWith( + "template-123", + expect.objectContaining({ + keyUsages: [ + CertKeyUsageType.DIGITAL_SIGNATURE, + CertKeyUsageType.NON_REPUDIATION, + CertKeyUsageType.KEY_AGREEMENT, + CertKeyUsageType.CRL_SIGN, + CertKeyUsageType.DECIPHER_ONLY + ], + extendedKeyUsages: [ + CertExtendedKeyUsageType.CLIENT_AUTH, + CertExtendedKeyUsageType.CODE_SIGNING, + CertExtendedKeyUsageType.OCSP_SIGNING, + CertExtendedKeyUsageType.SERVER_AUTH + ] + }) + ); + }); + + it("should throw ForbiddenRequestError when profile is not configured for API enrollment", async () => { + const profileId = "profile-123"; + const mockProfile = { + id: profileId, + projectId: "project-123", + enrollmentType: EnrollmentType.EST, // Wrong enrollment type + caId: "ca-123", + certificateTemplateId: "template-123", + createdAt: new Date(), + updatedAt: new Date(), + slug: "test-profile-est", + description: "Test EST profile", + estConfigId: null, + apiConfigId: null + }; + + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + + await expect( + service.issueCertificateFromProfile({ + profileId, + certificateRequest: mockCertificateRequest, + ...mockActor + }) + ).rejects.toThrow(ForbiddenRequestError); + + await expect( + service.issueCertificateFromProfile({ + profileId, + certificateRequest: mockCertificateRequest, + ...mockActor + }) + ).rejects.toThrow("Profile is not configured for api enrollment"); + }); + + it("should throw NotFoundError when profile doesn't exist", async () => { + const profileId = "non-existent-profile"; + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(undefined); + + await expect( + service.issueCertificateFromProfile({ + profileId, + certificateRequest: mockCertificateRequest, + ...mockActor + }) + ).rejects.toThrow(NotFoundError); + }); + }); + + describe("signCertificateFromProfile", () => { + const mockCSR = "-----BEGIN CERTIFICATE REQUEST-----\nMIIC..."; + const mockValidity = { ttl: "30d" }; + + it("should sign certificate successfully for API enrollment profile", async () => { + const profileId = "profile-123"; + const mockProfile = { + id: profileId, + projectId: "project-123", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + createdAt: new Date(), + updatedAt: new Date(), + slug: "test-profile-sign", + description: "Test signing profile", + estConfigId: null, + apiConfigId: null + }; + + const mockCA = { + id: "ca-123", + projectId: "project-123", + externalCa: undefined, + internalCa: { + id: "internal-ca-123", + parentCaId: null, + type: "ROOT", + friendlyName: "Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "Test CA", + dn: "CN=Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "RSA_2048", + notBefore: undefined, + notAfter: undefined, + activeCaCertId: "cert-123", + caId: "ca-123" + }, + name: "Test CA", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true + }; + + const mockSignResult = { + certificate: "signed-cert", + certificateChain: "chain", + issuingCaCertificate: "issuing-ca", + serialNumber: "789012", + commonName: "test.example.com", + ca: { + id: "ca-123", + projectId: "project-123", + name: "Test CA", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true, + externalCa: undefined, + internalCa: { + id: "internal-ca-123", + parentCaId: null, + type: "ROOT", + friendlyName: "Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "Test CA", + dn: "CN=Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "RSA_2048", + notBefore: null, + notAfter: null, + activeCaCertId: "cert-123", + caId: "ca-123" + } + } + }; + + const mockCertRecord = { + id: "cert-456", + serialNumber: "789012", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-123", + commonName: "test.example.com", + friendlyName: "Test Signing Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-123", + certificateTemplateId: "template-123", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }; + + const mockTemplate = { + id: "template-123", + name: "Test Signing Template", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-123", + description: "Test template for signing certificates", + signatureAlgorithm: { defaultAlgorithm: "RSA-SHA256" }, + keyAlgorithm: { defaultKeyType: "RSA_2048" }, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.OPTIONAL, + value: ["example.com"] + } + ], + subject: undefined, + sans: undefined, + keyUsages: undefined, + extendedKeyUsages: undefined, + algorithms: undefined, + validity: undefined + }; + + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); + vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate); + vi.mocked(mockInternalCaService.signCertFromCa).mockResolvedValue(mockSignResult as any); + vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(mockCertRecord); + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCertRecord); + + const result = await service.signCertificateFromProfile({ + profileId, + csr: mockCSR, + validity: mockValidity, + ...mockActor + }); + + expect(result).toHaveProperty("certificate"); + expect(result).toHaveProperty("issuingCaCertificate"); + expect(result).toHaveProperty("certificateChain"); + expect(result).toHaveProperty("serialNumber", "789012"); + expect(result).toHaveProperty("certificateId", "cert-456"); + expect(result).not.toHaveProperty("privateKey"); + }); + + it("should throw ForbiddenRequestError when profile is not configured for API enrollment", async () => { + const profileId = "profile-123"; + const mockProfile = { + id: profileId, + projectId: "project-123", + enrollmentType: EnrollmentType.EST, // Wrong enrollment type + caId: "ca-123", + certificateTemplateId: "template-123", + createdAt: new Date(), + updatedAt: new Date(), + slug: "test-profile-est-sign", + description: "Test EST signing profile", + estConfigId: null, + apiConfigId: null + }; + + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + + await expect( + service.signCertificateFromProfile({ + profileId, + csr: mockCSR, + validity: mockValidity, + ...mockActor + }) + ).rejects.toThrow(ForbiddenRequestError); + + await expect( + service.signCertificateFromProfile({ + profileId, + csr: mockCSR, + validity: mockValidity, + ...mockActor + }) + ).rejects.toThrow("Profile is not configured for api enrollment"); + }); + }); + + describe("orderCertificateFromProfile", () => { + const mockCertificateOrder = { + altNames: [{ type: ACMESANType.DNS, value: "example.com" }], + validity: { ttl: "30d" }, + commonName: "example.com", + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH], + signatureAlgorithm: "RSA-SHA256", + keyAlgorithm: "RSA_2048" + }; + + it("should create order successfully for API enrollment profile", async () => { + const profileId = "profile-123"; + const mockProfile = { + id: profileId, + projectId: "project-123", + enrollmentType: EnrollmentType.API, + caId: "ca-123", + certificateTemplateId: "template-123", + createdAt: new Date(), + updatedAt: new Date(), + slug: "test-profile-order", + description: "Test order profile", + estConfigId: null, + apiConfigId: null + }; + + const mockCA = { + id: "ca-123", + projectId: "project-123", + externalCa: undefined, + internalCa: { + id: "internal-ca-123", + parentCaId: null, + type: "ROOT", + friendlyName: "Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "Test CA", + dn: "CN=Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "RSA_2048", + notBefore: undefined, + notAfter: undefined, + activeCaCertId: "cert-123", + caId: "ca-123" + }, + name: "Test CA", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true + }; + + const mockTemplate = { + id: "template-123", + name: "Test Order Template", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-123", + description: "Test template for ordering certificates", + signatureAlgorithm: { defaultAlgorithm: "RSA-SHA256" }, + keyAlgorithm: { defaultKeyType: "RSA_2048" }, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.OPTIONAL, + value: ["example.com"] + } + ], + subject: undefined, + sans: undefined, + keyUsages: undefined, + extendedKeyUsages: undefined, + algorithms: undefined, + validity: undefined + }; + + const mockCertificateResult = { + certificate: "cert", + certificateChain: "chain", + issuingCaCertificate: "issuing-ca", + privateKey: "key", + serialNumber: "123456", + ca: { + id: "ca-123", + projectId: "project-123", + name: "Test CA", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true, + externalCa: undefined, + internalCa: { + id: "internal-ca-123", + parentCaId: null, + type: "ROOT", + friendlyName: "Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "Test CA", + dn: "CN=Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "RSA_2048", + notBefore: null, + notAfter: null, + activeCaCertId: "cert-123", + caId: "ca-123" + } + } + }; + + const mockCertRecord = { + id: "cert-123", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-123", + commonName: "example.com", + friendlyName: "Test Order Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-123", + certificateTemplateId: "template-123", + revokedAt: null, + altNames: JSON.stringify([{ type: "DNS", value: "example.com" }]), + caCertId: null, + keyUsages: ["DIGITAL_SIGNATURE"], + extendedKeyUsages: ["SERVER_AUTH"], + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }; + + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); + vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate); + vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue(mockCertificateResult as any); + vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(mockCertRecord); + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCertRecord); + + const result = await service.orderCertificateFromProfile({ + profileId, + certificateOrder: mockCertificateOrder, + ...mockActor + }); + + expect(result).toHaveProperty("orderId"); + expect(result).toHaveProperty("status", "valid"); + expect(result).toHaveProperty("certificate"); + expect(result.subjectAlternativeNames).toHaveLength(1); + expect(result.subjectAlternativeNames[0]).toEqual({ + type: ACMESANType.DNS, + value: "example.com", + status: CertificateOrderStatus.VALID + }); + }); + + it("should throw ForbiddenRequestError when profile is not configured for API enrollment", async () => { + const profileId = "profile-123"; + const mockProfile = { + id: profileId, + projectId: "project-123", + enrollmentType: EnrollmentType.EST, // Wrong enrollment type + caId: "ca-123", + certificateTemplateId: "template-123", + createdAt: new Date(), + updatedAt: new Date(), + slug: "test-profile-est-order", + description: "Test EST order profile", + estConfigId: null, + apiConfigId: null + }; + + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + + await expect( + service.orderCertificateFromProfile({ + profileId, + certificateOrder: mockCertificateOrder, + ...mockActor + }) + ).rejects.toThrow(ForbiddenRequestError); + + await expect( + service.orderCertificateFromProfile({ + profileId, + certificateOrder: mockCertificateOrder, + ...mockActor + }) + ).rejects.toThrow("Profile is not configured for api enrollment"); + }); + }); + + describe("algorithm compatibility (integration tests)", () => { + const mockProfile = { + id: "profile-1", + slug: "test-profile", + projectId: "project-1", + caId: "ca-1", + certificateTemplateId: "template-1", + enrollmentType: EnrollmentType.API, + createdAt: new Date(), + updatedAt: new Date(), + description: "Test profile for algorithm compatibility", + estConfigId: null, + apiConfigId: null + }; + + const mockCertificateRequest = { + commonName: "test.example.com", + validity: { ttl: "30d" }, + keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE], + extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH] + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should successfully process RSA algorithms with RSA CAs", async () => { + const rsaCa = { + id: "ca-1", + projectId: "project-1", + status: "active", + name: "RSA Test CA", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true, + externalCa: undefined, + internalCa: { + id: "internal-ca-1", + parentCaId: null, + type: "ROOT", + friendlyName: "RSA Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "RSA Test CA", + dn: "CN=RSA Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "RSA_2048", + notBefore: undefined, + notAfter: undefined, + activeCaCertId: "cert-123", + caId: "ca-1" + } + }; + + const rsaTemplate = { + id: "template-1", + name: "RSA Template", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + description: "RSA template for algorithm compatibility", + signatureAlgorithm: { + allowedAlgorithms: ["SHA256-RSA", "SHA384-RSA"] + }, + keyAlgorithm: null, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.OPTIONAL, + value: ["example.com"] + } + ], + subject: undefined, + sans: undefined, + keyUsages: undefined, + extendedKeyUsages: undefined, + algorithms: undefined, + validity: undefined + }; + + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(rsaCa); + vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(rsaTemplate); + vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue({ + certificate: "cert", + certificateChain: "chain", + issuingCaCertificate: "ca-cert", + privateKey: "key", + serialNumber: "123456", + ca: rsaCa as any + }); + vi.mocked(mockCertificateDAL.findOne).mockResolvedValue({ + id: "cert-1", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + commonName: "test.example.com", + friendlyName: "Test Algorithm Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-1", + certificateTemplateId: "template-1", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }); + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue({ + id: "cert-1", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + commonName: "test.example.com", + friendlyName: "Test Algorithm Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-1", + certificateTemplateId: "template-1", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }); + + // Should not throw - RSA CA is compatible with RSA signature algorithms + await expect( + service.issueCertificateFromProfile({ + profileId: mockProfile.id, + certificateRequest: { + ...mockCertificateRequest, + signatureAlgorithm: "RSA-SHA256" + }, + ...mockActor + }) + ).resolves.toBeDefined(); + }); + + it("should successfully process ECDSA algorithms with EC CAs", async () => { + const ecCa = { + id: "ca-1", + projectId: "project-1", + status: "active", + name: "EC Test CA", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true, + externalCa: undefined, + internalCa: { + id: "internal-ca-1", + parentCaId: null, + type: "ROOT", + friendlyName: "EC Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "EC Test CA", + dn: "CN=EC Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "EC_prime256v1", + notBefore: undefined, + notAfter: undefined, + activeCaCertId: "cert-123", + caId: "ca-1" + } + }; + + const ecdsaTemplate = { + id: "template-1", + name: "ECDSA Template", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + description: "ECDSA template for algorithm compatibility", + signatureAlgorithm: { + allowedAlgorithms: ["SHA256-ECDSA", "SHA384-ECDSA"] + }, + keyAlgorithm: null, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.OPTIONAL, + value: ["example.com"] + } + ], + subject: undefined, + sans: undefined, + keyUsages: undefined, + extendedKeyUsages: undefined, + algorithms: undefined, + validity: undefined + }; + + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(ecCa); + vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(ecdsaTemplate); + vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue({ + certificate: "cert", + certificateChain: "chain", + issuingCaCertificate: "ca-cert", + privateKey: "key", + serialNumber: "123456", + ca: ecCa as any + }); + vi.mocked(mockCertificateDAL.findOne).mockResolvedValue({ + id: "cert-1", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + commonName: "test.example.com", + friendlyName: "Test Algorithm Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-1", + certificateTemplateId: "template-1", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }); + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue({ + id: "cert-1", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + commonName: "test.example.com", + friendlyName: "Test Algorithm Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-1", + certificateTemplateId: "template-1", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }); + + // Should not throw - EC CA is compatible with ECDSA signature algorithms + await expect( + service.issueCertificateFromProfile({ + profileId: mockProfile.id, + certificateRequest: { + ...mockCertificateRequest, + signatureAlgorithm: "ECDSA-SHA256" + }, + ...mockActor + }) + ).resolves.toBeDefined(); + }); + + it("should dynamically support new RSA key sizes", async () => { + const rsa8192Ca = { + id: "ca-1", + projectId: "project-1", + status: "active", + name: "RSA 8192 Test CA", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true, + externalCa: undefined, + internalCa: { + id: "internal-ca-1", + parentCaId: null, + type: "ROOT", + friendlyName: "RSA 8192 Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "RSA 8192 Test CA", + dn: "CN=RSA 8192 Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "RSA_8192", // Future RSA key size + notBefore: undefined, + notAfter: undefined, + activeCaCertId: "cert-123", + caId: "ca-1" + } + }; + + const rsaTemplate = { + id: "template-1", + name: "RSA 8192 Template", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + description: "RSA 8192 template for future key sizes", + signatureAlgorithm: { + allowedAlgorithms: ["SHA256-RSA"] + }, + keyAlgorithm: null, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.OPTIONAL, + value: ["example.com"] + } + ], + subject: undefined, + sans: undefined, + keyUsages: undefined, + extendedKeyUsages: undefined, + algorithms: undefined, + validity: undefined + }; + + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(rsa8192Ca); + vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(rsaTemplate); + vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue({ + certificate: "cert", + certificateChain: "chain", + issuingCaCertificate: "ca-cert", + privateKey: "key", + serialNumber: "123456", + ca: rsa8192Ca as any + }); + vi.mocked(mockCertificateDAL.findOne).mockResolvedValue({ + id: "cert-1", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + commonName: "test.example.com", + friendlyName: "Test Algorithm Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-1", + certificateTemplateId: "template-1", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }); + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue({ + id: "cert-1", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + commonName: "test.example.com", + friendlyName: "Test Algorithm Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-1", + certificateTemplateId: "template-1", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }); + + // Should not throw - dynamic check supports new RSA key sizes + await expect( + service.issueCertificateFromProfile({ + profileId: mockProfile.id, + certificateRequest: { + ...mockCertificateRequest, + signatureAlgorithm: "RSA-SHA256" + }, + ...mockActor + }) + ).resolves.toBeDefined(); + }); + + it("should dynamically support new EC curve types", async () => { + const newEcCa = { + id: "ca-1", + projectId: "project-1", + status: "active", + name: "EC secp521r1 Test CA", + createdAt: new Date(), + updatedAt: new Date(), + enableDirectIssuance: true, + externalCa: undefined, + internalCa: { + id: "internal-ca-1", + parentCaId: null, + type: "ROOT", + friendlyName: "EC secp521r1 Test CA", + organization: "Test Org", + ou: "Test OU", + country: "US", + province: "CA", + locality: "SF", + commonName: "EC secp521r1 Test CA", + dn: "CN=EC secp521r1 Test CA", + serialNumber: "123", + maxPathLength: null, + keyAlgorithm: "EC_secp521r1", // Future EC curve + notBefore: undefined, + notAfter: undefined, + activeCaCertId: "cert-123", + caId: "ca-1" + } + }; + + const ecdsaTemplate = { + id: "template-1", + name: "ECDSA secp521r1 Template", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + description: "ECDSA secp521r1 template for future EC curves", + signatureAlgorithm: { + allowedAlgorithms: ["SHA384-ECDSA"] + }, + keyAlgorithm: null, + attributes: [ + { + type: CertSubjectAttributeType.COMMON_NAME, + include: CertIncludeType.OPTIONAL, + value: ["example.com"] + } + ], + subject: undefined, + sans: undefined, + keyUsages: undefined, + extendedKeyUsages: undefined, + algorithms: undefined, + validity: undefined + }; + + vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); + vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(newEcCa); + vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({ + isValid: true, + errors: [], + warnings: [] + }); + vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(ecdsaTemplate); + vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue({ + certificate: "cert", + certificateChain: "chain", + issuingCaCertificate: "ca-cert", + privateKey: "key", + serialNumber: "123456", + ca: newEcCa as any + }); + vi.mocked(mockCertificateDAL.findOne).mockResolvedValue({ + id: "cert-1", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + commonName: "test.example.com", + friendlyName: "Test Algorithm Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-1", + certificateTemplateId: "template-1", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }); + vi.mocked(mockCertificateDAL.updateById).mockResolvedValue({ + id: "cert-1", + serialNumber: "123456", + status: "ACTIVE", + createdAt: new Date(), + updatedAt: new Date(), + projectId: "project-1", + commonName: "test.example.com", + friendlyName: "Test Algorithm Cert", + notBefore: new Date(), + notAfter: new Date(), + caId: "ca-1", + certificateTemplateId: "template-1", + revokedAt: null, + altNames: null, + caCertId: null, + keyUsages: null, + extendedKeyUsages: null, + revocationReason: null, + pkiSubscriberId: null, + profileId: null + }); + + // Should not throw - dynamic check supports new EC curves + await expect( + service.issueCertificateFromProfile({ + profileId: mockProfile.id, + certificateRequest: { + ...mockCertificateRequest, + signatureAlgorithm: "ECDSA-SHA384" + }, + ...mockActor + }) + ).resolves.toBeDefined(); + }); + }); +}); diff --git a/backend/src/services/certificate-v3/certificate-v3-service.ts b/backend/src/services/certificate-v3/certificate-v3-service.ts new file mode 100644 index 000000000..1c11b0a00 --- /dev/null +++ b/backend/src/services/certificate-v3/certificate-v3-service.ts @@ -0,0 +1,487 @@ +import { ForbiddenError } from "@casl/ability"; +import { randomUUID } from "crypto"; + +import { ActionProjectType } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { + ProjectPermissionCertificateProfileActions, + ProjectPermissionSub +} from "@app/ee/services/permission/project-permission"; +import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; +import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; +import { + CertificateOrderStatus, + CertKeyAlgorithm, + CertSignatureAlgorithm +} from "@app/services/certificate/certificate-types"; +import { + TCertificateAuthorityDALFactory, + TCertificateAuthorityWithAssociatedCa +} from "@app/services/certificate-authority/certificate-authority-dal"; +import { CaType } from "@app/services/certificate-authority/certificate-authority-enums"; +import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; +import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; +import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; +import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; + +import { CertSubjectAlternativeNameType } from "../certificate-common/certificate-constants"; +import { + bufferToString, + buildCertificateSubjectFromTemplate, + buildSubjectAlternativeNamesFromTemplate, + convertExtendedKeyUsageArrayToLegacy, + convertKeyUsageArrayToLegacy, + mapEnumsForValidation, + normalizeDateForApi +} from "../certificate-common/certificate-utils"; +import { + TCertificateFromProfileResponse, + TCertificateOrderResponse, + TIssueCertificateFromProfileDTO, + TOrderCertificateFromProfileDTO, + TSignCertificateFromProfileDTO +} from "./certificate-v3-types"; + +type TCertificateV3ServiceFactoryDep = { + certificateDAL: Pick; + certificateAuthorityDAL: Pick; + certificateProfileDAL: Pick; + certificateTemplateV2Service: Pick< + TCertificateTemplateV2ServiceFactory, + "validateCertificateRequest" | "getTemplateV2ById" + >; + internalCaService: Pick; + permissionService: Pick; +}; + +export type TCertificateV3ServiceFactory = ReturnType; + +const validateProfileAndPermissions = async ( + profileId: string, + actor: ActorType, + actorId: string, + actorAuthMethod: ActorAuthMethod, + actorOrgId: string, + certificateProfileDAL: Pick, + permissionService: Pick, + requiredEnrollmentType: EnrollmentType +) => { + const profile = await certificateProfileDAL.findByIdWithConfigs(profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + if (profile.enrollmentType !== requiredEnrollmentType) { + throw new ForbiddenRequestError({ + message: `Profile is not configured for ${requiredEnrollmentType} enrollment` + }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: profile.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.IssueCert, + ProjectPermissionSub.CertificateProfiles + ); + + return profile; +}; + +const validateCaSupport = (ca: TCertificateAuthorityWithAssociatedCa, operation: string) => { + const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL; + if (caType !== CaType.INTERNAL) { + throw new BadRequestError({ message: `Only internal CAs support ${operation}` }); + } + return caType; +}; + +const validateAlgorithmCompatibility = ( + ca: TCertificateAuthorityWithAssociatedCa, + template: { + algorithms?: { + signature?: string[]; + }; + } +) => { + if (!template.algorithms?.signature || template.algorithms.signature.length === 0) { + return; + } + + const caKeyAlgorithm = ca.internalCa?.keyAlgorithm; + if (!caKeyAlgorithm) { + throw new BadRequestError({ message: "CA key algorithm not found" }); + } + + const compatibleAlgorithms = + template.algorithms?.signature?.filter((sigAlg: string) => { + const parts = sigAlg.split("-"); + if (parts.length === 0) { + return false; + } + const keyType = parts[parts.length - 1]; + + if (caKeyAlgorithm.startsWith("RSA")) { + return keyType === "RSA"; + } + + if (caKeyAlgorithm.startsWith("EC")) { + return keyType === "ECDSA"; + } + + return false; + }) || []; + + if (compatibleAlgorithms.length === 0) { + throw new BadRequestError({ + message: `Template signature algorithms (${template.algorithms?.signature?.join(", ") || "none"}) are not compatible with CA key algorithm (${caKeyAlgorithm})` + }); + } +}; + +const extractCertificateFromBuffer = (certData: Buffer | { rawData: Buffer } | string): string => { + if (typeof certData === "string") return certData; + if (Buffer.isBuffer(certData)) return bufferToString(certData); + if (certData && typeof certData === "object" && "rawData" in certData && Buffer.isBuffer(certData.rawData)) { + return bufferToString(certData.rawData); + } + return bufferToString(certData as unknown as Buffer); +}; + +export const certificateV3ServiceFactory = ({ + certificateDAL, + certificateAuthorityDAL, + certificateProfileDAL, + certificateTemplateV2Service, + internalCaService, + permissionService +}: TCertificateV3ServiceFactoryDep) => { + const issueCertificateFromProfile = async ({ + profileId, + certificateRequest, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TIssueCertificateFromProfileDTO): Promise => { + const profile = await validateProfileAndPermissions( + profileId, + actor, + actorId, + actorAuthMethod, + actorOrgId, + certificateProfileDAL, + permissionService, + EnrollmentType.API + ); + + if (certificateRequest.commonName && Array.isArray(certificateRequest.commonName)) { + throw new BadRequestError({ + message: "Common Name must be a single value, not an array" + }); + } + + const mappedCertificateRequest = mapEnumsForValidation({ + ...certificateRequest, + subjectAlternativeNames: certificateRequest.altNames + }); + + const template = await certificateTemplateV2Service.getTemplateV2ById({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + templateId: profile.certificateTemplateId + }); + if (!template) { + throw new NotFoundError({ message: "Certificate template not found for this profile" }); + } + + const validationResult = await certificateTemplateV2Service.validateCertificateRequest( + profile.certificateTemplateId, + mappedCertificateRequest + ); + + if (!validationResult.isValid) { + throw new BadRequestError({ + message: `Certificate request validation failed: ${validationResult.errors.join(", ")}` + }); + } + + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId); + if (!ca) { + throw new NotFoundError({ message: "Certificate Authority not found" }); + } + + validateCaSupport(ca, "direct certificate issuance"); + + if (!actorAuthMethod) { + throw new BadRequestError({ message: "Authentication method is required for certificate issuance" }); + } + + validateAlgorithmCompatibility(ca, template); + + const effectiveSignatureAlgorithm = certificateRequest.signatureAlgorithm as CertSignatureAlgorithm | undefined; + const effectiveKeyAlgorithm = certificateRequest.keyAlgorithm as CertKeyAlgorithm | undefined; + + if (template.algorithms?.keyAlgorithm && !effectiveKeyAlgorithm) { + throw new BadRequestError({ + message: "Key algorithm is required by template policy but not provided in request" + }); + } + + if (template.algorithms?.signature && !effectiveSignatureAlgorithm) { + throw new BadRequestError({ + message: "Signature algorithm is required by template policy but not provided in request" + }); + } + + const certificateSubject = buildCertificateSubjectFromTemplate(certificateRequest, template.subject); + const subjectAlternativeNames = buildSubjectAlternativeNamesFromTemplate( + { subjectAlternativeNames: certificateRequest.altNames }, + template.sans + ); + + const { certificate, certificateChain, issuingCaCertificate, privateKey, serialNumber } = + await internalCaService.issueCertFromCa({ + caId: ca.id, + friendlyName: certificateSubject.common_name || "Certificate", + commonName: certificateSubject.common_name || "", + altNames: subjectAlternativeNames, + ttl: certificateRequest.validity.ttl, + keyUsages: convertKeyUsageArrayToLegacy(certificateRequest.keyUsages) || [], + extendedKeyUsages: convertExtendedKeyUsageArrayToLegacy(certificateRequest.extendedKeyUsages) || [], + notBefore: normalizeDateForApi(certificateRequest.notBefore), + notAfter: normalizeDateForApi(certificateRequest.notAfter), + signatureAlgorithm: effectiveSignatureAlgorithm, + keyAlgorithm: effectiveKeyAlgorithm, + actor, + actorId, + actorAuthMethod, + actorOrgId, + isFromProfile: true + }); + + const cert = await certificateDAL.findOne({ serialNumber, caId: ca.id }); + if (!cert) { + throw new NotFoundError({ message: "Certificate was issued but could not be found in database" }); + } + + await certificateDAL.updateById(cert.id, { profileId }); + + return { + certificate: bufferToString(certificate), + issuingCaCertificate: bufferToString(issuingCaCertificate), + certificateChain: bufferToString(certificateChain), + privateKey: bufferToString(privateKey), + serialNumber, + certificateId: cert.id, + projectId: profile.projectId, + profileName: profile.slug + }; + }; + + const signCertificateFromProfile = async ({ + profileId, + csr, + validity, + notBefore, + notAfter, + signatureAlgorithm, + keyAlgorithm, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TSignCertificateFromProfileDTO): Promise> => { + const profile = await validateProfileAndPermissions( + profileId, + actor, + actorId, + actorAuthMethod, + actorOrgId, + certificateProfileDAL, + permissionService, + EnrollmentType.API + ); + + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId); + if (!ca) { + throw new NotFoundError({ message: "Certificate Authority not found" }); + } + + validateCaSupport(ca, "CSR signing"); + + if (!actorAuthMethod) { + throw new BadRequestError({ message: "Authentication method is required for certificate signing" }); + } + + const template = await certificateTemplateV2Service.getTemplateV2ById({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + templateId: profile.certificateTemplateId + }); + + if (!template) { + throw new NotFoundError({ message: "Certificate template not found for this profile" }); + } + + validateAlgorithmCompatibility(ca, template); + + const effectiveSignatureAlgorithm = signatureAlgorithm; + const effectiveKeyAlgorithm = keyAlgorithm; + + if (template.algorithms?.keyAlgorithm && !effectiveKeyAlgorithm) { + throw new BadRequestError({ + message: "Key algorithm is required by template policy but not provided in request" + }); + } + + if (template.algorithms?.signature && !effectiveSignatureAlgorithm) { + throw new BadRequestError({ + message: "Signature algorithm is required by template policy but not provided in request" + }); + } + + const { certificate, certificateChain, issuingCaCertificate, serialNumber } = + await internalCaService.signCertFromCa({ + isInternal: true, + caId: ca.id, + csr, + ttl: validity.ttl, + altNames: undefined, + notBefore: normalizeDateForApi(notBefore), + notAfter: normalizeDateForApi(notAfter), + signatureAlgorithm: effectiveSignatureAlgorithm, + keyAlgorithm: effectiveKeyAlgorithm, + isFromProfile: true + }); + + const cert = await certificateDAL.findOne({ serialNumber, caId: ca.id }); + if (!cert) { + throw new NotFoundError({ message: "Certificate was signed but could not be found in database" }); + } + + await certificateDAL.updateById(cert.id, { profileId }); + + const certificateString = extractCertificateFromBuffer(certificate as unknown as Buffer); + const certificateChainString = extractCertificateFromBuffer(certificateChain as unknown as Buffer); + + return { + certificate: certificateString, + issuingCaCertificate: extractCertificateFromBuffer(issuingCaCertificate as unknown as Buffer), + certificateChain: certificateChainString, + serialNumber, + certificateId: cert.id, + projectId: profile.projectId, + profileName: profile.slug + }; + }; + + const orderCertificateFromProfile = async ({ + profileId, + certificateOrder, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TOrderCertificateFromProfileDTO): Promise => { + const profile = await validateProfileAndPermissions( + profileId, + actor, + actorId, + actorAuthMethod, + actorOrgId, + certificateProfileDAL, + permissionService, + EnrollmentType.API + ); + + const certificateRequest = { + commonName: certificateOrder.commonName, + keyUsages: certificateOrder.keyUsages, + extendedKeyUsages: certificateOrder.extendedKeyUsages, + subjectAlternativeNames: certificateOrder.altNames.map((san) => ({ + type: san.type === "dns" ? CertSubjectAlternativeNameType.DNS_NAME : CertSubjectAlternativeNameType.IP_ADDRESS, + value: san.value + })), + validity: certificateOrder.validity, + notBefore: certificateOrder.notBefore, + notAfter: certificateOrder.notAfter, + signatureAlgorithm: certificateOrder.signatureAlgorithm, + keyAlgorithm: certificateOrder.keyAlgorithm + }; + + const mappedCertificateRequest = mapEnumsForValidation(certificateRequest); + const validationResult = await certificateTemplateV2Service.validateCertificateRequest( + profile.certificateTemplateId, + mappedCertificateRequest + ); + + if (!validationResult.isValid) { + throw new BadRequestError({ + message: `Certificate order validation failed: ${validationResult.errors.join(", ")}` + }); + } + + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId); + if (!ca) { + throw new NotFoundError({ message: "Certificate Authority not found" }); + } + + const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL; + + if (caType === CaType.INTERNAL) { + const certificateResult = await issueCertificateFromProfile({ + profileId, + certificateRequest, + actor, + actorId, + actorAuthMethod, + actorOrgId + }); + + const orderId = randomUUID(); + + return { + orderId, + status: CertificateOrderStatus.VALID, + subjectAlternativeNames: certificateOrder.altNames.map((san) => ({ + type: san.type, + value: san.value, + status: CertificateOrderStatus.VALID + })), + authorizations: [], + finalize: `/api/v3/certificates/orders/${orderId}/completed`, + certificate: certificateResult.certificate, + projectId: certificateResult.projectId, + profileName: certificateResult.profileName + }; + } + + if (caType === CaType.ACME) { + throw new BadRequestError({ + message: "ACME certificate ordering via profiles is not yet implemented." + }); + } + + throw new BadRequestError({ + message: `Certificate ordering is not supported for CA type: ${caType}` + }); + }; + + return { + issueCertificateFromProfile, + signCertificateFromProfile, + orderCertificateFromProfile + }; +}; diff --git a/backend/src/services/certificate-v3/certificate-v3-types.ts b/backend/src/services/certificate-v3/certificate-v3-types.ts new file mode 100644 index 000000000..b54042c5c --- /dev/null +++ b/backend/src/services/certificate-v3/certificate-v3-types.ts @@ -0,0 +1,99 @@ +import { TProjectPermission } from "@app/lib/types"; + +import { ACMESANType, CertificateOrderStatus } from "../certificate/certificate-types"; +import { + CertExtendedKeyUsageType, + CertKeyUsageType, + CertSubjectAlternativeNameType +} from "../certificate-common/certificate-constants"; + +export type TIssueCertificateFromProfileDTO = { + profileId: string; + certificateRequest: { + commonName?: string; + keyUsages?: CertKeyUsageType[]; + extendedKeyUsages?: CertExtendedKeyUsageType[]; + altNames?: Array<{ + type: CertSubjectAlternativeNameType; + value: string; + }>; + validity: { + ttl: string; + }; + notBefore?: Date; + notAfter?: Date; + signatureAlgorithm?: string; + keyAlgorithm?: string; + }; +} & Omit; + +export type TSignCertificateFromProfileDTO = { + profileId: string; + csr: string; + validity: { + ttl: string; + }; + notBefore?: Date; + notAfter?: Date; + signatureAlgorithm?: string; + keyAlgorithm?: string; +} & Omit; + +export type TOrderCertificateFromProfileDTO = { + profileId: string; + certificateOrder: { + altNames: Array<{ + type: ACMESANType; + value: string; + }>; + validity: { + ttl: string; + }; + commonName?: string; + keyUsages?: CertKeyUsageType[]; + extendedKeyUsages?: CertExtendedKeyUsageType[]; + notBefore?: Date; + notAfter?: Date; + signatureAlgorithm?: string; + keyAlgorithm?: string; + }; +} & Omit; + +export type TCertificateFromProfileResponse = { + certificate: string; + issuingCaCertificate: string; + certificateChain: string; + privateKey?: string; + serialNumber: string; + certificateId: string; + projectId: string; + profileName: string; +}; + +export type TCertificateOrderResponse = { + orderId: string; + status: CertificateOrderStatus; + subjectAlternativeNames: Array<{ + type: ACMESANType; + value: string; + status: CertificateOrderStatus; + }>; + authorizations: Array<{ + identifier: { + type: ACMESANType; + value: string; + }; + status: CertificateOrderStatus; + expires?: string; + challenges: Array<{ + type: string; + status: CertificateOrderStatus; + url: string; + token: string; + }>; + }>; + finalize: string; + certificate?: string; + projectId: string; + profileName: string; +}; diff --git a/backend/src/services/certificate/certificate-types.ts b/backend/src/services/certificate/certificate-types.ts index 527df2a39..9da331be8 100644 --- a/backend/src/services/certificate/certificate-types.ts +++ b/backend/src/services/certificate/certificate-types.ts @@ -8,14 +8,26 @@ import { TCertificateSecretDALFactory } from "./certificate-secret-dal"; export enum CertStatus { ACTIVE = "active", + EXPIRED = "expired", REVOKED = "revoked" } export enum CertKeyAlgorithm { RSA_2048 = "RSA_2048", + RSA_3072 = "RSA_3072", RSA_4096 = "RSA_4096", ECDSA_P256 = "EC_prime256v1", - ECDSA_P384 = "EC_secp384r1" + ECDSA_P384 = "EC_secp384r1", + ECDSA_P521 = "EC_secp521r1" +} + +export enum CertSignatureAlgorithm { + RSA_SHA256 = "RSA-SHA256", + RSA_SHA384 = "RSA-SHA384", + RSA_SHA512 = "RSA-SHA512", + ECDSA_SHA256 = "ECDSA-SHA256", + ECDSA_SHA384 = "ECDSA-SHA384", + ECDSA_SHA512 = "ECDSA-SHA512" } export enum CertKeyUsage { @@ -39,6 +51,11 @@ export enum CertExtendedKeyUsage { OCSP_SIGNING = "ocspSigning" } +export enum CertSignatureType { + RSA = "RSA", + ECDSA = "ECDSA" +} + export const CertExtendedKeyUsageOIDToName: Record = { [x509.ExtendedKeyUsage.clientAuth]: CertExtendedKeyUsage.CLIENT_AUTH, [x509.ExtendedKeyUsage.serverAuth]: CertExtendedKeyUsage.SERVER_AUTH, @@ -105,13 +122,48 @@ export type TGetCertificateCredentialsDTO = { kmsService: Pick; }; +export enum CertSubjectAlternativeNameType { + DNS_NAME = "dns_name", + IP_ADDRESS = "ip_address", + EMAIL = "email", + URI = "uri" +} + export enum TAltNameType { EMAIL = "email", DNS = "dns", IP = "ip", URL = "url" } + +export const mapLegacyAltNameType = (legacyType: TAltNameType): CertSubjectAlternativeNameType => { + switch (legacyType) { + case TAltNameType.EMAIL: + return CertSubjectAlternativeNameType.EMAIL; + case TAltNameType.DNS: + return CertSubjectAlternativeNameType.DNS_NAME; + case TAltNameType.IP: + return CertSubjectAlternativeNameType.IP_ADDRESS; + case TAltNameType.URL: + return CertSubjectAlternativeNameType.URI; + default: + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + throw new Error(`Unknown legacy alt name type: ${legacyType}`); + } +}; export type TAltNameMapping = { type: TAltNameType; value: string; }; + +export enum ACMESANType { + DNS = "dns", + IP = "ip" +} + +export enum CertificateOrderStatus { + PENDING = "pending", + PROCESSING = "processing", + VALID = "valid", + INVALID = "invalid" +} diff --git a/backend/src/services/enrollment-config/api-enrollment-config-dal.ts b/backend/src/services/enrollment-config/api-enrollment-config-dal.ts new file mode 100644 index 000000000..1edfdae6c --- /dev/null +++ b/backend/src/services/enrollment-config/api-enrollment-config-dal.ts @@ -0,0 +1,118 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +import { TApiEnrollmentConfigInsert, TApiEnrollmentConfigUpdate } from "./enrollment-config-types"; + +export type TApiEnrollmentConfigDALFactory = ReturnType; + +export const apiEnrollmentConfigDALFactory = (db: TDbClient) => { + const apiEnrollmentConfigOrm = ormify(db, TableName.PkiApiEnrollmentConfig); + + const create = async (data: TApiEnrollmentConfigInsert, tx?: Knex) => { + try { + const [apiConfig] = await (tx || db)(TableName.PkiApiEnrollmentConfig).insert(data).returning("*"); + + return apiConfig; + } catch (error) { + throw new DatabaseError({ error, name: "Create API enrollment config" }); + } + }; + + const updateById = async (id: string, data: TApiEnrollmentConfigUpdate, tx?: Knex) => { + try { + const [apiConfig] = await (tx || db)(TableName.PkiApiEnrollmentConfig).where({ id }).update(data).returning("*"); + + return apiConfig; + } catch (error) { + throw new DatabaseError({ error, name: "Update API enrollment config" }); + } + }; + + const deleteById = async (id: string, tx?: Knex) => { + try { + const [apiConfig] = await (tx || db)(TableName.PkiApiEnrollmentConfig).where({ id }).del().returning("*"); + + return apiConfig; + } catch (error) { + throw new DatabaseError({ error, name: "Delete API enrollment config" }); + } + }; + + const findById = async (id: string, tx?: Knex) => { + try { + const apiConfig = await (tx || db)(TableName.PkiApiEnrollmentConfig).where({ id }).first(); + + return apiConfig; + } catch (error) { + throw new DatabaseError({ error, name: "Find API enrollment config by id" }); + } + }; + + const findProfilesForAutoRenewal = async (renewalThresholdDays: number = 30, projectId?: string, tx?: Knex) => { + try { + let query = (tx || db)(TableName.PkiCertificateProfile) + .join( + TableName.PkiApiEnrollmentConfig, + `${TableName.PkiCertificateProfile}.apiConfigId`, + `${TableName.PkiApiEnrollmentConfig}.id` + ) + .where(`${TableName.PkiApiEnrollmentConfig}.autoRenew`, true); + + if (projectId) { + query = query.where(`${TableName.PkiCertificateProfile}.projectId`, projectId); + } + + const profiles = await query + .where((qb) => { + void qb + .whereNull(`${TableName.PkiApiEnrollmentConfig}.autoRenewDays`) + .orWhere(`${TableName.PkiApiEnrollmentConfig}.autoRenewDays`, "<=", renewalThresholdDays); + }) + .select((tx || db).ref("id").withSchema(TableName.PkiCertificateProfile)) + .select((tx || db).ref("name").withSchema(TableName.PkiCertificateProfile)) + .select((tx || db).ref("projectId").withSchema(TableName.PkiCertificateProfile)) + .select((tx || db).ref("autoRenewDays").withSchema(TableName.PkiCertificateProfile)); + + return profiles as Array<{ id: string; name: string; projectId: string; autoRenewDays?: number }>; + } catch (error) { + throw new DatabaseError({ error, name: "Find profiles for auto renewal" }); + } + }; + + const isConfigInUse = async (configId: string, tx?: Knex) => { + try { + const doc = await (tx || db)(TableName.PkiCertificateProfile).where({ apiConfigId: configId }).count("*").first(); + + if (!doc || typeof doc !== "object") { + return 0; + } + + const countValue = (doc as Record).count; + if (typeof countValue === "number") { + return countValue; + } + if (typeof countValue === "string") { + const parsed = parseInt(countValue, 10); + return Number.isNaN(parsed) ? 0 : parsed; + } + + return 0; + } catch (error) { + throw new DatabaseError({ error, name: "Check if API enrollment config is in use" }); + } + }; + + return { + ...apiEnrollmentConfigOrm, + create, + updateById, + deleteById, + findById, + findProfilesForAutoRenewal, + isConfigInUse + }; +}; diff --git a/backend/src/services/enrollment-config/enrollment-config-types.ts b/backend/src/services/enrollment-config/enrollment-config-types.ts new file mode 100644 index 000000000..516f135fd --- /dev/null +++ b/backend/src/services/enrollment-config/enrollment-config-types.ts @@ -0,0 +1,29 @@ +import { + TPkiApiEnrollmentConfigs, + TPkiApiEnrollmentConfigsInsert, + TPkiApiEnrollmentConfigsUpdate +} from "@app/db/schemas/pki-api-enrollment-configs"; +import { + TPkiEstEnrollmentConfigs, + TPkiEstEnrollmentConfigsInsert, + TPkiEstEnrollmentConfigsUpdate +} from "@app/db/schemas/pki-est-enrollment-configs"; + +export type TEstEnrollmentConfig = TPkiEstEnrollmentConfigs; +export type TEstEnrollmentConfigInsert = TPkiEstEnrollmentConfigsInsert; +export type TEstEnrollmentConfigUpdate = TPkiEstEnrollmentConfigsUpdate; + +export type TApiEnrollmentConfig = TPkiApiEnrollmentConfigs; +export type TApiEnrollmentConfigInsert = TPkiApiEnrollmentConfigsInsert; +export type TApiEnrollmentConfigUpdate = TPkiApiEnrollmentConfigsUpdate; + +export interface TEstConfigData { + disableBootstrapCaValidation: boolean; + passphrase: string; + caChain?: string; +} + +export interface TApiConfigData { + autoRenew: boolean; + autoRenewDays?: number; +} diff --git a/backend/src/services/enrollment-config/est-enrollment-config-dal.ts b/backend/src/services/enrollment-config/est-enrollment-config-dal.ts new file mode 100644 index 000000000..b7520f290 --- /dev/null +++ b/backend/src/services/enrollment-config/est-enrollment-config-dal.ts @@ -0,0 +1,61 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +import { TEstEnrollmentConfigInsert, TEstEnrollmentConfigUpdate } from "./enrollment-config-types"; + +export type TEstEnrollmentConfigDALFactory = ReturnType; + +export const estEnrollmentConfigDALFactory = (db: TDbClient) => { + const estEnrollmentConfigOrm = ormify(db, TableName.PkiEstEnrollmentConfig); + + const create = async (data: TEstEnrollmentConfigInsert, tx?: Knex) => { + try { + const result = await (tx || db)(TableName.PkiEstEnrollmentConfig).insert(data).returning("*"); + const [estConfig] = result; + + if (!estConfig) { + throw new Error("Failed to create EST enrollment config"); + } + + return estConfig; + } catch (error) { + throw new DatabaseError({ error, name: "Create EST enrollment config" }); + } + }; + + const updateById = async (id: string, data: TEstEnrollmentConfigUpdate, tx?: Knex) => { + try { + const result = await (tx || db)(TableName.PkiEstEnrollmentConfig).where({ id }).update(data).returning("*"); + const [estConfig] = result; + + if (!estConfig) { + return null; + } + + return estConfig; + } catch (error) { + throw new DatabaseError({ error, name: "Update EST enrollment config" }); + } + }; + + const findById = async (id: string, tx?: Knex) => { + try { + const estConfig = await (tx || db)(TableName.PkiEstEnrollmentConfig).where({ id }).first(); + + return estConfig || null; + } catch (error) { + throw new DatabaseError({ error, name: "Find EST enrollment config by id" }); + } + }; + + return { + ...estEnrollmentConfigOrm, + create, + updateById, + findById + }; +}; diff --git a/backend/src/services/external-migration/external-migration-service.ts b/backend/src/services/external-migration/external-migration-service.ts index 14885b1d6..4192ffcd5 100644 --- a/backend/src/services/external-migration/external-migration-service.ts +++ b/backend/src/services/external-migration/external-migration-service.ts @@ -1,9 +1,33 @@ import { OrgMembershipRole } from "@app/db/schemas"; +import { + AuditLogInfo, + EventType, + SecretApprovalEvent, + TAuditLogServiceFactory +} from "@app/ee/services/audit-log/audit-log-types"; import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { crypto } from "@app/lib/crypto/cryptography"; -import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors"; +import { DatabaseErrorCode } from "@app/lib/error-codes"; +import { BadRequestError, DatabaseError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { OrgServiceActor } from "@app/lib/types"; +import { AppConnection } from "../app-connection/app-connection-enums"; +import { decryptAppConnectionCredentials } from "../app-connection/app-connection-fns"; +import { TAppConnectionServiceFactory } from "../app-connection/app-connection-service"; +import { + getHCVaultAuthMounts, + getHCVaultKubernetesAuthRoles, + getHCVaultSecretsForPath, + HCVaultAuthType, + listHCVaultMounts, + listHCVaultPolicies, + listHCVaultSecretPaths, + THCVaultConnection +} from "../app-connection/hc-vault"; +import { TKmsServiceFactory } from "../kms/kms-service"; +import { TSecretServiceFactory } from "../secret/secret-service"; +import { SecretProtectionType } from "../secret/secret-types"; import { TUserDALFactory } from "../user/user-dal"; import { decryptEnvKeyDataFn, @@ -15,16 +39,29 @@ import { TExternalMigrationQueueFactory } from "./external-migration-queue"; import { ExternalMigrationProviders, ExternalPlatforms, + TCreateVaultExternalMigrationDTO, + TDeleteVaultExternalMigrationDTO, THasCustomVaultMigrationDTO, TImportEnvKeyDataDTO, - TImportVaultDataDTO + TImportVaultDataDTO, + TUpdateVaultExternalMigrationDTO, + VaultImportStatus } from "./external-migration-types"; +import { TVaultExternalMigrationConfigDALFactory } from "./vault-external-migration-config-dal"; type TExternalMigrationServiceFactoryDep = { permissionService: TPermissionServiceFactory; + secretService: TSecretServiceFactory; + auditLogService: Pick; externalMigrationQueue: TExternalMigrationQueueFactory; + appConnectionService: Pick; + vaultExternalMigrationConfigDAL: Pick< + TVaultExternalMigrationConfigDALFactory, + "create" | "findOne" | "transaction" | "find" | "updateById" | "deleteById" | "findById" + >; userDAL: Pick; gatewayService: Pick; + kmsService: Pick; }; export type TExternalMigrationServiceFactory = ReturnType; @@ -33,7 +70,12 @@ export const externalMigrationServiceFactory = ({ permissionService, externalMigrationQueue, userDAL, - gatewayService + gatewayService, + secretService, + auditLogService, + appConnectionService, + vaultExternalMigrationConfigDAL, + kmsService }: TExternalMigrationServiceFactoryDep) => { const importEnvKeyData = async ({ decryptionKey, @@ -171,9 +213,554 @@ export const externalMigrationServiceFactory = ({ return actorOrgId in vaultMigrationTransformMappings; }; + const validateVaultExternalMigrationConnection = async ({ + connection, + namespace + }: { + connection: THCVaultConnection; + namespace: string; + }) => { + // Allow root namespace access when no namespace is configured on the connection + const isRootAccess = namespace === "root" || namespace === "/"; + const hasNoNamespace = connection.credentials.namespace === undefined; + + if (hasNoNamespace && isRootAccess) { + // Skip validation for root access with no configured namespace + } else if (connection.credentials.namespace !== namespace) { + throw new BadRequestError({ message: "Namespace value does not match the namespace of the connection" }); + } + + try { + await listHCVaultPolicies(namespace, connection, gatewayService); + await getHCVaultAuthMounts(namespace, HCVaultAuthType.Kubernetes, connection, gatewayService); + + const mounts = await listHCVaultMounts(connection, gatewayService); + const sampleKvMount = mounts.find((mount) => mount.type === "kv"); + if (sampleKvMount) { + await listHCVaultSecretPaths(namespace, connection, gatewayService, sampleKvMount.path); + } + } catch (error) { + throw new BadRequestError({ + message: `Failed to establish namespace confiugration. ${error instanceof Error ? error.message : "Unknown error"}` + }); + } + }; + + const createVaultExternalMigration = async ({ namespace, connectionId, actor }: TCreateVaultExternalMigrationDTO) => { + const { hasRole } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + if (!hasRole(OrgMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "Only admins can configure vault external migration" }); + } + + const connection = await appConnectionService.connectAppConnectionById( + AppConnection.HCVault, + connectionId, + actor + ); + + await validateVaultExternalMigrationConnection({ + connection, + namespace + }); + + try { + const config = await vaultExternalMigrationConfigDAL.create({ + namespace, + connectionId, + orgId: actor.orgId + }); + + return config; + } catch (error) { + if ( + error instanceof DatabaseError && + (error.error as { code: string })?.code === DatabaseErrorCode.UniqueViolation + ) { + throw new BadRequestError({ + message: `Vault external migration already exists for this namespace` + }); + } + + throw error; + } + }; + + const updateVaultExternalMigration = async ({ + id, + namespace, + connectionId, + actor + }: TUpdateVaultExternalMigrationDTO) => { + const { hasRole } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + if (!hasRole(OrgMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "Only admins can update vault external migration" }); + } + + if (connectionId) { + const connection = await appConnectionService.connectAppConnectionById( + AppConnection.HCVault, + connectionId, + actor + ); + + await validateVaultExternalMigrationConnection({ + connection, + namespace + }); + } + + const config = await vaultExternalMigrationConfigDAL.updateById(id, { + namespace, + connectionId + }); + + return config; + }; + + const getVaultExternalMigrationConfigs = async ({ actor }: { actor: OrgServiceActor }) => { + const { hasRole } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + if (!hasRole(OrgMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "Only admins can view vault external migration configs" }); + } + + const configs = await vaultExternalMigrationConfigDAL.find({ + orgId: actor.orgId + }); + + return configs; + }; + + const getVaultNamespaces = async ({ actor }: { actor: OrgServiceActor }) => { + const { hasRole } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + if (!hasRole(OrgMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "Only admins can view vault namespaces" }); + } + + // Get all configured namespaces for this org + const vaultConfigs = await vaultExternalMigrationConfigDAL.find({ + orgId: actor.orgId + }); + + // Return the configured namespaces as an array of objects with id and name + // where both id and name are the namespace path + const namespaces = vaultConfigs.map((config) => ({ + id: config.namespace, + name: config.namespace + })); + + return namespaces; + }; + + const getVaultPolicies = async ({ actor, namespace }: { actor: OrgServiceActor; namespace: string }) => { + const { hasRole } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + if (!hasRole(OrgMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "Only admins can view vault policies" }); + } + + const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({ + orgId: actor.orgId, + namespace + }); + + if (!vaultConfig) { + throw new NotFoundError({ message: "Vault migration config not found for this namespace" }); + } + + if (!vaultConfig.connection) { + throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" }); + } + + const credentials = await decryptAppConnectionCredentials({ + orgId: vaultConfig.orgId, + encryptedCredentials: vaultConfig.connection.encryptedCredentials, + kmsService, + projectId: null + }); + + const connection = { + ...vaultConfig.connection, + credentials + } as THCVaultConnection; + + const policies = await listHCVaultPolicies(namespace, connection, gatewayService); + return policies; + }; + + const getVaultMounts = async ({ actor, namespace }: { actor: OrgServiceActor; namespace: string }) => { + const { hasRole } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + if (!hasRole(OrgMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "Only admins can view vault mounts" }); + } + + const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({ + orgId: actor.orgId, + namespace + }); + + if (!vaultConfig) { + throw new NotFoundError({ message: "Vault migration config not found for this namespace" }); + } + + if (!vaultConfig.connection) { + throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" }); + } + + const credentials = await decryptAppConnectionCredentials({ + orgId: vaultConfig.orgId, + encryptedCredentials: vaultConfig.connection.encryptedCredentials, + kmsService, + projectId: null + }); + + const connection = { + ...vaultConfig.connection, + credentials + } as THCVaultConnection; + + const mounts = await listHCVaultMounts(connection, gatewayService, namespace); + return mounts; + }; + + const getVaultSecretPaths = async ({ + actor, + namespace, + mountPath + }: { + actor: OrgServiceActor; + namespace: string; + mountPath: string; + }) => { + const { hasRole } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + if (!hasRole(OrgMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "Only admins can view vault secret paths" }); + } + + const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({ + orgId: actor.orgId, + namespace + }); + + if (!vaultConfig) { + throw new NotFoundError({ message: "Vault migration config not found for this namespace" }); + } + + if (!vaultConfig.connection) { + throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" }); + } + + const credentials = await decryptAppConnectionCredentials({ + orgId: vaultConfig.orgId, + encryptedCredentials: vaultConfig.connection.encryptedCredentials, + kmsService, + projectId: null + }); + + const connection = { + ...vaultConfig.connection, + credentials + } as THCVaultConnection; + + const secretPaths = await listHCVaultSecretPaths(namespace, connection, gatewayService, mountPath); + + return secretPaths; + }; + + const importVaultSecrets = async ({ + actor, + projectId, + environment, + secretPath, + vaultNamespace, + vaultSecretPath, + auditLogInfo + }: { + actor: OrgServiceActor; + projectId: string; + environment: string; + secretPath: string; + vaultNamespace: string; + vaultSecretPath: string; + auditLogInfo: AuditLogInfo; + }) => { + const { hasRole } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + if (!hasRole(OrgMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "Only admins can import vault secrets" }); + } + + const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({ + orgId: actor.orgId, + namespace: vaultNamespace + }); + + if (!vaultConfig) { + throw new NotFoundError({ message: "Vault migration config not found for this namespace" }); + } + + if (!vaultConfig.connection) { + throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" }); + } + + const credentials = await decryptAppConnectionCredentials({ + orgId: vaultConfig.orgId, + encryptedCredentials: vaultConfig.connection.encryptedCredentials, + kmsService, + projectId: null + }); + + const connection = { + ...vaultConfig.connection, + credentials + } as THCVaultConnection; + + const vaultSecrets = await getHCVaultSecretsForPath(vaultNamespace, vaultSecretPath, connection, gatewayService); + + try { + const secretOperation = await secretService.createManySecretsRaw({ + actorId: actor.id, + actor: actor.type, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + secretPath, + environment, + projectId, + secrets: Object.entries(vaultSecrets).map(([secretKey, secretValue]) => ({ + secretKey, + secretValue + })) + }); + + if (secretOperation.type === SecretProtectionType.Approval) { + await auditLogService.createAuditLog({ + projectId, + ...auditLogInfo, + event: { + type: EventType.SECRET_APPROVAL_REQUEST, + metadata: { + committedBy: secretOperation.approval.committerUserId, + secretApprovalRequestId: secretOperation.approval.id, + secretApprovalRequestSlug: secretOperation.approval.slug, + secretPath, + environment, + secrets: Object.entries(vaultSecrets).map(([secretKey]) => ({ + secretKey + })), + eventType: SecretApprovalEvent.CreateMany + } + } + }); + + return { status: VaultImportStatus.ApprovalRequired }; + } + + return { status: VaultImportStatus.Imported }; + } catch (error) { + throw new BadRequestError({ + message: `Failed to import Vault secrets. ${error instanceof Error ? error.message : "Unknown error"}` + }); + } + }; + + const deleteVaultExternalMigration = async ({ id, actor }: TDeleteVaultExternalMigrationDTO) => { + const { hasRole } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + if (!hasRole(OrgMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "Only admins can delete vault external migration configs" }); + } + + const config = await vaultExternalMigrationConfigDAL.findById(id); + + if (!config) { + throw new NotFoundError({ message: "Vault migration config not found" }); + } + + if (config.orgId !== actor.orgId) { + throw new ForbiddenRequestError({ message: "Config does not belong to this organization" }); + } + + const deletedConfig = await vaultExternalMigrationConfigDAL.deleteById(id); + + return deletedConfig; + }; + + const getVaultAuthMounts = async ({ + actor, + namespace, + authType + }: { + actor: OrgServiceActor; + namespace: string; + authType?: string; + }) => { + const { hasRole } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + if (!hasRole(OrgMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "Only admins can view vault auth mounts" }); + } + + const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({ + orgId: actor.orgId, + namespace + }); + + if (!vaultConfig) { + throw new NotFoundError({ message: "Vault migration config not found for this namespace" }); + } + + if (!vaultConfig.connection) { + throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" }); + } + + const credentials = await decryptAppConnectionCredentials({ + orgId: vaultConfig.orgId, + encryptedCredentials: vaultConfig.connection.encryptedCredentials, + kmsService, + projectId: null + }); + + const connection = { + ...vaultConfig.connection, + credentials + } as THCVaultConnection; + + const authMounts = await getHCVaultAuthMounts(namespace, authType as HCVaultAuthType, connection, gatewayService); + + return authMounts; + }; + + const getVaultKubernetesAuthRoles = async ({ + actor, + namespace, + mountPath + }: { + actor: OrgServiceActor; + namespace: string; + mountPath: string; + }) => { + const { hasRole } = await permissionService.getOrgPermission( + actor.type, + actor.id, + actor.orgId, + actor.authMethod, + actor.orgId + ); + + if (!hasRole(OrgMembershipRole.Admin)) { + throw new ForbiddenRequestError({ message: "Only admins can view vault Kubernetes auth roles" }); + } + + const vaultConfig = await vaultExternalMigrationConfigDAL.findOne({ + orgId: actor.orgId, + namespace + }); + + if (!vaultConfig) { + throw new NotFoundError({ message: "Vault migration config not found for this namespace" }); + } + + if (!vaultConfig.connection) { + throw new BadRequestError({ message: "Vault migration connection is not configured for this namespace" }); + } + + const credentials = await decryptAppConnectionCredentials({ + orgId: vaultConfig.orgId, + encryptedCredentials: vaultConfig.connection.encryptedCredentials, + kmsService, + projectId: null + }); + + const connection = { + ...vaultConfig.connection, + credentials + } as THCVaultConnection; + + // Get roles for the specified mount path only + const roles = await getHCVaultKubernetesAuthRoles(namespace, mountPath, connection, gatewayService); + + return roles; + }; + return { importEnvKeyData, importVaultData, - hasCustomVaultMigration + hasCustomVaultMigration, + createVaultExternalMigration, + getVaultExternalMigrationConfigs, + updateVaultExternalMigration, + deleteVaultExternalMigration, + getVaultNamespaces, + getVaultPolicies, + getVaultMounts, + getVaultAuthMounts, + getVaultSecretPaths, + importVaultSecrets, + getVaultKubernetesAuthRoles }; }; diff --git a/backend/src/services/external-migration/external-migration-types.ts b/backend/src/services/external-migration/external-migration-types.ts index 804444172..49e5bfa88 100644 --- a/backend/src/services/external-migration/external-migration-types.ts +++ b/backend/src/services/external-migration/external-migration-types.ts @@ -1,4 +1,4 @@ -import { TOrgPermission } from "@app/lib/types"; +import { OrgServiceActor, TOrgPermission } from "@app/lib/types"; import { ActorAuthMethod, ActorType } from "../auth/auth-type"; @@ -121,3 +121,26 @@ export enum ExternalMigrationProviders { Vault = "vault", EnvKey = "env-key" } + +export enum VaultImportStatus { + Imported = "imported", + ApprovalRequired = "approval-required" +} + +export type TCreateVaultExternalMigrationDTO = { + namespace: string; + connectionId: string; + actor: OrgServiceActor; +}; + +export type TUpdateVaultExternalMigrationDTO = { + id: string; + namespace: string; + connectionId: string | null; + actor: OrgServiceActor; +}; + +export type TDeleteVaultExternalMigrationDTO = { + id: string; + actor: OrgServiceActor; +}; diff --git a/backend/src/services/external-migration/vault-external-migration-config-dal.ts b/backend/src/services/external-migration/vault-external-migration-config-dal.ts new file mode 100644 index 000000000..34ff8757a --- /dev/null +++ b/backend/src/services/external-migration/vault-external-migration-config-dal.ts @@ -0,0 +1,67 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { buildFindFilter, ormify, prependTableNameToFindFilter, selectAllTableCols } from "@app/lib/knex"; + +export type TVaultExternalMigrationConfigDALFactory = ReturnType; + +export const vaultExternalMigrationConfigDALFactory = (db: TDbClient) => { + const orm = ormify(db, TableName.VaultExternalMigrationConfig); + + const findOne = async (filter: { orgId: string; namespace: string }, tx?: Knex) => { + try { + const result = await (tx || db?.replicaNode?.() || db)(TableName.VaultExternalMigrationConfig) + .leftJoin( + TableName.AppConnection, + `${TableName.AppConnection}.id`, + `${TableName.VaultExternalMigrationConfig}.connectionId` + ) + /* eslint-disable @typescript-eslint/no-misused-promises */ + .where(buildFindFilter(prependTableNameToFindFilter(TableName.VaultExternalMigrationConfig, filter))) + .select(selectAllTableCols(TableName.VaultExternalMigrationConfig)) + .select( + db.ref("id").withSchema(TableName.AppConnection).as("appConnectionId"), + db.ref("name").withSchema(TableName.AppConnection).as("appConnectionName"), + db.ref("app").withSchema(TableName.AppConnection).as("appConnectionApp"), + db.ref("encryptedCredentials").withSchema(TableName.AppConnection).as("appConnectionEncryptedCredentials"), + db.ref("orgId").withSchema(TableName.AppConnection).as("appConnectionOrgId"), + db.ref("method").withSchema(TableName.AppConnection).as("appConnectionMethod"), + db.ref("description").withSchema(TableName.AppConnection).as("appConnectionDescription"), + db.ref("version").withSchema(TableName.AppConnection).as("appConnectionVersion"), + db.ref("gatewayId").withSchema(TableName.AppConnection).as("appConnectionGatewayId"), + db.ref("projectId").withSchema(TableName.AppConnection).as("appConnectionProjectId"), + db.ref("createdAt").withSchema(TableName.AppConnection).as("appConnectionCreatedAt"), + db.ref("updatedAt").withSchema(TableName.AppConnection).as("appConnectionUpdatedAt") + ) + .first(); + + if (!result) return undefined; + + return { + ...result, + connection: result.appConnectionId + ? { + id: result.appConnectionId, + name: result.appConnectionName, + app: result.appConnectionApp, + encryptedCredentials: result.appConnectionEncryptedCredentials, + orgId: result.appConnectionOrgId, + method: result.appConnectionMethod, + description: result.appConnectionDescription, + version: result.appConnectionVersion, + gatewayId: result.appConnectionGatewayId, + projectId: result.appConnectionProjectId, + createdAt: result.appConnectionCreatedAt, + updatedAt: result.appConnectionUpdatedAt + } + : undefined + }; + } catch (error) { + throw new DatabaseError({ error, name: "Find one" }); + } + }; + + return { ...orm, findOne }; +}; diff --git a/backend/src/services/kms/kms-service.ts b/backend/src/services/kms/kms-service.ts index 5b35f2f63..b665df4ed 100644 --- a/backend/src/services/kms/kms-service.ts +++ b/backend/src/services/kms/kms-service.ts @@ -743,23 +743,27 @@ export const kmsServiceFactory = ({ if (!project.kmsSecretManagerEncryptedDataKey) { const lock = await keyStore .acquireLock([KeyStorePrefixes.KmsProjectDataKeyCreation, projectId], 3000, { retryCount: 0 }) - .catch(() => null); + .catch((err) => { + logger.error(err, "KMS. Failed to acquire lock."); + return null; + }); try { if (!lock) { await keyStore.waitTillReady({ key: `${KeyStorePrefixes.WaitUntilReadyKmsProjectDataKeyCreation}${projectId}`, keyCheckCb: (val) => val === "true", - waitingCb: () => logger.debug("KMS. Waiting for secret manager data key to be created"), + waitingCb: () => logger.info("KMS. Waiting for secret manager data key to be created"), delay: 500 }); project = await projectDAL.findById(projectId, trx); } else { + logger.info(`KMS. Generating KMS key for project ${projectId}`); const projectDataKey = await (trx || projectDAL).transaction(async (tx) => { project = await projectDAL.findById(projectId, tx); if (project.kmsSecretManagerEncryptedDataKey) { - return; + return project.kmsSecretManagerEncryptedDataKey; } const dataKey = crypto.randomBytes(32); diff --git a/backend/src/services/kms/kms-types.ts b/backend/src/services/kms/kms-types.ts index eaf0adbde..11badd7a5 100644 --- a/backend/src/services/kms/kms-types.ts +++ b/backend/src/services/kms/kms-types.ts @@ -99,5 +99,5 @@ export type TImportKeyMaterialDTO = { projectId: string; orgId: string; keyUsage: KmsKeyUsage; - kmipMetadata?: Record; + kmipMetadata?: Record | null; }; diff --git a/backend/src/services/secret-folder/secret-folder-service.ts b/backend/src/services/secret-folder/secret-folder-service.ts index 8e0892bd1..c216ea3a8 100644 --- a/backend/src/services/secret-folder/secret-folder-service.ts +++ b/backend/src/services/secret-folder/secret-folder-service.ts @@ -5,6 +5,7 @@ import path from "path"; import { v4 as uuidv4, validate as uuidValidate } from "uuid"; import { ActionProjectType, TProjectEnvironments, TSecretFolders, TSecretFoldersInsert } from "@app/db/schemas"; +import { TDynamicSecretDALFactory } from "@app/ee/services/dynamic-secret/dynamic-secret-dal"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { TSecretApprovalPolicyServiceFactory } from "@app/ee/services/secret-approval-policy/secret-approval-policy-service"; @@ -12,6 +13,7 @@ import { TSecretSnapshotServiceFactory } from "@app/ee/services/secret-snapshot/ import { PgSqlLock } from "@app/keystore/keystore"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { OrderByDirection, OrgServiceActor } from "@app/lib/types"; +import { ActorType } from "@app/services/auth/auth-type"; import { buildFolderPath } from "@app/services/secret-folder/secret-folder-fns"; import { @@ -47,7 +49,11 @@ type TSecretFolderServiceFactoryDep = { folderCommitService: Pick; projectDAL: Pick; secretApprovalPolicyService: Pick; - secretV2BridgeDAL: Pick; + secretV2BridgeDAL: Pick< + TSecretV2BridgeDALFactory, + "findByFolderIds" | "invalidateSecretCacheByProjectId" | "findOne" + >; + dynamicSecretDAL: Pick; }; export type TSecretFolderServiceFactory = ReturnType; @@ -61,7 +67,8 @@ export const secretFolderServiceFactory = ({ folderCommitService, projectDAL, secretApprovalPolicyService, - secretV2BridgeDAL + secretV2BridgeDAL, + dynamicSecretDAL }: TSecretFolderServiceFactoryDep) => { const createFolder = async ({ projectId, @@ -111,24 +118,11 @@ export const secretFolderServiceFactory = ({ }); } - // check if the exact folder already exists - const existingFolder = await folderDAL.findOne( - { - envId: env.id, - parentId: parentFolder.id, - name, - isReserved: false - }, - tx - ); - - if (existingFolder) { - return existingFolder; - } - // exact folder case if (parentFolder.path === pathWithFolder) { - return parentFolder; + throw new BadRequestError({ + message: `Folder with name '${name}' already exists in path '${secretPath}'` + }); } let currentParentId = parentFolder.id; @@ -534,13 +528,19 @@ export const secretFolderServiceFactory = ({ projectId, env, parentId, - idOrName + idOrName, + actor }: { projectId: string; env: TProjectEnvironments; parentId: string; idOrName: string; + actor: ActorType; }) => { + if (actor === ActorType.IDENTITY) { + return; + } + let targetFolder = await folderDAL .findOne({ envId: env.id, @@ -638,7 +638,8 @@ export const secretFolderServiceFactory = ({ actorAuthMethod, environment, path: secretPath, - idOrName + idOrName, + forceDelete = false }: TDeleteFolderDTO) => { const { permission } = await permissionService.getProjectPermission({ actor, @@ -664,7 +665,7 @@ export const secretFolderServiceFactory = ({ message: `Folder with path '${secretPath}' in environment with slug '${environment}' not found` }); - await $checkFolderPolicy({ projectId, env, parentId: parentFolder.id, idOrName }); + await $checkFolderPolicy({ projectId, env, parentId: parentFolder.id, idOrName, actor }); let folderToDelete = await folderDAL .findOne({ @@ -690,6 +691,22 @@ export const secretFolderServiceFactory = ({ throw new NotFoundError({ message: `Folder with ID '${idOrName}' not found` }); } + // Check if folder contains resources (secrets, dynamic secrets, subfolders) + if (!forceDelete) { + const error = new BadRequestError({ + message: `Cannot delete folder "${folderToDelete.name}" because it contains resources. Use forceDelete=true to delete it forcefully.`, + name: "deleteFolder" + }); + const secretV2 = await secretV2BridgeDAL.findOne({ folderId: folderToDelete.id }).catch(() => null); + if (secretV2) throw error; + + const dynamicSecret = await dynamicSecretDAL.findOne({ folderId: folderToDelete.id }).catch(() => null); + if (dynamicSecret) throw error; + + const subfolder = await folderDAL.findOne({ parentId: folderToDelete.id }).catch(() => null); + if (subfolder) throw error; + } + const [doc] = await folderDAL.delete( { envId: env.id, @@ -1315,7 +1332,7 @@ export const secretFolderServiceFactory = ({ }); } - await $checkFolderPolicy({ projectId, env, parentId: parentFolder.id, idOrName }); + await $checkFolderPolicy({ projectId, env, parentId: parentFolder.id, idOrName, actor }); let folderToDelete = await folderDAL .findOne({ diff --git a/backend/src/services/secret-folder/secret-folder-types.ts b/backend/src/services/secret-folder/secret-folder-types.ts index eed815da5..da8be52a0 100644 --- a/backend/src/services/secret-folder/secret-folder-types.ts +++ b/backend/src/services/secret-folder/secret-folder-types.ts @@ -37,6 +37,7 @@ export type TDeleteFolderDTO = { environment: string; path: string; idOrName: string; + forceDelete?: boolean; } & TProjectPermission; export type TGetFolderDTO = { diff --git a/backend/src/services/secret-import/secret-import-fns.ts b/backend/src/services/secret-import/secret-import-fns.ts index c739c5ad2..42c135c88 100644 --- a/backend/src/services/secret-import/secret-import-fns.ts +++ b/backend/src/services/secret-import/secret-import-fns.ts @@ -258,10 +258,6 @@ export const fnSecretsV2FromImports = async ({ })[]; }[] = [{ secretImports: rootSecretImports, depth: 0, parentImportedSecrets: [] }]; - const processedSecretImports = await processReservedImports(rootSecretImports, secretImportDAL); - - stack[0] = { secretImports: processedSecretImports, depth: 0, parentImportedSecrets: [] }; - const processedImports: TSecretImportSecretsV2[] = []; while (stack.length) { @@ -299,7 +295,9 @@ export const fnSecretsV2FromImports = async ({ ); const importedSecretsGroupByFolderId = groupBy(importedSecrets, (i) => i.folderId); - sanitizedImports.forEach(({ importPath, importEnv }) => { + const processedBatchImports = await processReservedImports(sanitizedImports, secretImportDAL); + + processedBatchImports.forEach(({ importPath, importEnv }) => { cyclicDetector.add(getImportUniqKey(importEnv.slug, importPath)); }); // now we need to check recursively deeper imports made inside other imports @@ -308,7 +306,7 @@ export const fnSecretsV2FromImports = async ({ const deeperImportsGroupByFolderId = groupBy(deeperImports, (i) => i.folderId); const isFirstIteration = !processedImports.length; - sanitizedImports.forEach(({ importPath, importEnv, id, folderId }, i) => { + processedBatchImports.forEach(({ importPath, importEnv, id, folderId }, i) => { const sourceImportFolder = importedFolderGroupBySourceImport[`${importEnv.id}-${importPath}`]?.[0]; const secretsWithDuplicate = (importedSecretsGroupByFolderId?.[importedFolders?.[i]?.id as string] || []) .filter((item) => diff --git a/backend/src/services/secret-sync/laravel-forge/index.ts b/backend/src/services/secret-sync/laravel-forge/index.ts new file mode 100644 index 000000000..f38e2a06b --- /dev/null +++ b/backend/src/services/secret-sync/laravel-forge/index.ts @@ -0,0 +1,4 @@ +export * from "./laravel-forge-sync-constants"; +export * from "./laravel-forge-sync-fns"; +export * from "./laravel-forge-sync-schemas"; +export * from "./laravel-forge-sync-types"; diff --git a/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-constants.ts b/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-constants.ts new file mode 100644 index 000000000..7bde155ec --- /dev/null +++ b/backend/src/services/secret-sync/laravel-forge/laravel-forge-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 LARAVEL_FORGE_SYNC_LIST_OPTION: TSecretSyncListItem = { + name: "Laravel Forge", + destination: SecretSync.LaravelForge, + connection: AppConnection.LaravelForge, + canImportSecrets: true +}; diff --git a/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-fns.ts b/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-fns.ts new file mode 100644 index 000000000..bbb0f354e --- /dev/null +++ b/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-fns.ts @@ -0,0 +1,207 @@ +import { request } from "@app/lib/config/request"; +import { IntegrationUrls } from "@app/services/integration-auth/integration-list"; +import { matchesSchema } from "@app/services/secret-sync/secret-sync-fns"; +import { TSecretMap } from "@app/services/secret-sync/secret-sync-types"; + +import { + LaravelForgeSecret, + TGetLaravelForgeSecrets, + TLaravelForgeSecrets, + TLaravelForgeSyncWithCredentials +} from "./laravel-forge-sync-types"; + +const getLaravelForgeSecretsRaw = async ({ apiToken, orgSlug, serverId, siteId }: TGetLaravelForgeSecrets) => { + const { data } = await request.get( + `${IntegrationUrls.LARAVELFORGE_API_URL}/api/orgs/${orgSlug}/servers/${serverId}/sites/${siteId}/environment`, + { + headers: { + Authorization: `Bearer ${apiToken}`, + Accept: "application/json", + "Content-Type": "application/json" + } + } + ); + + return data.data.attributes.content; +}; + +const parseEnv = (str: string) => { + const lines = str.split("\n"); + const parsed: { key: string; value: string }[] = []; + + let i = 0; + while (i < lines.length) { + const trimmed = lines[i].trim(); + + // Skip empty lines and comments + if (trimmed === "" || trimmed.startsWith("#")) { + i += 1; + // eslint-disable-next-line no-continue + continue; + } + + if (trimmed.includes("=")) { + const equalIndex = trimmed.indexOf("="); + const key = trimmed.substring(0, equalIndex).trim(); + const valueRaw = trimmed.substring(equalIndex + 1).trim(); + + // Check if value starts with a quote + const startsWithDoubleQuote = valueRaw.startsWith('"'); + const startsWithSingleQuote = valueRaw.startsWith("'"); + + if (startsWithDoubleQuote || startsWithSingleQuote) { + const quoteChar = startsWithDoubleQuote ? '"' : "'"; + + const closingQuoteIndex = valueRaw.indexOf(quoteChar, 1); + + if (closingQuoteIndex !== -1) { + // Single-line quoted value + const value = valueRaw.slice(1, closingQuoteIndex); + parsed.push({ key, value }); + i += 1; + } else { + // Multiline quoted value - collect lines until closing quote + let value = valueRaw.slice(1); + i += 1; + + while (i < lines.length) { + const nextLine = lines[i]; + const closingIndex = nextLine.indexOf(quoteChar); + + if (closingIndex !== -1) { + value += `\n${nextLine.substring(0, closingIndex)}`; + parsed.push({ key, value }); + i += 1; + break; + } else { + value += `\n${nextLine}`; + i += 1; + } + } + } + } else { + // Unquoted value + parsed.push({ key, value: valueRaw }); + i += 1; + } + } else { + i += 1; + } + } + + return parsed; +}; + +const getLaravelForgeSecrets = async (secretSync: TLaravelForgeSyncWithCredentials): Promise => { + const { + connection, + destinationConfig: { orgSlug, serverId, siteId } + } = secretSync; + + const { apiToken } = connection.credentials; + + const secrets = await getLaravelForgeSecretsRaw({ apiToken, orgSlug, serverId, siteId }); + + const parsedSecrets = parseEnv(secrets); + + return parsedSecrets; +}; + +const buildEnvString = (secrets: LaravelForgeSecret[]) => { + if (secrets.length === 0) { + return "# .env"; + } + + return secrets + .map((secret) => { + const { value } = secret; + + if (value.includes(`"`)) { + return `${secret.key}='${value}'`; + } + + if (value.includes(" ") || value.includes("\n") || value.includes(`'`)) { + return `${secret.key}="${value}"`; + } + return `${secret.key}=${value}`; + }) + .join("\n"); +}; + +const updateLaravelForgeSecrets = async (secretSync: TLaravelForgeSyncWithCredentials, envString: string) => { + const { + connection, + destinationConfig: { orgSlug, serverId, siteId } + } = secretSync; + + const { apiToken } = connection.credentials; + + await request.put( + `${IntegrationUrls.LARAVELFORGE_API_URL}/api/orgs/${orgSlug}/servers/${serverId}/sites/${siteId}/environment`, + { + environment: envString + }, + + { + headers: { + Authorization: `Bearer ${apiToken}`, + Accept: "application/json", + "Content-Type": "application/json" + } + } + ); +}; + +export const LaravelForgeSyncFns = { + async syncSecrets(secretSync: TLaravelForgeSyncWithCredentials, secretMap: TSecretMap) { + const { + environment, + syncOptions: { disableSecretDeletion, keySchema } + } = secretSync; + + const secrets = await getLaravelForgeSecrets(secretSync); + + // Create a map of the existing secrets + const updatedSecretsMap = new Map(secrets.map((secret) => [secret.key, secret.value])); + + for (const [key, { value }] of Object.entries(secretMap)) { + // Add the new secrets to the map + updatedSecretsMap.set(key, value); + } + + if (!disableSecretDeletion) { + secrets.forEach((secret) => { + if (!matchesSchema(secret.key, environment?.slug || "", keySchema)) return; + + if (!secretMap[secret.key]) { + updatedSecretsMap.delete(secret.key); + } + }); + } + + const updatedSecrets = Array.from(updatedSecretsMap.entries()).map(([key, value]) => ({ key, value })); + + const envString = buildEnvString(updatedSecrets); + + await updateLaravelForgeSecrets(secretSync, envString); + }, + + async getSecrets(secretSync: TLaravelForgeSyncWithCredentials): Promise { + const secrets = await getLaravelForgeSecrets(secretSync); + return Object.fromEntries(secrets.map((secret) => [secret.key, { value: secret.value }])); + }, + + async removeSecrets(secretSync: TLaravelForgeSyncWithCredentials, secretMap: TSecretMap) { + const existingSecrets = await getLaravelForgeSecrets(secretSync); + + const newSecrets = existingSecrets.filter((secret) => !Object.hasOwn(secretMap, secret.key)); + + if (newSecrets.length === existingSecrets.length) { + return; + } + + const envString = buildEnvString(newSecrets); + + await updateLaravelForgeSecrets(secretSync, envString); + } +}; diff --git a/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-schemas.ts b/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-schemas.ts new file mode 100644 index 000000000..168ebde3b --- /dev/null +++ b/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-schemas.ts @@ -0,0 +1,68 @@ +import RE2 from "re2"; +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 slugValidator = (val: string) => { + return new RE2("^[a-z0-9.-]+$").test(val) && !new RE2(".[-]$").test(val); +}; + +const LaravelForgeSyncDestinationConfigSchema = z.object({ + orgSlug: z + .string() + .min(1, "Org Slug is required") + .max(512, "Org Slug cannot exceed 512 characters") + .refine( + (val) => slugValidator(val), + "Org Slug can only contain lowercase letters, numbers, dots, and dashes, and cannot end with a dot or dash." + ) + .describe(SecretSyncs.DESTINATION_CONFIG.LARAVEL_FORGE.orgSlug), + orgName: z.string().optional().describe(SecretSyncs.DESTINATION_CONFIG.LARAVEL_FORGE.orgName), + serverId: z + .string() + .min(1, "Server ID is required") + .refine((val) => !Number.isNaN(Number(val)), "Server ID must be a valid integer") + .describe(SecretSyncs.DESTINATION_CONFIG.LARAVEL_FORGE.serverId), + serverName: z.string().optional().describe(SecretSyncs.DESTINATION_CONFIG.LARAVEL_FORGE.serverName), + siteId: z.string().min(1, "Site ID is required").describe(SecretSyncs.DESTINATION_CONFIG.LARAVEL_FORGE.siteId), + siteName: z.string().optional().describe(SecretSyncs.DESTINATION_CONFIG.LARAVEL_FORGE.siteName) +}); + +const LaravelForgeSyncOptionsConfig: TSyncOptionsConfig = { canImportSecrets: true }; + +export const LaravelForgeSyncSchema = BaseSecretSyncSchema( + SecretSync.LaravelForge, + LaravelForgeSyncOptionsConfig +).extend({ + destination: z.literal(SecretSync.LaravelForge), + destinationConfig: LaravelForgeSyncDestinationConfigSchema +}); + +export const CreateLaravelForgeSyncSchema = GenericCreateSecretSyncFieldsSchema( + SecretSync.LaravelForge, + LaravelForgeSyncOptionsConfig +).extend({ + destinationConfig: LaravelForgeSyncDestinationConfigSchema +}); + +export const UpdateLaravelForgeSyncSchema = GenericUpdateSecretSyncFieldsSchema( + SecretSync.LaravelForge, + LaravelForgeSyncOptionsConfig +).extend({ + destinationConfig: LaravelForgeSyncDestinationConfigSchema.optional() +}); + +export const LaravelForgeSyncListItemSchema = z.object({ + name: z.literal("Laravel Forge"), + connection: z.literal(AppConnection.LaravelForge), + destination: z.literal(SecretSync.LaravelForge), + canImportSecrets: z.literal(true) +}); diff --git a/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-types.ts b/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-types.ts new file mode 100644 index 000000000..faa5dffa4 --- /dev/null +++ b/backend/src/services/secret-sync/laravel-forge/laravel-forge-sync-types.ts @@ -0,0 +1,41 @@ +import z from "zod"; + +import { TLaravelForgeConnection } from "@app/services/app-connection/laravel-forge"; + +import { + CreateLaravelForgeSyncSchema, + LaravelForgeSyncListItemSchema, + LaravelForgeSyncSchema +} from "./laravel-forge-sync-schemas"; + +export type TLaravelForgeSyncListItem = z.infer; + +export type TLaravelForgeSync = z.infer; + +export type TLaravelForgeSyncInput = z.infer; + +export type TLaravelForgeSyncWithCredentials = TLaravelForgeSync & { + connection: TLaravelForgeConnection; +}; + +export type TGetLaravelForgeSecrets = { + apiToken: string; + orgSlug: string; + serverId: string; + siteId: string; +}; + +export type TLaravelForgeSecrets = { + data: { + id: string; + type: string; + attributes: { + content: string; + }; + }; +}; + +export type LaravelForgeSecret = { + key: string; + value: string; +}; diff --git a/backend/src/services/secret-sync/secret-sync-enums.ts b/backend/src/services/secret-sync/secret-sync-enums.ts index fe0dc9f56..235b3db3a 100644 --- a/backend/src/services/secret-sync/secret-sync-enums.ts +++ b/backend/src/services/secret-sync/secret-sync-enums.ts @@ -28,7 +28,8 @@ export enum SecretSync { Checkly = "checkly", DigitalOceanAppPlatform = "digital-ocean-app-platform", Netlify = "netlify", - Bitbucket = "bitbucket" + Bitbucket = "bitbucket", + LaravelForge = "laravel-forge" } 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 a6faebd1e..85fc27250 100644 --- a/backend/src/services/secret-sync/secret-sync-fns.ts +++ b/backend/src/services/secret-sync/secret-sync-fns.ts @@ -49,6 +49,8 @@ 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 { NETLIFY_SYNC_LIST_OPTION, NetlifySyncFns } from "./netlify"; import { RAILWAY_SYNC_LIST_OPTION } from "./railway/railway-sync-constants"; import { RailwaySyncFns } from "./railway/railway-sync-fns"; @@ -91,7 +93,8 @@ const SECRET_SYNC_LIST_OPTIONS: Record = { [SecretSync.Checkly]: CHECKLY_SYNC_LIST_OPTION, [SecretSync.DigitalOceanAppPlatform]: DIGITAL_OCEAN_APP_PLATFORM_SYNC_LIST_OPTION, [SecretSync.Netlify]: NETLIFY_SYNC_LIST_OPTION, - [SecretSync.Bitbucket]: BITBUCKET_SYNC_LIST_OPTION + [SecretSync.Bitbucket]: BITBUCKET_SYNC_LIST_OPTION, + [SecretSync.LaravelForge]: LARAVEL_FORGE_SYNC_LIST_OPTION }; export const listSecretSyncOptions = () => { @@ -277,6 +280,8 @@ export const SecretSyncFns = { return NetlifySyncFns.syncSecrets(secretSync, schemaSecretMap); case SecretSync.Bitbucket: return BitbucketSyncFns.syncSecrets(secretSync, schemaSecretMap); + case SecretSync.LaravelForge: + return LaravelForgeSyncFns.syncSecrets(secretSync, schemaSecretMap); default: throw new Error( `Unhandled sync destination for sync secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -393,6 +398,9 @@ export const SecretSyncFns = { case SecretSync.Bitbucket: secretMap = await BitbucketSyncFns.getSecrets(secretSync); break; + case SecretSync.LaravelForge: + secretMap = await LaravelForgeSyncFns.getSecrets(secretSync); + break; default: throw new Error( `Unhandled sync destination for get secrets fns: ${(secretSync as TSecretSyncWithCredentials).destination}` @@ -486,6 +494,8 @@ export const SecretSyncFns = { return NetlifySyncFns.removeSecrets(secretSync, schemaSecretMap); case SecretSync.Bitbucket: return BitbucketSyncFns.removeSecrets(secretSync, schemaSecretMap); + case SecretSync.LaravelForge: + return LaravelForgeSyncFns.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 1fbc66cca..0ec8aede0 100644 --- a/backend/src/services/secret-sync/secret-sync-maps.ts +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -32,7 +32,8 @@ export const SECRET_SYNC_NAME_MAP: Record = { [SecretSync.Checkly]: "Checkly", [SecretSync.DigitalOceanAppPlatform]: "Digital Ocean App Platform", [SecretSync.Netlify]: "Netlify", - [SecretSync.Bitbucket]: "Bitbucket" + [SecretSync.Bitbucket]: "Bitbucket", + [SecretSync.LaravelForge]: "Laravel Forge" }; export const SECRET_SYNC_CONNECTION_MAP: Record = { @@ -65,7 +66,8 @@ export const SECRET_SYNC_CONNECTION_MAP: Record = { [SecretSync.Checkly]: AppConnection.Checkly, [SecretSync.DigitalOceanAppPlatform]: AppConnection.DigitalOcean, [SecretSync.Netlify]: AppConnection.Netlify, - [SecretSync.Bitbucket]: AppConnection.Bitbucket + [SecretSync.Bitbucket]: AppConnection.Bitbucket, + [SecretSync.LaravelForge]: AppConnection.LaravelForge }; export const SECRET_SYNC_PLAN_MAP: Record = { @@ -98,7 +100,8 @@ export const SECRET_SYNC_PLAN_MAP: Record = { [SecretSync.Checkly]: SecretSyncPlanType.Regular, [SecretSync.DigitalOceanAppPlatform]: SecretSyncPlanType.Regular, [SecretSync.Netlify]: SecretSyncPlanType.Regular, - [SecretSync.Bitbucket]: SecretSyncPlanType.Regular + [SecretSync.Bitbucket]: SecretSyncPlanType.Regular, + [SecretSync.LaravelForge]: SecretSyncPlanType.Regular }; export const SECRET_SYNC_SKIP_FIELDS_MAP: Record = { @@ -140,7 +143,8 @@ export const SECRET_SYNC_SKIP_FIELDS_MAP: Record = { [SecretSync.Checkly]: ["groupName", "accountName"], [SecretSync.DigitalOceanAppPlatform]: ["appName"], [SecretSync.Netlify]: ["accountName", "siteName"], - [SecretSync.Bitbucket]: [] + [SecretSync.Bitbucket]: [], + [SecretSync.LaravelForge]: [] }; const defaultDuplicateCheck: DestinationDuplicateCheckFn = () => true; @@ -199,5 +203,6 @@ export const DESTINATION_DUPLICATE_CHECK_MAP: Record + Check out the configuration docs for [Laravel Forge + Connections](/integrations/app-connections/laravel-forge) to learn how to + obtain the required credentials. + diff --git a/docs/api-reference/endpoints/app-connections/laravel-forge/delete.mdx b/docs/api-reference/endpoints/app-connections/laravel-forge/delete.mdx new file mode 100644 index 000000000..e2d8a0f02 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/laravel-forge/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/app-connections/laravel-forge/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/laravel-forge/get-by-id.mdx b/docs/api-reference/endpoints/app-connections/laravel-forge/get-by-id.mdx new file mode 100644 index 000000000..675551508 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/laravel-forge/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/app-connections/laravel-forge/{connectionId}" +--- diff --git a/docs/api-reference/endpoints/app-connections/laravel-forge/get-by-name.mdx b/docs/api-reference/endpoints/app-connections/laravel-forge/get-by-name.mdx new file mode 100644 index 000000000..541a393f9 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/laravel-forge/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/app-connections/laravel-forge/connection-name/{connectionName}" +--- diff --git a/docs/api-reference/endpoints/app-connections/laravel-forge/list.mdx b/docs/api-reference/endpoints/app-connections/laravel-forge/list.mdx new file mode 100644 index 000000000..209eb6514 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/laravel-forge/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/app-connections/laravel-forge" +--- diff --git a/docs/api-reference/endpoints/app-connections/laravel-forge/update.mdx b/docs/api-reference/endpoints/app-connections/laravel-forge/update.mdx new file mode 100644 index 000000000..b00d06818 --- /dev/null +++ b/docs/api-reference/endpoints/app-connections/laravel-forge/update.mdx @@ -0,0 +1,10 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/app-connections/laravel-forge/{connectionId}" +--- + + + Check out the configuration docs for [Laravel Forge + Connections](/integrations/app-connections/laravel-forge) to learn how to + obtain the required credentials. + diff --git a/docs/api-reference/endpoints/certificate-profiles/create.mdx b/docs/api-reference/endpoints/certificate-profiles/create.mdx new file mode 100644 index 000000000..e24e42207 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-profiles/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/pki/certificate-profiles" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-profiles/delete.mdx b/docs/api-reference/endpoints/certificate-profiles/delete.mdx new file mode 100644 index 000000000..a1762640a --- /dev/null +++ b/docs/api-reference/endpoints/certificate-profiles/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/pki/certificate-profiles/{id}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-profiles/get-by-id.mdx b/docs/api-reference/endpoints/certificate-profiles/get-by-id.mdx new file mode 100644 index 000000000..38e0c20f8 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-profiles/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/pki/certificate-profiles/{id}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-profiles/get-by-slug.mdx b/docs/api-reference/endpoints/certificate-profiles/get-by-slug.mdx new file mode 100644 index 000000000..9013020d6 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-profiles/get-by-slug.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Slug" +openapi: "GET /api/v1/pki/certificate-profiles/slug/{slug}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-profiles/list-certificates.mdx b/docs/api-reference/endpoints/certificate-profiles/list-certificates.mdx new file mode 100644 index 000000000..d0a690f76 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-profiles/list-certificates.mdx @@ -0,0 +1,4 @@ +--- +title: "List Certificates" +openapi: "GET /api/v1/pki/certificate-profiles/{id}/certificates" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-profiles/list.mdx b/docs/api-reference/endpoints/certificate-profiles/list.mdx new file mode 100644 index 000000000..c0f461512 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-profiles/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/pki/certificate-profiles" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-profiles/update.mdx b/docs/api-reference/endpoints/certificate-profiles/update.mdx new file mode 100644 index 000000000..e483cf030 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-profiles/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/pki/certificate-profiles/{id}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-templates-v2/create.mdx b/docs/api-reference/endpoints/certificate-templates-v2/create.mdx new file mode 100644 index 000000000..2fb4da177 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-templates-v2/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v2/certificate-templates" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-templates-v2/delete.mdx b/docs/api-reference/endpoints/certificate-templates-v2/delete.mdx new file mode 100644 index 000000000..dc92ca55a --- /dev/null +++ b/docs/api-reference/endpoints/certificate-templates-v2/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v2/certificate-templates/{id}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-templates-v2/get-by-id.mdx b/docs/api-reference/endpoints/certificate-templates-v2/get-by-id.mdx new file mode 100644 index 000000000..c97389a1d --- /dev/null +++ b/docs/api-reference/endpoints/certificate-templates-v2/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v2/certificate-templates/{id}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-templates-v2/list.mdx b/docs/api-reference/endpoints/certificate-templates-v2/list.mdx new file mode 100644 index 000000000..ab752e851 --- /dev/null +++ b/docs/api-reference/endpoints/certificate-templates-v2/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v2/certificate-templates" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-templates-v2/update.mdx b/docs/api-reference/endpoints/certificate-templates-v2/update.mdx new file mode 100644 index 000000000..7bdeca14e --- /dev/null +++ b/docs/api-reference/endpoints/certificate-templates-v2/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v2/certificate-templates/{id}" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificate-templates/create.mdx b/docs/api-reference/endpoints/certificate-templates/create.mdx deleted file mode 100644 index 56fcf3791..000000000 --- a/docs/api-reference/endpoints/certificate-templates/create.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Create" -openapi: "POST /api/v1/pki/certificate-templates" ---- diff --git a/docs/api-reference/endpoints/certificate-templates/delete.mdx b/docs/api-reference/endpoints/certificate-templates/delete.mdx deleted file mode 100644 index c4f13d470..000000000 --- a/docs/api-reference/endpoints/certificate-templates/delete.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Delete" -openapi: "DELETE /api/v1/pki/certificate-templates/{certificateTemplateId}" ---- diff --git a/docs/api-reference/endpoints/certificate-templates/get-by-id.mdx b/docs/api-reference/endpoints/certificate-templates/get-by-id.mdx deleted file mode 100644 index 802dc5326..000000000 --- a/docs/api-reference/endpoints/certificate-templates/get-by-id.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Get by ID" -openapi: "GET /api/v1/pki/certificate-templates/{certificateTemplateId}" ---- diff --git a/docs/api-reference/endpoints/certificate-templates/update.mdx b/docs/api-reference/endpoints/certificate-templates/update.mdx deleted file mode 100644 index 53c5f6fdf..000000000 --- a/docs/api-reference/endpoints/certificate-templates/update.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Update" -openapi: "PATCH /api/v1/pki/certificate-templates/{certificateTemplateId}" ---- diff --git a/docs/api-reference/endpoints/pki/subscribers/create.mdx b/docs/api-reference/endpoints/pki/subscribers/create.mdx deleted file mode 100644 index 14a53b7fa..000000000 --- a/docs/api-reference/endpoints/pki/subscribers/create.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Create" -openapi: "POST /api/v1/pki/subscribers" ---- diff --git a/docs/api-reference/endpoints/pki/subscribers/delete.mdx b/docs/api-reference/endpoints/pki/subscribers/delete.mdx deleted file mode 100644 index 5975b89e9..000000000 --- a/docs/api-reference/endpoints/pki/subscribers/delete.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Delete" -openapi: "DELETE /api/v1/pki/subscribers/{subscriberName}" ---- diff --git a/docs/api-reference/endpoints/pki/subscribers/get-latest-cert-bundle.mdx b/docs/api-reference/endpoints/pki/subscribers/get-latest-cert-bundle.mdx deleted file mode 100644 index 894c8ed4e..000000000 --- a/docs/api-reference/endpoints/pki/subscribers/get-latest-cert-bundle.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Retrieve latest certificate bundle" -openapi: "GET /api/v1/pki/subscribers/{subscriberName}/latest-certificate-bundle" ---- diff --git a/docs/api-reference/endpoints/pki/subscribers/issue-cert.mdx b/docs/api-reference/endpoints/pki/subscribers/issue-cert.mdx deleted file mode 100644 index c9c71c80d..000000000 --- a/docs/api-reference/endpoints/pki/subscribers/issue-cert.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Issue Certificate" -openapi: "POST /api/v1/pki/subscribers/{subscriberName}/issue-certificate" ---- diff --git a/docs/api-reference/endpoints/pki/subscribers/list-certs.mdx b/docs/api-reference/endpoints/pki/subscribers/list-certs.mdx deleted file mode 100644 index 3a4607303..000000000 --- a/docs/api-reference/endpoints/pki/subscribers/list-certs.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "List Certificates" -openapi: "GET /api/v1/pki/subscribers/{subscriberName}/certificates" ---- diff --git a/docs/api-reference/endpoints/pki/subscribers/order-cert.mdx b/docs/api-reference/endpoints/pki/subscribers/order-cert.mdx deleted file mode 100644 index 93abf1433..000000000 --- a/docs/api-reference/endpoints/pki/subscribers/order-cert.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Order Certificate" -openapi: "POST /api/v1/pki/subscribers/{subscriberName}/order-certificate" ---- diff --git a/docs/api-reference/endpoints/pki/subscribers/read.mdx b/docs/api-reference/endpoints/pki/subscribers/read.mdx deleted file mode 100644 index 0d223217d..000000000 --- a/docs/api-reference/endpoints/pki/subscribers/read.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Retrieve" -openapi: "GET /api/v1/pki/subscribers/{subscriberName}" ---- diff --git a/docs/api-reference/endpoints/pki/subscribers/sign-cert.mdx b/docs/api-reference/endpoints/pki/subscribers/sign-cert.mdx deleted file mode 100644 index d31d30239..000000000 --- a/docs/api-reference/endpoints/pki/subscribers/sign-cert.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Sign Certificate" -openapi: "POST /api/v1/pki/subscribers/{subscriberName}/sign-certificate" ---- diff --git a/docs/api-reference/endpoints/pki/subscribers/update.mdx b/docs/api-reference/endpoints/pki/subscribers/update.mdx deleted file mode 100644 index 5b62cbe7d..000000000 --- a/docs/api-reference/endpoints/pki/subscribers/update.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Update" -openapi: "PATCH /api/v1/pki/subscribers/{subscriberName}" ---- diff --git a/docs/api-reference/endpoints/secret-syncs/laravel-forge/create.mdx b/docs/api-reference/endpoints/secret-syncs/laravel-forge/create.mdx new file mode 100644 index 000000000..076571648 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/laravel-forge/create.mdx @@ -0,0 +1,4 @@ +--- +title: "Create" +openapi: "POST /api/v1/secret-syncs/laravel-forge" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/laravel-forge/delete.mdx b/docs/api-reference/endpoints/secret-syncs/laravel-forge/delete.mdx new file mode 100644 index 000000000..303c82079 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/laravel-forge/delete.mdx @@ -0,0 +1,4 @@ +--- +title: "Delete" +openapi: "DELETE /api/v1/secret-syncs/laravel-forge/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/laravel-forge/get-by-id.mdx b/docs/api-reference/endpoints/secret-syncs/laravel-forge/get-by-id.mdx new file mode 100644 index 000000000..12602b1db --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/laravel-forge/get-by-id.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by ID" +openapi: "GET /api/v1/secret-syncs/laravel-forge/{syncId}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/laravel-forge/get-by-name.mdx b/docs/api-reference/endpoints/secret-syncs/laravel-forge/get-by-name.mdx new file mode 100644 index 000000000..f5082df25 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/laravel-forge/get-by-name.mdx @@ -0,0 +1,4 @@ +--- +title: "Get by Name" +openapi: "GET /api/v1/secret-syncs/laravel-forge/sync-name/{syncName}" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/laravel-forge/import-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/laravel-forge/import-secrets.mdx new file mode 100644 index 000000000..c86085e95 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/laravel-forge/import-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Import Secrets" +openapi: "POST /api/v1/secret-syncs/laravel-forge/{syncId}/import-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/laravel-forge/list.mdx b/docs/api-reference/endpoints/secret-syncs/laravel-forge/list.mdx new file mode 100644 index 000000000..fa64bf933 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/laravel-forge/list.mdx @@ -0,0 +1,4 @@ +--- +title: "List" +openapi: "GET /api/v1/secret-syncs/laravel-forge" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/laravel-forge/remove-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/laravel-forge/remove-secrets.mdx new file mode 100644 index 000000000..7b4c3a752 --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/laravel-forge/remove-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Secrets" +openapi: "POST /api/v1/secret-syncs/laravel-forge/{syncId}/remove-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/laravel-forge/sync-secrets.mdx b/docs/api-reference/endpoints/secret-syncs/laravel-forge/sync-secrets.mdx new file mode 100644 index 000000000..ce638b4cb --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/laravel-forge/sync-secrets.mdx @@ -0,0 +1,4 @@ +--- +title: "Sync Secrets" +openapi: "POST /api/v1/secret-syncs/laravel-forge/{syncId}/sync-secrets" +--- diff --git a/docs/api-reference/endpoints/secret-syncs/laravel-forge/update.mdx b/docs/api-reference/endpoints/secret-syncs/laravel-forge/update.mdx new file mode 100644 index 000000000..9f9f4a28f --- /dev/null +++ b/docs/api-reference/endpoints/secret-syncs/laravel-forge/update.mdx @@ -0,0 +1,4 @@ +--- +title: "Update" +openapi: "PATCH /api/v1/secret-syncs/laravel-forge/{syncId}" +--- diff --git a/docs/cli/commands/gateway.mdx b/docs/cli/commands/gateway.mdx index bddbf614c..59202e46e 100644 --- a/docs/cli/commands/gateway.mdx +++ b/docs/cli/commands/gateway.mdx @@ -31,6 +31,7 @@ If you are moving from Gateway v1 to Gateway v2, this is NOT a drop-in switch. G ## Subcommands & flags + Run the Infisical gateway component within your the network where your target resources are located. The gateway establishes an SSH reverse tunnel to the specified relay server and provides secure access to private resources within your network. @@ -245,6 +246,7 @@ The Relay supports multiple authentication methods. Below are the available auth ### Other Flags + The name of the relay that this gateway should connect to. The relay must be running and registered before starting the gateway. @@ -276,6 +278,7 @@ The Relay supports multiple authentication methods. Below are the available auth ``` + @@ -293,6 +296,7 @@ sudo infisical gateway systemd install --token= --domain= --name= ### Flags + The machine identity access token to authenticate with Infisical. @@ -334,6 +338,7 @@ sudo infisical gateway systemd install --token= --domain= --name= ``` + ### Service Details @@ -360,9 +365,11 @@ sudo systemctl disable infisical-gateway # Disable auto-start on boot ``` + ## Legacy Gateway Commands + **This command is deprecated and will be removed in a future release.** @@ -579,6 +586,7 @@ The Infisical CLI supports multiple authentication methods. Below are the availa ### Other Flags + Domain of your self-hosted Infisical instance. @@ -588,6 +596,7 @@ The Infisical CLI supports multiple authentication methods. Below are the availa ``` + @@ -614,6 +623,7 @@ sudo infisical gateway install --token= --domain= ### Flags + The machine identity access token to authenticate with Infisical. @@ -635,6 +645,7 @@ sudo infisical gateway install --token= --domain= ``` + ### Service Details @@ -659,3 +670,4 @@ sudo systemctl disable infisical-gateway # Disable auto-start on boot ``` + diff --git a/docs/cli/commands/relay.mdx b/docs/cli/commands/relay.mdx index b377b9ce2..92358ed8b 100644 --- a/docs/cli/commands/relay.mdx +++ b/docs/cli/commands/relay.mdx @@ -13,7 +13,7 @@ description: "Relay-related commands for Infisical" ```bash # Install systemd service sudo infisical relay systemd install --host= --name= --token= - + # Uninstall systemd service sudo infisical relay systemd uninstall ``` @@ -26,6 +26,7 @@ Relay-related commands for Infisical that provide identity-aware relay infrastru ## Subcommands & flags + Run the Infisical relay component. The relay handles network traffic routing between Infisical and your gateways. @@ -35,6 +36,7 @@ infisical relay start --host= --name= --auth-method= ### Flags + The host (IP address or hostname) of the instance where the relay is deployed. This must be a static public IP or resolvable hostname that gateways can reach. @@ -57,6 +59,7 @@ infisical relay start --host= --name= --auth-method= ``` + ### Authentication @@ -280,6 +283,7 @@ infisical relay systemd ### Subcommands + Install and enable systemd service for the relay. Must be run with sudo on Linux systems. @@ -289,6 +293,7 @@ sudo infisical relay systemd install --host= --name= --token= #### Flags + The host (IP address or hostname) of the instance where the relay is deployed. This must be a static public IP or resolvable hostname that gateways can reach. @@ -331,6 +336,7 @@ sudo infisical relay systemd install --domain=http://localhost:8080 --token= + #### Examples @@ -386,5 +392,7 @@ sudo infisical relay systemd uninstall - Cleans up the service configuration + + diff --git a/docs/docs.json b/docs/docs.json index ee817ab9f..7f25805d8 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -125,6 +125,7 @@ "integrations/app-connections/hashicorp-vault", "integrations/app-connections/heroku", "integrations/app-connections/humanitec", + "integrations/app-connections/laravel-forge", "integrations/app-connections/ldap", "integrations/app-connections/mssql", "integrations/app-connections/mysql", @@ -420,6 +421,7 @@ "documentation/guides/node", "documentation/guides/python", "documentation/guides/nextjs-vercel", + "documentation/guides/kubernetes-operator", "documentation/guides/microsoft-power-apps" ] } @@ -550,6 +552,7 @@ "integrations/secret-syncs/hashicorp-vault", "integrations/secret-syncs/heroku", "integrations/secret-syncs/humanitec", + "integrations/secret-syncs/laravel-forge", "integrations/secret-syncs/netlify", "integrations/secret-syncs/oci-vault", "integrations/secret-syncs/railway", @@ -1778,6 +1781,18 @@ "api-reference/endpoints/app-connections/humanitec/delete" ] }, + { + "group": "Laravel Forge", + "pages": [ + "api-reference/endpoints/app-connections/laravel-forge/list", + "api-reference/endpoints/app-connections/laravel-forge/available", + "api-reference/endpoints/app-connections/laravel-forge/get-by-id", + "api-reference/endpoints/app-connections/laravel-forge/get-by-name", + "api-reference/endpoints/app-connections/laravel-forge/create", + "api-reference/endpoints/app-connections/laravel-forge/update", + "api-reference/endpoints/app-connections/laravel-forge/delete" + ] + }, { "group": "LDAP", "pages": [ @@ -2258,6 +2273,19 @@ "api-reference/endpoints/secret-syncs/humanitec/remove-secrets" ] }, + { + "group": "Laravel Forge", + "pages": [ + "api-reference/endpoints/secret-syncs/laravel-forge/list", + "api-reference/endpoints/secret-syncs/laravel-forge/get-by-id", + "api-reference/endpoints/secret-syncs/laravel-forge/get-by-name", + "api-reference/endpoints/secret-syncs/laravel-forge/create", + "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/remove-secrets" + ] + }, { "group": "Netlify", "pages": [ @@ -2424,20 +2452,6 @@ { "group": "Infisical PKI", "pages": [ - { - "group": "Subscribers", - "pages": [ - "api-reference/endpoints/pki/subscribers/list-certs", - "api-reference/endpoints/pki/subscribers/create", - "api-reference/endpoints/pki/subscribers/read", - "api-reference/endpoints/pki/subscribers/update", - "api-reference/endpoints/pki/subscribers/delete", - "api-reference/endpoints/pki/subscribers/issue-cert", - "api-reference/endpoints/pki/subscribers/sign-cert", - "api-reference/endpoints/pki/subscribers/order-cert", - "api-reference/endpoints/pki/subscribers/get-latest-cert-bundle" - ] - }, { "group": "Certificate Authorities", "pages": [ @@ -2494,10 +2508,11 @@ { "group": "Certificate Templates", "pages": [ - "api-reference/endpoints/certificate-templates/create", - "api-reference/endpoints/certificate-templates/update", - "api-reference/endpoints/certificate-templates/get-by-id", - "api-reference/endpoints/certificate-templates/delete" + "api-reference/endpoints/certificate-templates-v2/list", + "api-reference/endpoints/certificate-templates-v2/create", + "api-reference/endpoints/certificate-templates-v2/update", + "api-reference/endpoints/certificate-templates-v2/get-by-id", + "api-reference/endpoints/certificate-templates-v2/delete" ] }, { @@ -2521,6 +2536,15 @@ "api-reference/endpoints/pki-alerts/delete" ] }, + { + "group": "Certificate Profiles", + "pages": [ + "api-reference/endpoints/certificate-profiles/create", + "api-reference/endpoints/certificate-profiles/update", + "api-reference/endpoints/certificate-profiles/get-by-id", + "api-reference/endpoints/certificate-profiles/delete" + ] + }, { "group": "Certificate Syncs", "pages": [ diff --git a/docs/documentation/guides/kubernetes-operator.mdx b/docs/documentation/guides/kubernetes-operator.mdx new file mode 100644 index 000000000..fc42b8d2f --- /dev/null +++ b/docs/documentation/guides/kubernetes-operator.mdx @@ -0,0 +1,239 @@ +--- +title: "Managing Secrets With Kubernetes Operator" +sidebarTitle: "Kubernetes Operator" +description: "How to use the Infisical Kubernetes Operator to Push Secrets, Pull Secrets, and Generate Dynamic Secrets within your clusters." +--- + +Infisical's Kubernetes Operator provides a seamless, secure, and automated way to synchronize secrets between your Infisical instance and your Kubernetes clusters. The Operator's three Custom Resource Definitions (CRDs) make this possible. In this guide, we provide the necessary CRDs and configurations for your kubernetes cluster, but you can customize them to fit your use-case. + +In this guide, we'll walk through how to: + +1. **Install the Infisical Operator on your Kubernetes cluster**. +2. **Configure Authentication using Kubernetes Service Accounts**. +3. **Use Each of the three CRDs**. + - **InfisicalSecret** [Sync secrets from Infisical to Kubernetes] + - **InfisicalPushSecret** [Sync secrets from Kubernetes to Infisical] + - **InfisicalDynamicSecret** [Manage Dynamic Secrets and automatically create time-bound leases] + +## Prerequisites + +Before we begin, make sure your environment is ready +1. Installed tools + - [helm](https://helm.sh/docs/intro/install/), [git](https://git-scm.com/downloads), [kubectl](https://kubernetes.io/docs/tasks/tools/) +2. Kubernetes Cluster + - Ensure you have access to a running cluster and connect with kubectl +3. PostgreSQL Cluster (for InfisicalDynamicSecret) + - Ensure you have a running database that you have access to +4. Clone [infisical-guides-source-code](https://github.com/Infisical/infisical-guides-source-code) repository +5. Access to an Infisical instance (cloud or self-hosted) + +## Step-By-Step Guide + + + + +The [Infisical Operator](https://infisical.com/docs/integrations/platforms/kubernetes/overview) runs inside your cluster and is responsible for handling secret synchronization events. + +```console +helm repo add infisical-helm-charts 'https://dl.cloudsmith.io/public/infisical/helm-charts/helm/charts/' +helm repo update +helm install infisical-operator infisical-helm-charts/secrets-operator +``` + +Verify the operator pod is running: + +```console +kubectl get pods -n default +``` + + + +The operator uses a [Machine Identity](https://infisical.com/docs/documentation/platform/identities/machine-identities) to authenticate with Infisical through the Kubernetes Auth Method +1. Login to [Infisical](https://app.infisical.com/) +2. Select **Organization Access** from the left navigational pane +3. Create an Identity and give it a name and a role +4. Once the Machine Identity is created, copy the **Identity ID** (will be used later) +5. Select the created Machine Identity, and add a [Kubernetes Authentication Method](https://infisical.com/docs/documentation/platform/identities/kubernetes-auth). Use these configurations: + - **Allowed Service Account Names**: infisical-service-account, default + - **Allowed Namespaces**: default + - **Kubernetes Host URL** can be found by running ```kubectl cluster-info``` + - **Token Reviewer JWT / CA Certificate**: We will be generating these two later and adding them in later, leave it blank for now +6. Once the Machine Identity has been created, navigate back to **Overview** +7. Now select **Add New Project** + - Add a **Project Name**, and select **Secrets Management** as the product type + - Add a description (Optional). +8. Once the Project is created, navigate into the Project to **Add Secrets**. You can add any key-value pair for this example, however if you want to use the InfisicalSecret CRD example provided in this demo, use the following configurations: + - **Key**: SMTP_HOST + - **Value**: smtp@gmail.com + - **Tags**: N/A (Not needed for this demonstration) + - **Environments**: Production +9. Now lets navigate to the **Project Access** tab on the left hand navigation pane. + - Add the machine identity we created, and give it Admin permissions (just for demonstration purposes) + + + + + +Now we will be interacting with the local repository you cloned earlier. Make sure you are in the directory that contains the yaml configurations. Assuming you are in your root user directory: + +```console +cd infisical-guides-source-code/kubernetes-operator-demo +``` + +1. Create the ```infisical-token-reviewer``` service account. This Manifest creates a **service account** that the Infisical Operator uses to authenticate with Kubernetes for token reviews. It allows Infisical to validate Kubernetes tokens securely during the Machine Identity authentication process. +```console +kubectl apply -f infisical-reviewer-service-account.yaml +``` +2. Create the token for the reviewer service account. This Yaml defines a **service account token secret** linked to the reviewer account created above. It generates a JWT token that Infisical uses for the Kubernetes Auth Method in your Machine Identity configuration. +```console +kubectl apply -f service-account-reviewer-token.yaml +``` +3. This file binds the ```infisical-token-reviewer``` service account to the built-in ```system:auth-delegator``` ClusterRole. That role allows the service account to perform **token review** and **authentication delegation** requests on behalf of other service accounts - a key part of Kubernetes-based identity verification. Without this binding, the Infisical Operator wouldn't have permission to validate tokens. +```console +kubectl apply -f cluster-role-binding.yaml +``` +4. Create the service account that will be used by the InfisicalSecret. This file creates a **dedicated service account** ```infisical-service-account``` that the Infisical Operator uses to access and sync secrets within your cluster. It operates as the Operator's working identity in your cluster, separate from the token reviewer. +```console +kubectl apply -f infisical-service-account.yaml +``` +5. Create the token for the Infisical service account. This manifest defines a **token secret** for the ```infisical-service-account```. It allows the Infisical operator to authenticate against Infisical's API when syncing secrets. The token will then be manually patched and associated with the service account to make sure Kubernetes mains it persistently. +```console +kubectl apply -f infisical-service-account-token.yaml +``` +6. Apply the patch to manually associate the token secret +```console +kubectl patch serviceaccount infisical-service-account -p '{"secrets": [{"name": "infisical-service-account-token"}]}' -n default +``` +7. Create the **JWT Token** and **Certificate** and add it to the **Machine Identity** we created under **Kubernetes Auth**. For the generated CA, navigate to the **Advanced** tab to paste the certificate: +- JWT Command +```console +kubectl get secret infisical-token-reviewer-token -n default -o jsonpath='{.data.token}' | base64 -d +``` + +- CA Command +```console +kubectl get secret infisical-token-reviewer-token -n default -o jsonpath='{.data.ca\.crt}' | base64 -d +``` + + + +1. Check to see if the service accounts were created + +```console +kubectl get serviceaccount -n default | grep infisical +``` + +2. Verify the tokens were created and linked + +```console +kubectl get secrets -n default | grep infisical +``` + + + +The [InfisicalSecret](https://infisical.com/docs/integrations/platforms/kubernetes/infisical-secret-crd) CRD tells the operator to sync secrets from Infisical to Kubernetes. By referencing your ```identityID```, ```projectSlug```, and ```envSlug```, this CRD tells the Infisical Operator which Infisical secrets to fetch and how to format them into a Kubernetes Secret. Make sure to edit the provided CRD to match your specific Machine Identity ID, Project ID, and which environment your secrets are being pulled from (default is prod). + - **Project Slug**: Can be found when you select your project and navigate to settings + - **Identity ID**: Can be found when you select your machine identity from your organization's access control + +1. After editing the ```example-infisical-secret-crd.yaml``` to contain your demo-specific values, apply the yaml in your cluster +```console +kubectl apply -f example-infisical-secret-crd.yaml +``` + + + + +1. Check that the ```InfisicalSecret``` was created successfully +```console +kubectl get infisicalsecret -n default +``` +2. Check that the operator created the ```managed-secret``` +```console +kubectl get secret managed-secret -n default +``` +3. View the secret contents (base64 encoded) +```console +kubectl get secret managed-secret -n default -o jsonpath='{.data}' | jq +``` + + + +1. Deploy the nginx demo deployment that will use the managed secret. +```console +kubectl apply -f demo-deployment.yaml +``` +2. Wait 15-20 seconds and then verify the deployments +```console +kubectl get deployments +kubectl get pods -l app=nginx +``` + + + +1. Check that the environment variable is in the running pod. If everything was successful, at this point you should be able to see the secret populate in the kubernetes pod and have a successful **sync** from Infisical to Kubernetes. +```console +kubectl exec -it $(kubectl get pod -l app=nginx -o jsonpath='{.items[0].metadata.name}') -- env | grep SMTP +``` + + + +Now that we have successfully synced secrets from Infisical to Kubernetes, lets explore how we can push **Kubernetes Secrets** to Infisical. + + 1. Either create a **Kubernetes Secret** via yaml, or use the one in the repository. +```console +kubectl apply -f source-secret.yaml +``` + 2. Verify creation of the secret +```console +kubectl get secret push-secret-demo -n default -o yaml +``` + + + +The [InfisicalPushSecret](https://infisical.com/docs/integrations/platforms/kubernetes/infisical-push-secret-crd) CRD tells the operator to sync secrets from Kubernetes to Infisical. Make sure you edit the CRD to include the specific **Project Slug**, and **Identity ID**. The other values present in ```example-push-secret.yaml``` should be configured based on the previously committed yaml configurations. + + 1. Apply the InfisicalPushSecret CRD provided after making the necessary changes +```console +kubectl apply -f example-push-secret-crd.yaml +``` + + 2. Once your CRD has been configured, go back to your project within Infisical and check to see if your secrets have populated there. + +![secrets dashboard](../../images/push-secret.png) + + + + +The [InfisicalDynamicSecret](https://infisical.com/docs/integrations/platforms/kubernetes/infisical-dynamic-secret-crd) CRD allows you to sync dynamic secrets and create leases automatically in Kubernetes as native **Kubernetes Secret** resources Any Pod, Deployment, or other Kubernetes resource can make use of dynamic secrets from Infisical just like any other Kubernetes secret. + + 1. Navigate to your Infisical **Project** and click on the dropdown next to **Add Secret**. From here you will select **Add Dynamic Secret** + - Select **SQL Database** as the service you would like to connect to. + - Select **PostegreSQL** as the database service. Enter in the connection details for your database, specifically the **Host**, **Port**, **User**, **Password**, and **Database Name**. + - For the **Secret Name**, if you want to use the same name as the one in the cloned **InfisicalDynamicSecret** CRD, use the name **dynamic-secret-lease**. Otherwise you will need to change the **dynamicSecret.secretName** config in the InfisicalDynamicSecret CRD to whatever you name the secret here. + - In the CA (SSL) section, make sure to upload the **CA Certificate** for your database. + - Finally, select **Prod** as the environment (we are keeping this configuration as part of the demonstration). + +![secrets dashboard dynamic](../../images/dynamic-secret.png) + + 2. Edit the ```dynamic-secret-crd``` with the proper machine **Identity ID**, **Project Slug**, **dynamicSecret.secretName** (same as the **Secret Name** you gave to the dynamic secret in Infisical), and managedSecretReference.secretName (name of the kubernetes secret that Infisical Operator will create/populate in the cluster). + - If you want to keep the **managedSecretReference.secretName** then you can leave it as **dynamic-secret-test** + + 3. Once the changes have been saved, apply the yaml: + + ```console + kubectl apply -f dynamic-secret-crd.yaml + ``` + + 4. After applying the CRD, you should notice that the dynamic secret lease has been created and synced with your cluster. Verify by running: + +```console +kubectl get secret dynamic-secret-test -n default -o yaml +``` + + 5. Once the dynamic secret lease has been created, you should see that the secret has data that contains the lease credentials. + +![dynamic secrets output](../../images/dynamic-secret-crd.png) + + + +**Congratulations! You successfully managed secrets with Kubernetes.** \ No newline at end of file diff --git a/docs/documentation/platform/external-migrations/overview.mdx b/docs/documentation/platform/external-migrations/overview.mdx index cf6c5e5a1..d8e85fe91 100644 --- a/docs/documentation/platform/external-migrations/overview.mdx +++ b/docs/documentation/platform/external-migrations/overview.mdx @@ -1,16 +1,22 @@ --- title: "External Migrations" sidebarTitle: "Overview" -description: "Learn how to migrate secrets from third-party secrets management platforms to Infisical." +description: "Learn how to migrate resources from third-party secrets management platforms to Infisical." --- ## Overview -Infisical supports migrating secrets from third-party secrets management platforms to Infisical. This is useful if you're looking to easily switch to Infisical and wish to move over your existing secrets from a different platform. +Infisical supports migrating resources from third-party secrets management platforms to Infisical. This is useful if you're looking to easily switch to Infisical and wish to move over your existing resources from a different platform. + +Infisical offers two types of migration approaches: + +- **In-Platform Migration Tooling**: Configure platform connections to enable granular, on-demand imports of secrets, policies, and configurations directly within the Infisical UI. This allows you to migrate resources incrementally as needed. + +- **Bulk Data Import**: Perform one-time organization-level migrations to import all resources from external platforms at once. This is ideal for initial migrations when moving entirely to Infisical. ## Supported Platforms - [EnvKey](./envkey) - [Vault](./vault) -We're always looking to add more migration paths for other providers. If we're missing a platform, please open an issue on our [GitHub repository](https://github.com/infisical/infisical/issues). \ No newline at end of file +We're always looking to add more migration paths for other providers. If we're missing a platform, please open an issue on our [GitHub repository](https://github.com/infisical/infisical/issues). diff --git a/docs/documentation/platform/external-migrations/vault.mdx b/docs/documentation/platform/external-migrations/vault.mdx index 8254e104f..e267cc5e9 100644 --- a/docs/documentation/platform/external-migrations/vault.mdx +++ b/docs/documentation/platform/external-migrations/vault.mdx @@ -1,40 +1,239 @@ --- title: "Migrating from Vault to Infisical" sidebarTitle: "Vault" -description: "Learn how to migrate secrets from Vault to Infisical." +description: "Learn how to migrate resources from Vault to Infisical." --- -## Migrating from Vault +Infisical provides two approaches for migrating from HashiCorp Vault. -Migrating from Vault Self-Hosted or Dedicated Vault is a straight forward process with our inbuilt migration option. In order to migrate from Vault, you'll need to provide Infisical an access token to your Vault instance. +### Which approach should I use? -Currently the Vault migration only supports migrating secrets from the KV V2 and V1 secrets engine. If you're using a different secrets engine, please open an issue on our [GitHub repository](https://github.com/infisical/infisical/issues). +**Choose In-Platform Migration Tooling if you want to:** +- Migrate specific secrets, not everything at once +- Import secrets into existing Infisical projects +- Translate Vault policies to Infisical access controls +- Import Kubernetes authentication configurations +- Have more control over the migration process -### Prerequisites +**Choose Bulk Data Import if you want to:** -- A Vault instance with the KV secret engine enabled. -- An access token to your Vault instance. - - -### Project Mapping - -When migrating from Vault, you'll need to choose how you want to map your Vault resources to Infisical projects. - -There are two options for project mapping: - -- `Namespace`: This will map your selected Vault namespace to a single Infisical project. When you select this option, each KV secret engine within the namespace will be mapped to a single Infisical project. Each KV secret engine will be mapped to a Infisical environment within the project. This means if you have 3 KV secret engines, you'll have 3 environments inside the same project, where the name of the environments correspond to the name of the KV secret engines. -- `Key Vault`: This will map all the KV secret engines within your Vault instance to a Infisical project. Each KV engine will be created as a Infisical project. This means if you have 3 KV secret engines, you'll have 3 Infisical projects. For each of the created projects, a single default environment will be created called `Production`, which will contain all your secrets from the corresponding KV secret engine. +- Migrate all secrets from Vault in one go +- Automatically create new Infisical projects from your Vault structure +- Perform a one-time migration when moving entirely from Vault to Infisical +## In-Platform Migration Tooling +This migration approach lets you set up a connection to your Vault instance once, then import specific resources as needed throughout Infisical. +### Step 1: Set Up Your Vault Connection - In order to migrate from Vault, you'll need to create a Vault policy that allows Infisical to read the secrets and metadata from the KV v2 secrets engines within your Vault instance. + In your Vault instance, create a policy that allows Infisical to read your secrets, policies, and authentication configurations. This policy grants read-only access and doesn't allow Infisical to modify anything in Vault. + + ```hcl + # System endpoints - for listing namespaces, policies, mounts, and auth methods + path "sys/namespaces" { + capabilities = ["list"] + } - ```python + path "sys/policy" { + capabilities = ["read", "list"] + } + + path "sys/policy/*" { + capabilities = ["read"] + } + + path "sys/mounts" { + capabilities = ["read"] + } + + path "sys/auth" { + capabilities = ["read"] + } + + # KV v2 secrets - for listing and reading secrets + # Replace '+' with your actual KV v2 mount paths (e.g., "secret", "kv") + path "+/metadata/*" { + capabilities = ["list", "read"] + } + + path "+/data/*" { + capabilities = ["read"] + } + + # KV v1 secrets - for listing and reading secrets + # Replace '+' with your actual KV v1 mount paths (e.g., "secret", "kv-v1") + # WARNING: This is broad - ideally specify exact mount names + path "+/*" { + capabilities = ["list", "read"] + } + + # Kubernetes auth - for reading auth configuration and roles + path "auth/+/config" { + capabilities = ["read"] + } + + path "auth/+/role" { + capabilities = ["list"] + } + + path "auth/+/role/*" { + capabilities = ["read"] + } + ``` + + + Save this policy in Vault with the name `infisical-in-platform-migration`. + + + + + In Infisical, navigate to **Organization Settings > App Connections** and create a new HashiCorp Vault connection. + + Follow the [HashiCorp Vault App Connection documentation](/integrations/app-connections/hashicorp-vault) for detailed setup instructions. When configuring authentication (Token or AppRole), make sure it uses the `infisical-in-platform-migration` policy you created. + + + + + Navigate to **Organization Settings > External Migrations** in Infisical. + + Under the "In-Platform Migration Tooling" section for HashiCorp Vault, click **"+ Add Namespace"**. + + ![In-Platform Migration Tooling](/images/platform/external-migrations/vault-in-platform/external-migration-overview.png) + + Configure your namespace: + + ![Namespace Configuration](/images/platform/external-migrations/vault-in-platform/namespace-configuration-modal.png) + + - **Namespace**: Enter your Vault namespace path (e.g., `admin/namespace1`). If you intend to use the root namespace, set the namespace value to "root". + - **Connection**: Select the App Connection you created in the previous step. + + + You can add multiple namespaces with different connections if you have multiple Vault instances or namespaces to migrate from. + + + + + +### Step 2: Import Your Resources + +Once your Vault connection is configured, you'll see import options throughout Infisical wherever relevant. Here's what you can import: + +#### Import Secrets into a Project + +You can import secrets from Vault directly into a specific environment and secret path: + +1. Navigate to your project and select a specific environment (e.g., Development, Production) +2. In the secrets view, click the dropdown icon (caret) next to the **"+ Add Secret"** button +3. Select **"Add from HashiCorp Vault"** + + ![Import Vault Secrets](/images/platform/external-migrations/vault-in-platform/import-vault-secrets-modal.png) + +4. Choose your Vault namespace and the secret path you want to import +5. Click **"Import Secrets"** + +The secrets will be imported into your current environment and folder path. + +#### Import Kubernetes Authentication Configurations + +When setting up Kubernetes authentication for a machine identity, you can import the configuration from Vault: + +1. Navigate to **Access Control > Machine Identities** and select an identity +2. Click **"Add Authentication Method"** and choose **Kubernetes Auth** +3. In the configuration modal, click **"Load from Vault"** + + ![Load Kubernetes Auth from Vault](/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-auth-modal.png) + +4. Select your Vault namespace and the Kubernetes role +5. Click **"Load"** + + ![Kubernetes Auth Form Populated](/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-auth-modal-form.png) + +The authentication settings (service accounts, TTL, policies, etc.) will be automatically populated from your Vault configuration. + + + Sensitive values like service account JWTs cannot be retrieved from Vault and + must be manually provided in the form after importing the configuration. + + +#### Import and Translate Access Control Policies + +When configuring project role-based access control, you can import Vault HCL policies and automatically translate them to Infisical permissions. + + + Policy translation is best-effort and provides a starting point based on your + Vault configuration. The translated permissions should be reviewed and + adjusted as needed since Vault and Infisical have different access control + models. Infisical will analyze path patterns and capabilities to suggest + equivalent permissions. + + +**To import and translate a policy:** + +1. Navigate to your project, then go to **Access Control > Roles** and create or edit a role +2. In the policy configuration, click **"Add from HashiCorp Vault"** + + ![Import Vault Policy Button](/images/platform/external-migrations/vault-in-platform/translate-vault-policy-toggle.png) + +3. Select your Vault namespace +4. Either choose an existing policy from the dropdown or paste your own HCL policy + + ![Translate Vault Policy Modal](/images/platform/external-migrations/vault-in-platform/translate-vault-policy-modal.png) + +5. Review the automatically translated Infisical permissions +6. Make any adjustments and save + + + **How policy translation works:** + + - Vault path patterns are analyzed to identify KV secret engines and environments + - Vault capabilities (`read`,`list`, `create`, etc.) are mapped to Infisical permissions + - Wildcards in paths are converted to glob patterns + - Secret paths are preserved for granular access control + + Always review the translated permissions carefully, as Vault's capability-based model may not map 1:1 with Infisical's permission structure. + + +--- + +## Bulk Data Import + +This migration approach imports all secrets from your Vault instance in one operation and automatically creates new Infisical projects based on your Vault structure. + +### Understanding Project Mapping + +Before starting the bulk import, you need to decide how your Vault structure will map to Infisical projects: + + + Each Vault namespace becomes a single Infisical project, with each KV secret engine becoming an environment within that project. + + **Example:** If you have a namespace with 3 KV secret engines (`dev-secrets`, `staging-secrets`, `prod-secrets`): + + - Creates: 1 Infisical project + - Environments: 3 (`dev-secrets`, `staging-secrets`, `prod-secrets`) + + + + Each KV secret engine becomes its own Infisical project with a single `Production` environment. + + **Example:** If you have 3 KV secret engines (`dev-secrets`, `staging-secrets`, `prod-secrets`): + + - Creates: 3 Infisical projects (`dev-secrets`, `staging-secrets`, `prod-secrets`) + - Each project has: 1 environment (`Production`) + + +### How to Perform a Bulk Import + + + + In your Vault instance, create a policy that allows Infisical to read all secrets and metadata. This policy grants read-only access. + + + ```hcl # Allow listing secret engines/mounts path "sys/mounts" { capabilities = ["read", "list"] @@ -63,65 +262,54 @@ There are two options for project mapping: capabilities = ["read", "list"] } ``` + - Save this policy with the name `infisical-migration`. + Save this policy in Vault with the name `infisical-bulk-migration`. - You can use the Vault CLI to easily generate an access token for the new `infisical-migration` policy that you created in the previous step. + Use the Vault CLI to generate an access token: ```bash - vault token create --policy="infisical-migration" + vault token create --policy="infisical-bulk-migration" ``` - After generating the token, you should see the following output: + Copy the `token` value from the output - you'll need it in the next step. - ```t - $ vault token create --policy="infisical-migration" - - Key Value - --- ----- - token - token_accessor p6kJDiBSzYYdabJUIpGCsCBm - token_duration 768h - token_renewable true - token_policies ["default" "infisical-migration"] - identity_policies [] - policies ["default" "infisical-migration"] - ``` - - Copy the `token` field and save it for later, as you'll need this when configuring the migration to Infisical. - - Open the Infisical dashboard and go to Organization Settings > External Migrations. + + In Infisical, navigate to **Organization Settings > External Migrations**. ![Infisical Organization settings](/images/platform/external-migrations/infisical-external-migration-dashboard.png) + + Under the "Bulk Data Import" section, click **"+ Import"**. + - - Select the Vault platform and click on Next. + + Select **HashiCorp Vault** as the migration source and click **Next**. ![Select Vault platform](/images/platform/external-migrations/infisical-import-vault-modal.png) + - - Enter the Vault access token that you generated in the previous step and click Import data. + + Fill in your Vault connection details: ![Configure Vault migration](/images/platform/external-migrations/infisical-import-vault.png) - - `Vault URL`: The URL of your Vault instance. - - `Vault Namespace`: The namespace of your Vault instance. This is optional, and can be left blank if you're not using namespaces for your Vault instance. - - `Vault Access Token`: The access token that you generated in the previous step. + - **Vault URL**: Your Vault instance URL (e.g., `https://vault.example.com`) + - **Vault Namespace**: Optional - only needed if using Vault Enterprise namespaces + - **Vault Access Token**: The token you generated in step 2 + - **Project Mapping**: Choose how to structure your Infisical projects (see [Understanding Project Mapping](#understanding-project-mapping)) - - `Project Mapping`: Choose how you want to map your Vault resources to Infisical projects. You can review the mapping options in the [Project Mapping](#project-mapping) section. + Click **"Import Data"** to start the migration. - Click on Import data to start the migration. + + The import runs in the background and may take several minutes. You'll receive an email when it completes. + - - - It may take several minutes to complete the migration. You will receive an email when the migration is complete, or if there were any errors during the migration process. - \ No newline at end of file diff --git a/docs/documentation/platform/gateways-deprecated/networking.mdx b/docs/documentation/platform/gateways-deprecated/networking.mdx index 6acdc1993..51a81ee42 100644 --- a/docs/documentation/platform/gateways-deprecated/networking.mdx +++ b/docs/documentation/platform/gateways-deprecated/networking.mdx @@ -3,7 +3,7 @@ title: "Networking" description: "Network configuration and firewall requirements for Infisical Gateway" --- -The Infisical Gateway requires outbound network connectivity to establish secure communication with Infisical's relay infrastructure. +The Infisical Gateway requires outbound network connectivity to establish secure communication with Infisical's relay infrastructure. This page outlines the required ports, protocols, and firewall configurations needed for optimal gateway usage. ## Network Architecture @@ -67,11 +67,11 @@ The gateway uses QUIC (Quick UDP Internet Connections) for primary communication ## Understanding Firewall Behavior with UDP Unlike TCP connections, UDP is a stateless protocol, and depending on your organization's firewall configuration, you may need to adjust network rules accordingly. -When the gateway sends UDP packets to a relay server, the return responses need to be allowed back through the firewall. -Modern firewalls handle this through "connection tracking" (also called "stateful inspection"), but the behavior can vary depending on your firewall configuration. +When the gateway sends UDP packets to a relay server, the return responses need to be allowed back through the firewall. +Modern firewalls handle this through "connection tracking" (also called "stateful inspection"), but the behavior can vary depending on your firewall configuration. -### Connection Tracking +### Connection Tracking Modern firewalls automatically track UDP connections and allow return responses. This is the preferred configuration as it: - Automatically handles return responses @@ -100,6 +100,7 @@ Configure security groups to allow: ## Frequently Asked Questions + The gateway is designed to handle network interruptions gracefully: @@ -139,7 +140,7 @@ This design maintains security by avoiding the need for inbound firewall rules t If your firewall has strict UDP restrictions: 1. **Work with your network team** to allow outbound UDP to the specific relay IP addresses -2. **Use explicit IP whitelisting** if connection tracking is disabled +2. **Use explicit IP whitelisting** if connection tracking is disabled 3. **Consider network policy exceptions** for the gateway host 4. **Monitor firewall logs** to identify which specific rules are blocking traffic @@ -161,8 +162,9 @@ No, relay servers cannot decrypt any traffic passing through them: - **End-to-end encryption**: All traffic between the gateway and Infisical Cloud is encrypted using mutual TLS with certificate pinning - **Relay acts as a tunnel**: The relay server only forwards encrypted packets - it has no access to encryption keys -- **No data storage**: Relay servers do not store any traffic or network-identifiable information +- **No data storage**: Relay servers do not store any traffic or network-identifiable information - **Certificate isolation**: Each organization has its own private PKI system, ensuring complete tenant isolation The relay infrastructure is designed as a secure forwarding mechanism, similar to a VPN tunnel, where the relay provider cannot see the contents of the traffic flowing through it. - \ No newline at end of file + + diff --git a/docs/documentation/platform/gateways/gateway-deployment.mdx b/docs/documentation/platform/gateways/gateway-deployment.mdx index cef258c41..9a5b7d816 100644 --- a/docs/documentation/platform/gateways/gateway-deployment.mdx +++ b/docs/documentation/platform/gateways/gateway-deployment.mdx @@ -3,7 +3,7 @@ title: "Gateway Deployment" description: "Complete guide to deploying Infisical Gateways including network configuration and firewall requirements" --- -Infisical Gateways enables secure communication between your private resources and the Infisical platform without exposing inbound ports in your network. +Infisical Gateways enables secure communication between your private resources and the Infisical platform without exposing inbound ports in your network. This guide covers everything you need to deploy and configure Infisical Gateways. ## Deployment Steps @@ -18,73 +18,73 @@ To successfully deploy an Infisical Gateway for use, follow these steps in order Simple and secure authentication using client ID and client secret. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=universal-auth` - `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID=` - `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET=` - + Direct authentication using a machine identity access token. - + **Environment Variables:** - `INFISICAL_TOKEN=` - + Authentication using Kubernetes service account tokens. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=kubernetes` - `INFISICAL_MACHINE_IDENTITY_ID=` - + Authentication using AWS IAM roles. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=aws-iam` - `INFISICAL_MACHINE_IDENTITY_ID=` - + Authentication using GCP identity tokens. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=gcp-id-token` - `INFISICAL_MACHINE_IDENTITY_ID=` - + Authentication using GCP service account keys. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=gcp-iam` - `INFISICAL_MACHINE_IDENTITY_ID=` - `INFISICAL_GCP_SERVICE_ACCOUNT_KEY_FILE_PATH=` - + Authentication using Azure managed identity. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=azure` - `INFISICAL_MACHINE_IDENTITY_ID=` - + Authentication using OIDC identity tokens. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=oidc-auth` - `INFISICAL_MACHINE_IDENTITY_ID=` - `INFISICAL_JWT=` - + Authentication using JWT tokens. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=jwt-auth` - `INFISICAL_MACHINE_IDENTITY_ID=` @@ -111,7 +111,7 @@ To successfully deploy an Infisical Gateway for use, follow these steps in order | TCP | Infisical instance host (US/EU, other) | 443 | API communication and certificate requests | For managed relays, allow outbound traffic to the provided relay server IP/hostname. For self-hosted relays, allow outbound traffic to your own relay server address. - + If you are in a corporate environment with strict egress filtering, ensure outbound TCP 2222 to relay servers and outbound HTTPS 443 to Infisical API endpoints are allowed. @@ -185,10 +185,9 @@ To successfully deploy an Infisical Gateway for use, follow these steps in order - - - ## Frequently Asked Questions + + No inbound ports need to be opened for gateways. The gateway only makes outbound connections: @@ -263,3 +262,4 @@ The gateway is designed to handle network interruptions gracefully: No manual intervention is typically required during network interruptions. + diff --git a/docs/documentation/platform/gateways/relay-deployment.mdx b/docs/documentation/platform/gateways/relay-deployment.mdx index adf5fdb9d..767cf3732 100644 --- a/docs/documentation/platform/gateways/relay-deployment.mdx +++ b/docs/documentation/platform/gateways/relay-deployment.mdx @@ -32,73 +32,73 @@ To successfully deploy an Infisical Relay for use, follow these steps in order. Simple and secure authentication using client ID and client secret. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=universal-auth` - `INFISICAL_UNIVERSAL_AUTH_CLIENT_ID=` - `INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET=` - + Direct authentication using a machine identity access token. - + **Environment Variables:** - `INFISICAL_TOKEN=` - + Authentication using Kubernetes service account tokens. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=kubernetes` - `INFISICAL_MACHINE_IDENTITY_ID=` - + Authentication using AWS IAM roles. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=aws-iam` - `INFISICAL_MACHINE_IDENTITY_ID=` - + Authentication using GCP identity tokens. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=gcp-id-token` - `INFISICAL_MACHINE_IDENTITY_ID=` - + Authentication using GCP service account keys. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=gcp-iam` - `INFISICAL_MACHINE_IDENTITY_ID=` - `INFISICAL_GCP_SERVICE_ACCOUNT_KEY_FILE_PATH=` - + Authentication using Azure managed identity. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=azure` - `INFISICAL_MACHINE_IDENTITY_ID=` - + Authentication using OIDC identity tokens. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=oidc-auth` - `INFISICAL_MACHINE_IDENTITY_ID=` - `INFISICAL_JWT=` - + Authentication using JWT tokens. - + **Environment Variables:** - `INFISICAL_AUTH_METHOD=jwt-auth` - `INFISICAL_MACHINE_IDENTITY_ID=` @@ -168,7 +168,6 @@ To successfully deploy an Infisical Relay for use, follow these steps in order. ```bash infisical relay start \ - --type= \ --host= \ --name= \ --auth-method= @@ -185,6 +184,7 @@ To successfully deploy an Infisical Relay for use, follow these steps in order. ## Frequently Asked Questions + No, relay servers cannot decrypt any traffic passing through them due to end-to-end encryption: @@ -241,3 +241,4 @@ Relay server outages affect gateway connectivity: For production environments, consider deploying multiple relay servers to avoid single points of failure. + diff --git a/docs/images/app-connections/laravel-forge/api-token-create-form.png b/docs/images/app-connections/laravel-forge/api-token-create-form.png new file mode 100644 index 000000000..895da4d77 Binary files /dev/null and b/docs/images/app-connections/laravel-forge/api-token-create-form.png differ diff --git a/docs/images/app-connections/laravel-forge/api-token-generated.png b/docs/images/app-connections/laravel-forge/api-token-generated.png new file mode 100644 index 000000000..8edc62261 Binary files /dev/null and b/docs/images/app-connections/laravel-forge/api-token-generated.png differ diff --git a/docs/images/app-connections/laravel-forge/app-connection-create-api-token.png b/docs/images/app-connections/laravel-forge/app-connection-create-api-token.png new file mode 100644 index 000000000..69fe3b2b3 Binary files /dev/null and b/docs/images/app-connections/laravel-forge/app-connection-create-api-token.png differ diff --git a/docs/images/app-connections/laravel-forge/app-connection-form.png b/docs/images/app-connections/laravel-forge/app-connection-form.png new file mode 100644 index 000000000..e03289a65 Binary files /dev/null and b/docs/images/app-connections/laravel-forge/app-connection-form.png differ diff --git a/docs/images/app-connections/laravel-forge/app-connection-generated.png b/docs/images/app-connections/laravel-forge/app-connection-generated.png new file mode 100644 index 000000000..badc5d8c5 Binary files /dev/null and b/docs/images/app-connections/laravel-forge/app-connection-generated.png differ diff --git a/docs/images/app-connections/laravel-forge/app-connection-option.png b/docs/images/app-connections/laravel-forge/app-connection-option.png new file mode 100644 index 000000000..50ac89c27 Binary files /dev/null and b/docs/images/app-connections/laravel-forge/app-connection-option.png differ diff --git a/docs/images/app-connections/laravel-forge/app-connection-profile.png b/docs/images/app-connections/laravel-forge/app-connection-profile.png new file mode 100644 index 000000000..18e66a5a5 Binary files /dev/null and b/docs/images/app-connections/laravel-forge/app-connection-profile.png differ diff --git a/docs/images/dynamic-secret-crd.png b/docs/images/dynamic-secret-crd.png new file mode 100644 index 000000000..8b20dbd2e Binary files /dev/null and b/docs/images/dynamic-secret-crd.png differ diff --git a/docs/images/dynamic-secret.png b/docs/images/dynamic-secret.png new file mode 100644 index 000000000..812b2dfc3 Binary files /dev/null and b/docs/images/dynamic-secret.png differ diff --git a/docs/images/platform/external-migrations/vault-in-platform/external-migration-overview.png b/docs/images/platform/external-migrations/vault-in-platform/external-migration-overview.png new file mode 100644 index 000000000..3f2be6217 Binary files /dev/null and b/docs/images/platform/external-migrations/vault-in-platform/external-migration-overview.png differ diff --git a/docs/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-auth-modal-form.png b/docs/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-auth-modal-form.png new file mode 100644 index 000000000..230393be6 Binary files /dev/null and b/docs/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-auth-modal-form.png differ diff --git a/docs/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-auth-modal.png b/docs/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-auth-modal.png new file mode 100644 index 000000000..e3c41d187 Binary files /dev/null and b/docs/images/platform/external-migrations/vault-in-platform/import-vault-kubernetes-auth-modal.png differ diff --git a/docs/images/platform/external-migrations/vault-in-platform/import-vault-secrets-modal.png b/docs/images/platform/external-migrations/vault-in-platform/import-vault-secrets-modal.png new file mode 100644 index 000000000..55185db37 Binary files /dev/null and b/docs/images/platform/external-migrations/vault-in-platform/import-vault-secrets-modal.png differ diff --git a/docs/images/platform/external-migrations/vault-in-platform/namespace-configuration-modal.png b/docs/images/platform/external-migrations/vault-in-platform/namespace-configuration-modal.png new file mode 100644 index 000000000..828b82544 Binary files /dev/null and b/docs/images/platform/external-migrations/vault-in-platform/namespace-configuration-modal.png differ diff --git a/docs/images/platform/external-migrations/vault-in-platform/translate-vault-policy-modal.png b/docs/images/platform/external-migrations/vault-in-platform/translate-vault-policy-modal.png new file mode 100644 index 000000000..34955b03f Binary files /dev/null and b/docs/images/platform/external-migrations/vault-in-platform/translate-vault-policy-modal.png differ diff --git a/docs/images/platform/external-migrations/vault-in-platform/translate-vault-policy-toggle.png b/docs/images/platform/external-migrations/vault-in-platform/translate-vault-policy-toggle.png new file mode 100644 index 000000000..a165fff77 Binary files /dev/null and b/docs/images/platform/external-migrations/vault-in-platform/translate-vault-policy-toggle.png differ diff --git a/docs/images/push-secret.png b/docs/images/push-secret.png new file mode 100644 index 000000000..a70228434 Binary files /dev/null and b/docs/images/push-secret.png differ diff --git a/docs/images/secret-syncs/laravel-forge/select-option.png b/docs/images/secret-syncs/laravel-forge/select-option.png new file mode 100644 index 000000000..a10f3a437 Binary files /dev/null and b/docs/images/secret-syncs/laravel-forge/select-option.png differ diff --git a/docs/images/secret-syncs/laravel-forge/sync-created.png b/docs/images/secret-syncs/laravel-forge/sync-created.png new file mode 100644 index 000000000..3e85f0879 Binary files /dev/null and b/docs/images/secret-syncs/laravel-forge/sync-created.png differ diff --git a/docs/images/secret-syncs/laravel-forge/sync-destination.png b/docs/images/secret-syncs/laravel-forge/sync-destination.png new file mode 100644 index 000000000..735471a7a Binary files /dev/null and b/docs/images/secret-syncs/laravel-forge/sync-destination.png differ diff --git a/docs/images/secret-syncs/laravel-forge/sync-details.png b/docs/images/secret-syncs/laravel-forge/sync-details.png new file mode 100644 index 000000000..3588f88db Binary files /dev/null and b/docs/images/secret-syncs/laravel-forge/sync-details.png differ diff --git a/docs/images/secret-syncs/laravel-forge/sync-options.png b/docs/images/secret-syncs/laravel-forge/sync-options.png new file mode 100644 index 000000000..0c83e0a19 Binary files /dev/null and b/docs/images/secret-syncs/laravel-forge/sync-options.png differ diff --git a/docs/images/secret-syncs/laravel-forge/sync-review.png b/docs/images/secret-syncs/laravel-forge/sync-review.png new file mode 100644 index 000000000..2d617a8b0 Binary files /dev/null and b/docs/images/secret-syncs/laravel-forge/sync-review.png differ diff --git a/docs/images/secret-syncs/laravel-forge/sync-source.png b/docs/images/secret-syncs/laravel-forge/sync-source.png new file mode 100644 index 000000000..5bffb0ba9 Binary files /dev/null and b/docs/images/secret-syncs/laravel-forge/sync-source.png differ diff --git a/docs/integrations/app-connections/laravel-forge.mdx b/docs/integrations/app-connections/laravel-forge.mdx new file mode 100644 index 000000000..67ce55270 --- /dev/null +++ b/docs/integrations/app-connections/laravel-forge.mdx @@ -0,0 +1,107 @@ +--- +title: "Laravel Forge Connection" +description: "Learn how to configure a Laravel Forge Connection for Infisical." +--- + +Infisical supports the use of [API Tokens](https://forge.laravel.com/docs/api#create-a-new-api-token) to connect with Laravel Forge. + +## Create Laravel Forge API Token + + + + ![Laravel Forge User Settings](/images/app-connections/laravel-forge/app-connection-profile.png) + + + ![Applications Tab](/images/app-connections/laravel-forge/app-connection-create-api-token.png) + + + Provide a name for your token and select the following permissions: + - `user:view` + - `organization:view` + - `server:view` + - `site:manage-environment` + + Then click 'Add token'. + + ![Token Form](/images/app-connections/laravel-forge/api-token-create-form.png) + + + + Make sure to copy the token now—you won’t be able to access it again. + + ![Token Generated](/images/app-connections/laravel-forge/api-token-generated.png) + + + + +## Create a Laravel Forge Connection in Infisical + + + + + + 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 **Laravel Forge** Connection from the list of integrations. + ![Select Laravel Forge Connection](/images/app-connections/laravel-forge/app-connection-option.png) + + + Complete the form by providing: + - A descriptive name for the connection + - An optional description + - The API Token from the previous step + ![Laravel Forge Connection Modal](/images/app-connections/laravel-forge/app-connection-form.png) + + + After submitting the form, your **Laravel Forge Connection** will be successfully created and ready to use with your Infisical project. + ![Laravel Forge Connection Created](/images/app-connections/laravel-forge/app-connection-generated.png) + + + + + + + To create a Laravel Forge Connection via API, send a request to the [Create Laravel Forge Connection](/api-reference/endpoints/app-connections/laravel-forge/create) endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/app-connections/laravel-forge \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-laravel-forge-connection", + "method": "api-token", + "projectId": "7ffbb072-2575-495a-b5b0-127f88caef78", + "credentials": { + "apiToken": "[API TOKEN]" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "appConnection": { + "id": "a1b2c3d4-5678-90ab-cdef-1234567890ab", + "name": "my-laravel-forge-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": "laravel-forge", + "method": "api-token", + "credentials": {} + } + } + ``` + + + diff --git a/docs/integrations/secret-syncs/laravel-forge.mdx b/docs/integrations/secret-syncs/laravel-forge.mdx new file mode 100644 index 000000000..c93abb94a --- /dev/null +++ b/docs/integrations/secret-syncs/laravel-forge.mdx @@ -0,0 +1,157 @@ +--- +title: "Laravel Forge Sync" +description: "Learn how to configure a Laravel Forge Sync for Infisical." +--- + +**Prerequisites:** + +- Create a [Laravel Forge Connection](/integrations/app-connections/laravel-forge) + + + + + + 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 Laravel Forge](/images/secret-syncs/laravel-forge/select-option.png) + + + Configure the **Source** from where secrets should be retrieved, then click **Next**. + + ![Configure Source](/images/secret-syncs/laravel-forge/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/laravel-forge/sync-destination.png) + + - **Laravel Forge Connection**: The Laravel Forge Connection to authenticate with. + - **Organization**: The Organization in which the server and site reside. + - **Server**: The Server on which the site resides. + - **Site**: The Site for which secrets should be synced. + + + Configure the **Sync Options** to specify how secrets should be synced, then click **Next**. + + ![Configure Options](/images/secret-syncs/laravel-forge/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 Laravel Forge when keys conflict. + - **Import Secrets (Prioritize Laravel Forge)**: Imports secrets from the destination endpoint before syncing, prioritizing values from Laravel Forge 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 Laravel Forge Sync, then click **Next**. + + ![Configure Details](/images/secret-syncs/laravel-forge/sync-details.png) + + - **Name**: The name of your sync. Must be slug-friendly. + - **Description**: An optional description for your sync. + + + Review your Laravel Forge Sync configuration, then click **Create Sync**. + + ![Review Configuration](/images/secret-syncs/laravel-forge/sync-review.png) + + + If enabled, your Laravel Forge Sync will begin syncing your secrets to the destination endpoint. + + ![Sync Created](/images/secret-syncs/laravel-forge/sync-created.png) + + + + + + + To create a **Laravel Forge Sync**, make an API request to the [Create Laravel Forge Sync](/api-reference/endpoints/secret-syncs/laravel-forge/create) API endpoint. + + ### Sample request + + ```bash Request + curl --request POST \ + --url https://app.infisical.com/api/v1/secret-syncs/laravel-forge \ + --header 'Content-Type: application/json' \ + --data '{ + "name": "my-laravel-forge-sync", + "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "description": "sync to laravel forge site", + "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "environment": "dev", + "secretPath": "/", + "isEnabled": true, + "isAutoSyncEnabled": true, + "syncOptions": { + "initialSyncBehavior": "overwrite-destination", + "disableSecretDeletion": false + }, + "destinationConfig": { + "orgSlug": "org-abc123", + "serverId": "123", + "siteId": "site-abc123" + } + }' + ``` + + ### Sample response + + ```bash Response + { + "secretSync": { + "id": "3c90c3cc-0d44-4b50-8888-8dd25736052a", + "name": "my-laravel-forge-sync", + "description": "sync to laravel forge 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": "laravel-forge", + "name": "my-laravel-forge-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": "laravel-forge", + "destinationConfig": { + "orgSlug": "org-abc123", + "serverId": "123", + "siteId": "site-abc123" + } + } + } + ``` + + + diff --git a/docs/integrations/secret-syncs/netlify.mdx b/docs/integrations/secret-syncs/netlify.mdx index fd98fac41..c1c51d452 100644 --- a/docs/integrations/secret-syncs/netlify.mdx +++ b/docs/integrations/secret-syncs/netlify.mdx @@ -78,6 +78,7 @@ description: "Learn how to configure a Netlify Sync for Infisical." ![Sync Created](/images/secret-syncs/netlify/sync-created.png) + @@ -157,5 +158,6 @@ description: "Learn how to configure a Netlify Sync for Infisical." } } ``` + diff --git a/docs/internals/permissions/project-permissions.mdx b/docs/internals/permissions/project-permissions.mdx index 3a4dd11e6..0f4736d1a 100644 --- a/docs/internals/permissions/project-permissions.mdx +++ b/docs/internals/permissions/project-permissions.mdx @@ -291,6 +291,16 @@ Supports conditions and permission inversion | `create` | Issue new certificates | | `delete` | Revoke or remove certificates | +#### Subject: `certificate-profiles` + +| Action | Description | +| -------- | -------------------------------- | +| `read` | View certificate profiles | +| `create` | Create new certificate profiles | +| `edit` | Modify profile configurations | +| `delete` | Remove certificate profiles | +| `issue-cert` | Issue new certificates | + #### Subject: `certificate-templates` | Action | Description | diff --git a/docs/snippets/AppConnectionsBrowser.jsx b/docs/snippets/AppConnectionsBrowser.jsx index 951a643dd..cfc65d1fd 100644 --- a/docs/snippets/AppConnectionsBrowser.jsx +++ b/docs/snippets/AppConnectionsBrowser.jsx @@ -45,7 +45,8 @@ export const AppConnectionsBrowser = () => { {"name": "Redis", "slug": "redis", "path": "/integrations/app-connections/redis", "description": "Learn how to connect Redis to pull secrets from Infisical.", "category": "Databases"}, {"name": "LDAP", "slug": "ldap", "path": "/integrations/app-connections/ldap", "description": "Learn how to connect your LDAP to pull secrets from Infisical.", "category": "Directory Services"}, {"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": "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"}, ].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 d32f5ac04..3598bf68e 100644 --- a/docs/snippets/SecretSyncsBrowser.jsx +++ b/docs/snippets/SecretSyncsBrowser.jsx @@ -36,7 +36,8 @@ export const SecretSyncsBrowser = () => { {"name": "Camunda", "slug": "camunda", "path": "/integrations/secret-syncs/camunda", "description": "Learn how to sync secrets from Infisical to Camunda.", "category": "DevOps Tools"}, {"name": "Humanitec", "slug": "humanitec", "path": "/integrations/secret-syncs/humanitec", "description": "Learn how to sync secrets from Infisical to Humanitec.", "category": "DevOps Tools"}, {"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": "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"} ].sort(function(a, b) { return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); }); diff --git a/frontend/src/components/features/WishForm.tsx b/frontend/src/components/features/WishForm.tsx index fc38d0731..118900bc9 100644 --- a/frontend/src/components/features/WishForm.tsx +++ b/frontend/src/components/features/WishForm.tsx @@ -63,14 +63,14 @@ export const WishForm = () => { open={isOpen} > -
+
Request a feature
{ {`${destinationDetails.name}
diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/LaravelForgeSyncFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/LaravelForgeSyncFields.tsx new file mode 100644 index 000000000..90409da4f --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/LaravelForgeSyncFields.tsx @@ -0,0 +1,138 @@ +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 { + TLaravelForgeOrganization, + TLaravelForgeServer, + TLaravelForgeSite, + useLaravelForgeConnectionListOrganizations, + useLaravelForgeConnectionListServers, + useLaravelForgeConnectionListSites +} from "@app/hooks/api/appConnections/laravel-forge"; +import { SecretSync } from "@app/hooks/api/secretSyncs"; + +import { TSecretSyncForm } from "../schemas"; + +export const LaravelForgeSyncFields = () => { + const { control, setValue } = useFormContext< + TSecretSyncForm & { destination: SecretSync.LaravelForge } + >(); + + const connectionId = useWatch({ name: "connection.id", control }); + const orgSlug = useWatch({ name: "destinationConfig.orgSlug", control }); + const serverId = useWatch({ name: "destinationConfig.serverId", control }); + + const { data: organizations, isLoading: isOrganizationsLoading } = + useLaravelForgeConnectionListOrganizations(connectionId, { + enabled: Boolean(connectionId) + }); + + const { data: servers, isLoading: isServersLoading } = useLaravelForgeConnectionListServers( + connectionId, + orgSlug, + { + enabled: Boolean(connectionId && orgSlug) + } + ); + + const { data: sites, isLoading: isSitesLoading } = useLaravelForgeConnectionListSites( + connectionId, + orgSlug, + serverId, + { + enabled: Boolean(connectionId && orgSlug && serverId) + } + ); + + const handleChangeConnection = () => { + setValue("destinationConfig.orgSlug", ""); + setValue("destinationConfig.serverId", ""); + setValue("destinationConfig.siteId", ""); + setValue("destinationConfig.orgName", ""); + setValue("destinationConfig.serverName", ""); + setValue("destinationConfig.siteName", ""); + }; + + return ( + <> + + + ( + + org.slug === value) ?? null} + onChange={(option) => { + const selectedOrg = option as SingleValue; + onChange(selectedOrg?.slug ?? ""); + setValue("destinationConfig.orgName", selectedOrg?.name ?? ""); + setValue("destinationConfig.serverId", ""); + setValue("destinationConfig.siteId", ""); + }} + options={organizations} + placeholder="Select an organization..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> + + )} + /> + + ( + + server.id === value) ?? null} + onChange={(option) => { + const selectedServer = option as SingleValue; + onChange(selectedServer?.id ?? ""); + setValue("destinationConfig.serverName", selectedServer?.name ?? ""); + setValue("destinationConfig.siteId", ""); + }} + options={servers} + placeholder="Select a server..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> + + )} + /> + + ( + + site.id === value) ?? null} + onChange={(option) => { + const selectedSite = option as SingleValue; + onChange(selectedSite?.id ?? ""); + setValue("destinationConfig.siteName", selectedSite?.name ?? ""); + }} + options={sites} + placeholder="Select a site..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> + + )} + /> + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx index b2186fed1..61ba54369 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncDestinationFields/SecretSyncDestinationFields.tsx @@ -23,6 +23,7 @@ import { GitLabSyncFields } from "./GitLabSyncFields"; import { HCVaultSyncFields } from "./HCVaultSyncFields"; import { HerokuSyncFields } from "./HerokuSyncFields"; import { HumanitecSyncFields } from "./HumanitecSyncFields"; +import { LaravelForgeSyncFields } from "./LaravelForgeSyncFields"; import { NetlifySyncFields } from "./NetlifySyncFields"; import { OCIVaultSyncFields } from "./OCIVaultSyncFields"; import { RailwaySyncFields } from "./RailwaySyncFields"; @@ -100,6 +101,8 @@ export const SecretSyncDestinationFields = () => { return ; case SecretSync.Bitbucket: return ; + case SecretSync.LaravelForge: + return ; default: throw new Error(`Unhandled Destination Config Field: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx index 4fe457179..f66cadaea 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncOptionsFields/SecretSyncOptionsFields.tsx @@ -69,6 +69,7 @@ export const SecretSyncOptionsFields = ({ hideInitialSync }: Props) => { case SecretSync.DigitalOceanAppPlatform: case SecretSync.Netlify: case SecretSync.Bitbucket: + case SecretSync.LaravelForge: AdditionalSyncOptionsFieldsComponent = null; break; default: diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/LaravelForgeSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/LaravelForgeSyncReviewFields.tsx new file mode 100644 index 000000000..a359359f5 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/LaravelForgeSyncReviewFields.tsx @@ -0,0 +1,23 @@ +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 LaravelForgeSyncReviewFields = () => { + const { watch } = useFormContext(); + const orgName = watch("destinationConfig.orgName"); + const orgSlug = watch("destinationConfig.orgSlug"); + const serverName = watch("destinationConfig.serverName"); + const serverId = watch("destinationConfig.serverId"); + const siteName = watch("destinationConfig.siteName"); + const siteId = watch("destinationConfig.siteId"); + + return ( + <> + {orgName || orgSlug} + {serverName || serverId || "None"} + {siteName || siteId || "None"} + + ); +}; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index d6c34fb87..44d35cd2c 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -35,6 +35,7 @@ import { GitLabSyncReviewFields } from "./GitLabSyncReviewFields"; import { HCVaultSyncReviewFields } from "./HCVaultSyncReviewFields"; import { HerokuSyncReviewFields } from "./HerokuSyncReviewFields"; import { HumanitecSyncReviewFields } from "./HumanitecSyncReviewFields"; +import { LaravelForgeSyncReviewFields } from "./LaravelForgeSyncReviewFields"; import { NetlifySyncReviewFields } from "./NetlifySyncReviewFields"; import { OCIVaultSyncReviewFields } from "./OCIVaultSyncReviewFields"; import { OnePassSyncReviewFields } from "./OnePassSyncReviewFields"; @@ -168,6 +169,9 @@ export const SecretSyncReviewFields = () => { case SecretSync.Bitbucket: DestinationFieldsComponent = ; break; + case SecretSync.LaravelForge: + DestinationFieldsComponent = ; + break; default: throw new Error(`Unhandled Destination Review Fields: ${destination}`); } diff --git a/frontend/src/components/secret-syncs/forms/schemas/laravel-forge-sync-destination-schema.ts b/frontend/src/components/secret-syncs/forms/schemas/laravel-forge-sync-destination-schema.ts new file mode 100644 index 000000000..011f835b7 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/schemas/laravel-forge-sync-destination-schema.ts @@ -0,0 +1,18 @@ +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 LaravelForgeSyncDestinationSchema = BaseSecretSyncSchema().merge( + z.object({ + destination: z.literal(SecretSync.LaravelForge), + destinationConfig: z.object({ + orgSlug: z.string().trim().min(1, "Org Slug required"), + orgName: z.string().trim().min(1, "Org Name required"), + serverId: z.string().trim().min(1, "Server ID required"), + serverName: z.string().trim().min(1, "Server Name required"), + siteId: z.string().trim().min(1, "Site ID required"), + siteName: z.string().trim().min(1, "Site Name 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 f4616c711..5ebc38184 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 @@ -20,6 +20,7 @@ import { GitlabSyncDestinationSchema } from "./gitlab-sync-destination-schema"; import { HCVaultSyncDestinationSchema } from "./hc-vault-sync-destination-schema"; import { HerokuSyncDestinationSchema } from "./heroku-sync-destination-schema"; import { HumanitecSyncDestinationSchema } from "./humanitec-sync-destination-schema"; +import { LaravelForgeSyncDestinationSchema } from "./laravel-forge-sync-destination-schema"; import { NetlifySyncDestinationSchema } from "./netlify-sync-destination-schema"; import { OCIVaultSyncDestinationSchema } from "./oci-vault-sync-destination-schema"; import { RailwaySyncDestinationSchema } from "./railway-sync-destination-schema"; @@ -61,7 +62,8 @@ const SecretSyncUnionSchema = z.discriminatedUnion("destination", [ ChecklySyncDestinationSchema, DigitalOceanAppPlatformSyncDestinationSchema, NetlifySyncDestinationSchema, - BitbucketSyncDestinationSchema + BitbucketSyncDestinationSchema, + LaravelForgeSyncDestinationSchema ]); export const SecretSyncFormSchema = SecretSyncUnionSchema; diff --git a/frontend/src/components/v2/Badge/Badge.tsx b/frontend/src/components/v2/Badge/Badge.tsx index 51bbdaeaa..36541a781 100644 --- a/frontend/src/components/v2/Badge/Badge.tsx +++ b/frontend/src/components/v2/Badge/Badge.tsx @@ -20,11 +20,11 @@ const badgeVariants = cva( success: "bg-green/20 text-green", org: "bg-org-v1/20 text-org-v1 [&_svg]:text-org-v1 flex items-center opacity-100 hover:bg-org-v1/10 [&_svg]:size-3 gap-x-1 w-min whitespace-nowrap", namespace: - "bg-namespace-v1/20 text-namespace-v1 [&_svg]:text-namespace-v1 flex opacity-100 hover:bg-namespace-v1/10 items-center [&_svg]:size-3.5 gap-x-1 w-min whitespace-nowrap", + "bg-namespace-v1/20 text-namespace-v1 [&_svg]:text-namespace-v1 flex opacity-100 hover:bg-namespace-v1/10 items-center [&_svg]:size-3.5 gap-x-1.5 w-min whitespace-nowrap", project: - "bg-primary/10 text-primary [&_svg]:text-primary opacity-100 hover:bg-primary/10 flex items-center [&_svg]:size-3 gap-x-1 w-min whitespace-nowrap", + "bg-primary/10 text-primary [&_svg]:text-primary opacity-100 hover:bg-primary/10 flex items-center [&_svg]:size-3 w-min gap-x-1.5 whitespace-nowrap", instance: - "bg-mineshaft-200/20 text-mineshaft-200 [&_svg]:text-mineshaft-200 opacity-100 hover:bg-mineshaft-200/20 flex items-center [&_svg]:size-3 gap-x-1 w-min whitespace-nowrap" + "bg-mineshaft-200/20 text-mineshaft-200 [&_svg]:text-mineshaft-200 opacity-100 hover:bg-mineshaft-200/20 flex items-center [&_svg]:size-3 gap-x-1.5 w-min whitespace-nowrap" } } } diff --git a/frontend/src/components/v2/Breadcrumb/Breadcrumb.tsx b/frontend/src/components/v2/Breadcrumb/Breadcrumb.tsx index 6a0046bbf..63d2dc6f4 100644 --- a/frontend/src/components/v2/Breadcrumb/Breadcrumb.tsx +++ b/frontend/src/components/v2/Breadcrumb/Breadcrumb.tsx @@ -120,10 +120,16 @@ export type TBreadcrumbFormat = icon?: ReactNode; }; -const BreadcrumbContainer = ({ breadcrumbs }: { breadcrumbs: TBreadcrumbFormat[] }) => ( -
- - +const BreadcrumbContainer = ({ + breadcrumbs, + className +}: { + breadcrumbs: TBreadcrumbFormat[]; + className?: string; +}) => ( +
+ + {(breadcrumbs as TBreadcrumbFormat[]).map((el, index) => { const isNotLastCrumb = index + 1 !== breadcrumbs.length; const BreadcrumbSegment = isNotLastCrumb ? BreadcrumbLink : BreadcrumbPage; @@ -165,8 +171,8 @@ const BreadcrumbContainer = ({ breadcrumbs }: { breadcrumbs: TBreadcrumbFormat[] const Component = el.component; return ( - - + + diff --git a/frontend/src/components/v2/PageHeader/PageHeader.tsx b/frontend/src/components/v2/PageHeader/PageHeader.tsx index 01e710cd3..3c9e743ff 100644 --- a/frontend/src/components/v2/PageHeader/PageHeader.tsx +++ b/frontend/src/components/v2/PageHeader/PageHeader.tsx @@ -5,29 +5,48 @@ import { ReactNode } from "@tanstack/react-router"; import { twMerge } from "tailwind-merge"; import { Badge } from "@app/components/v2"; +import { BadgeProps } from "@app/components/v2/Badge/Badge"; +import { ProjectType } from "@app/hooks/api/projects/types"; type Props = { title: ReactNode; description?: ReactNode; children?: ReactNode; className?: string; - scope: "org" | "project" | "namespace" | "instance"; + scope: "org" | "namespace" | "instance" | ProjectType | null; }; const SCOPE_NAME: Record, { label: string; icon: IconDefinition }> = { org: { label: "Organization", icon: faGlobe }, - project: { label: "Project", icon: faCube }, + [ProjectType.SecretManager]: { label: "Project", icon: faCube }, + [ProjectType.CertificateManager]: { label: "Project", icon: faCube }, + [ProjectType.SSH]: { label: "Project", icon: faCube }, + [ProjectType.KMS]: { label: "Project", icon: faCube }, + [ProjectType.PAM]: { label: "Project", icon: faCube }, + [ProjectType.SecretScanning]: { label: "Project", icon: faCube }, namespace: { label: "Namespace", icon: faCubes }, instance: { label: "Server", icon: faServer } }; +const SCOPE_VARIANT: Record, BadgeProps["variant"]> = { + org: "org", + [ProjectType.SecretManager]: "project", + [ProjectType.CertificateManager]: "project", + [ProjectType.SSH]: "project", + [ProjectType.KMS]: "project", + [ProjectType.PAM]: "project", + [ProjectType.SecretScanning]: "project", + namespace: "namespace", + instance: "instance" +}; + export const PageHeader = ({ title, description, children, className, scope }: Props) => ( -
+

{title}

{scope && ( - + {SCOPE_NAME[scope].label} diff --git a/frontend/src/components/v2/Tabs/Tabs.tsx b/frontend/src/components/v2/Tabs/Tabs.tsx index 4100fd144..ebe8eb2ed 100644 --- a/frontend/src/components/v2/Tabs/Tabs.tsx +++ b/frontend/src/components/v2/Tabs/Tabs.tsx @@ -1,10 +1,19 @@ +import { IconDefinition } from "@fortawesome/free-brands-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import * as TabsPrimitive from "@radix-ui/react-tabs"; import { twMerge } from "tailwind-merge"; export type TabsProps = TabsPrimitive.TabsProps; export const Tabs = ({ className, children, ...props }: TabsProps) => ( - + {children} ); @@ -13,7 +22,11 @@ export type TabListProps = TabsPrimitive.TabsListProps; export const TabList = ({ className, children, ...props }: TabListProps) => ( {children} @@ -26,18 +39,29 @@ export const Tab = ({ className, children, variant = "project", + icon, ...props -}: TabProps & { variant?: "project" | "namespace" | "org" }) => ( +}: TabProps & { + icon?: IconDefinition; + variant?: "project" | "namespace" | "org" | "instance"; +}) => ( + {icon && } {children} ); @@ -46,7 +70,10 @@ export type TabPanelProps = TabsPrimitive.TabsContentProps; export const TabPanel = ({ className, children, ...props }: TabPanelProps) => ( {children} diff --git a/frontend/src/context/ProjectPermissionContext/index.tsx b/frontend/src/context/ProjectPermissionContext/index.tsx index a1669e18f..415a82641 100644 --- a/frontend/src/context/ProjectPermissionContext/index.tsx +++ b/frontend/src/context/ProjectPermissionContext/index.tsx @@ -4,6 +4,7 @@ export { ProjectPermissionActions, ProjectPermissionAuditLogsActions, ProjectPermissionCertificateActions, + ProjectPermissionCertificateProfileActions, ProjectPermissionCmekActions, ProjectPermissionDynamicSecretActions, ProjectPermissionGroupActions, diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index d3019409f..0236113eb 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -124,6 +124,14 @@ export enum ProjectPermissionPkiTemplateActions { ListCerts = "list-certs" } +export enum ProjectPermissionCertificateProfileActions { + Read = "read", + Create = "create", + Edit = "edit", + Delete = "delete", + IssueCert = "issue-cert" +} + export enum ProjectPermissionSecretRotationActions { Read = "read", ReadGeneratedCredentials = "read-generated-credentials", @@ -293,6 +301,7 @@ export enum ProjectPermissionSub { PkiAlerts = "pki-alerts", PkiCollections = "pki-collections", PkiSubscribers = "pki-subscribers", + CertificateProfiles = "certificate-profiles", Kms = "kms", Cmek = "cmek", SecretSyncs = "secret-syncs", @@ -470,6 +479,7 @@ export type ProjectPermissionSet = | (ForcedSubject & PkiSubscriberSubjectFields) ) ] + | [ProjectPermissionCertificateProfileActions, ProjectPermissionSub.CertificateProfiles] | [ProjectPermissionActions, ProjectPermissionSub.PkiAlerts] | [ProjectPermissionActions, ProjectPermissionSub.PkiCollections] | [ProjectPermissionActions.Delete, ProjectPermissionSub.Project] diff --git a/frontend/src/context/index.tsx b/frontend/src/context/index.tsx index fff370269..3e0f95807 100644 --- a/frontend/src/context/index.tsx +++ b/frontend/src/context/index.tsx @@ -15,6 +15,7 @@ export { ProjectPermissionActions, ProjectPermissionAuditLogsActions, ProjectPermissionCertificateActions, + ProjectPermissionCertificateProfileActions, ProjectPermissionCmekActions, ProjectPermissionDynamicSecretActions, ProjectPermissionGroupActions, diff --git a/frontend/src/helpers/appConnections.ts b/frontend/src/helpers/appConnections.ts index d0e3dcba1..bdb26c796 100644 --- a/frontend/src/helpers/appConnections.ts +++ b/frontend/src/helpers/appConnections.ts @@ -48,6 +48,7 @@ import { BitbucketConnectionMethod } from "@app/hooks/api/appConnections/types/b import { ChecklyConnectionMethod } from "@app/hooks/api/appConnections/types/checkly-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"; import { NetlifyConnectionMethod } from "@app/hooks/api/appConnections/types/netlify-connection"; import { OCIConnectionMethod } from "@app/hooks/api/appConnections/types/oci-connection"; import { RailwayConnectionMethod } from "@app/hooks/api/appConnections/types/railway-connection"; @@ -56,7 +57,13 @@ import { SupabaseConnectionMethod } from "@app/hooks/api/appConnections/types/su export const APP_CONNECTION_MAP: Record< AppConnection, - { name: string; image: string; size?: number; icon?: IconDefinition; enterprise?: boolean } + { + name: string; + image: string; + size?: number; + icon?: IconDefinition; + enterprise?: boolean; + } > = { [AppConnection.AWS]: { name: "AWS", image: "Amazon Web Services.png" }, [AppConnection.GitHub]: { name: "GitHub", image: "GitHub.png" }, @@ -115,7 +122,12 @@ export const APP_CONNECTION_MAP: Record< image: "Netlify.png" }, [AppConnection.Okta]: { name: "Okta", image: "Okta.png" }, - [AppConnection.Redis]: { name: "Redis", image: "Redis.png" } + [AppConnection.Redis]: { name: "Redis", image: "Redis.png" }, + [AppConnection.LaravelForge]: { + name: "Laravel Forge", + image: "Laravel Forge.png", + size: 65 + } }; export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) => { @@ -151,6 +163,7 @@ export const getAppConnectionMethodDetails = (method: TAppConnection["method"]) case ZabbixConnectionMethod.ApiToken: case DigitalOceanConnectionMethod.ApiToken: case OktaConnectionMethod.ApiToken: + case LaravelForgeConnectionMethod.ApiToken: return { name: "API Token", icon: faKey }; case PostgresConnectionMethod.UsernameAndPassword: case MsSqlConnectionMethod.UsernameAndPassword: diff --git a/frontend/src/helpers/project.ts b/frontend/src/helpers/project.ts index 843ded3e3..11d561e02 100644 --- a/frontend/src/helpers/project.ts +++ b/frontend/src/helpers/project.ts @@ -79,7 +79,7 @@ export const getProjectHomePage = (type: ProjectType, environments: ProjectEnv[] case ProjectType.SecretManager: return "/projects/secret-management/$projectId/overview" as const; case ProjectType.CertificateManager: - return "/projects/cert-management/$projectId/subscribers" as const; + return "/projects/cert-management/$projectId/policies" as const; case ProjectType.SecretScanning: return `/projects/${type}/$projectId/data-sources` as const; case ProjectType.PAM: diff --git a/frontend/src/helpers/secretSyncs.ts b/frontend/src/helpers/secretSyncs.ts index 6ce82cfb2..12cae6ea6 100644 --- a/frontend/src/helpers/secretSyncs.ts +++ b/frontend/src/helpers/secretSyncs.ts @@ -113,6 +113,10 @@ export const SECRET_SYNC_MAP: Record = { [SecretSync.Checkly]: AppConnection.Checkly, [SecretSync.DigitalOceanAppPlatform]: AppConnection.DigitalOcean, [SecretSync.Netlify]: AppConnection.Netlify, - [SecretSync.Bitbucket]: AppConnection.Bitbucket + [SecretSync.Bitbucket]: AppConnection.Bitbucket, + [SecretSync.LaravelForge]: AppConnection.LaravelForge }; export const SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP: Record< diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index e897cf0f0..66fed8a5d 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -37,5 +37,6 @@ export enum AppConnection { DigitalOcean = "digital-ocean", Netlify = "netlify", Okta = "okta", - Redis = "redis" + Redis = "redis", + LaravelForge = "laravel-forge" } diff --git a/frontend/src/hooks/api/appConnections/laravel-forge/index.ts b/frontend/src/hooks/api/appConnections/laravel-forge/index.ts new file mode 100644 index 000000000..2c1906d36 --- /dev/null +++ b/frontend/src/hooks/api/appConnections/laravel-forge/index.ts @@ -0,0 +1,2 @@ +export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/appConnections/laravel-forge/queries.tsx b/frontend/src/hooks/api/appConnections/laravel-forge/queries.tsx new file mode 100644 index 000000000..6986a94da --- /dev/null +++ b/frontend/src/hooks/api/appConnections/laravel-forge/queries.tsx @@ -0,0 +1,104 @@ +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; +import { appConnectionKeys } from "@app/hooks/api/appConnections"; + +import { TLaravelForgeOrganization, TLaravelForgeServer, TLaravelForgeSite } from "./types"; + +const laravelForgeConnectionKeys = { + all: [...appConnectionKeys.all, "laravel-forge"] as const, + listOrganizations: (connectionId: string) => + [...laravelForgeConnectionKeys.all, "organizations", connectionId] as const, + listServers: (connectionId: string, organizationSlug: string) => + [...laravelForgeConnectionKeys.all, "servers", connectionId, organizationSlug] as const, + listSites: (connectionId: string, organizationSlug: string, serverId: string) => + [...laravelForgeConnectionKeys.all, "sites", connectionId, organizationSlug, serverId] as const +}; + +export const useLaravelForgeConnectionListOrganizations = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TLaravelForgeOrganization[], + unknown, + TLaravelForgeOrganization[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: laravelForgeConnectionKeys.listOrganizations(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/app-connections/laravel-forge/${connectionId}/organizations` + ); + + return data; + }, + ...options + }); +}; + +export const useLaravelForgeConnectionListServers = ( + connectionId: string, + organizationSlug: string, + options?: Omit< + UseQueryOptions< + TLaravelForgeServer[], + unknown, + TLaravelForgeServer[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: laravelForgeConnectionKeys.listServers(connectionId, organizationSlug), + queryFn: async () => { + const params = { organizationSlug }; + const { data } = await apiRequest.get( + `/api/v1/app-connections/laravel-forge/${connectionId}/servers`, + { params } + ); + + return data; + }, + enabled: Boolean(connectionId && organizationSlug), + ...options + }); +}; + +export const useLaravelForgeConnectionListSites = ( + connectionId: string, + organizationSlug: string, + serverId: string, + options?: Omit< + UseQueryOptions< + TLaravelForgeSite[], + unknown, + TLaravelForgeSite[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: laravelForgeConnectionKeys.listSites(connectionId, organizationSlug, serverId), + queryFn: async () => { + const params = { + organizationSlug, + serverId + }; + + const { data } = await apiRequest.get( + `/api/v1/app-connections/laravel-forge/${connectionId}/sites`, + { params } + ); + + return data; + }, + enabled: Boolean(connectionId && organizationSlug && serverId), + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/laravel-forge/types.ts b/frontend/src/hooks/api/appConnections/laravel-forge/types.ts new file mode 100644 index 000000000..5a646df3f --- /dev/null +++ b/frontend/src/hooks/api/appConnections/laravel-forge/types.ts @@ -0,0 +1,15 @@ +export type TLaravelForgeOrganization = { + id: string; + name: string; + slug: string; +}; + +export type TLaravelForgeServer = { + id: string; + name: string; +}; + +export type TLaravelForgeSite = { + id: string; + name: string; +}; diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts index fdaae2c74..797e7a7c6 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -164,6 +164,10 @@ export type TOktaConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Okta; }; +export type TLaravelForgeConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.LaravelForge; +}; + export type TAzureAdCsConnectionOption = TAppConnectionOptionBase & { app: AppConnection.AzureADCS; }; @@ -210,7 +214,8 @@ export type TAppConnectionOption = | TDigitalOceanConnectionOption | TNetlifyConnectionOption | TOktaConnectionOption - | TAzureAdCsConnectionOption; + | TAzureAdCsConnectionOption + | TLaravelForgeConnectionOption; export type TAppConnectionOptionMap = { [AppConnection.AWS]: TAwsConnectionOption; @@ -252,4 +257,5 @@ export type TAppConnectionOptionMap = { [AppConnection.Okta]: TOktaConnectionOption; [AppConnection.AzureADCS]: TAzureAdCsConnectionOption; [AppConnection.Redis]: TRedisConnectionOption; + [AppConnection.LaravelForge]: TLaravelForgeConnectionOption; }; diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index 9f8df7cfa..fd840d5de 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -22,6 +22,7 @@ import { TGitLabConnection } from "./gitlab-connection"; import { THCVaultConnection } from "./hc-vault-connection"; import { THerokuConnection } from "./heroku-connection"; import { THumanitecConnection } from "./humanitec-connection"; +import { TLaravelForgeConnection } from "./laravel-forge-connection"; import { TLdapConnection } from "./ldap-connection"; import { TMsSqlConnection } from "./mssql-connection"; import { TMySqlConnection } from "./mysql-connection"; @@ -61,6 +62,7 @@ export * from "./gitlab-connection"; export * from "./hc-vault-connection"; export * from "./heroku-connection"; export * from "./humanitec-connection"; +export * from "./laravel-forge-connection"; export * from "./ldap-connection"; export * from "./mssql-connection"; export * from "./mysql-connection"; @@ -105,6 +107,7 @@ export type TAppConnection = | TOCIConnection | TOnePassConnection | THerokuConnection + | TLaravelForgeConnection | TRenderConnection | TFlyioConnection | TGitLabConnection diff --git a/frontend/src/hooks/api/appConnections/types/laravel-forge-connection.ts b/frontend/src/hooks/api/appConnections/types/laravel-forge-connection.ts new file mode 100644 index 000000000..ac48d929d --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/laravel-forge-connection.ts @@ -0,0 +1,13 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum LaravelForgeConnectionMethod { + ApiToken = "api-token" +} + +export type TLaravelForgeConnection = TRootAppConnection & { app: AppConnection.LaravelForge } & { + method: LaravelForgeConnectionMethod.ApiToken; + credentials: { + apiToken: string; + }; +}; diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index 970e0592d..33bc2107d 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -43,6 +43,8 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET]: "Revoke universal auth client secret", [EventType.CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS]: "Clear universal auth lockouts", [EventType.GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS]: "Get universal auth client secrets", + [EventType.GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET_BY_ID]: + "Get universal auth client secret by id", [EventType.CREATE_ENVIRONMENT]: "Create environment", [EventType.UPDATE_ENVIRONMENT]: "Update environment", [EventType.DELETE_ENVIRONMENT]: "Delete environment", diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index b10fcb60a..ea684a4bb 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -48,6 +48,7 @@ export enum EventType { REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "revoke-identity-universal-auth-client-secret", CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS = "clear-identity-universal-auth-lockouts", GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS = "get-identity-universal-auth-client-secret", + GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET_BY_ID = "get-identity-universal-auth-client-secret-by-id", LOGIN_IDENTITY_LDAP_AUTH = "login-identity-ldap-auth", ADD_IDENTITY_LDAP_AUTH = "add-identity-ldap-auth", diff --git a/frontend/src/hooks/api/auditLogs/types.tsx b/frontend/src/hooks/api/auditLogs/types.tsx index 1bb321fa2..fc15b5064 100644 --- a/frontend/src/hooks/api/auditLogs/types.tsx +++ b/frontend/src/hooks/api/auditLogs/types.tsx @@ -318,6 +318,14 @@ interface GetIdentityUniversalAuthClientSecretsEvent { }; } +interface GetIdentityUniversalAuthClientSecretByIdEvent { + type: EventType.GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET_BY_ID; + metadata: { + identityId: string; + clientSecretId: string; + }; +} + interface RevokeIdentityUniversalAuthClientSecretEvent { type: EventType.REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET; metadata: { @@ -906,6 +914,7 @@ export type Event = | GetIdentityUniversalAuthEvent | CreateIdentityUniversalAuthClientSecretEvent | GetIdentityUniversalAuthClientSecretsEvent + | GetIdentityUniversalAuthClientSecretByIdEvent | RevokeIdentityUniversalAuthClientSecretEvent | ClearIdentityUniversalAuthLockoutsEvent | CreateEnvironmentEvent diff --git a/frontend/src/hooks/api/ca/index.tsx b/frontend/src/hooks/api/ca/index.tsx index 36526ec18..05a161c75 100644 --- a/frontend/src/hooks/api/ca/index.tsx +++ b/frontend/src/hooks/api/ca/index.tsx @@ -2,8 +2,10 @@ export { AcmeDnsProvider, CaRenewalType, CaStatus, CaType, InternalCaType } from export { useCreateCa, useCreateCertificate, + useCreateCertificateV3, useDeleteCa, useImportCaCertificate, + useOrderCertificateWithProfile, useRenewCa, useSignIntermediate, useUpdateCa @@ -21,3 +23,4 @@ export { useListCasByTypeAndProjectId, useListExternalCasByProjectId } from "./queries"; +export type { TOrderCertificateDTO, TOrderCertificateResponse } from "./types"; diff --git a/frontend/src/hooks/api/ca/mutations.tsx b/frontend/src/hooks/api/ca/mutations.tsx index a14a0244b..fa422054c 100644 --- a/frontend/src/hooks/api/ca/mutations.tsx +++ b/frontend/src/hooks/api/ca/mutations.tsx @@ -9,9 +9,13 @@ import { TCreateCertificateAuthorityDTO, TCreateCertificateDTO, TCreateCertificateResponse, + TCreateCertificateV3DTO, + TCreateCertificateV3Response, TDeleteCertificateAuthorityDTO, TImportCaCertificateDTO, TImportCaCertificateResponse, + TOrderCertificateDTO, + TOrderCertificateResponse, TRenewCaDTO, TRenewCaResponse, TSignIntermediateDTO, @@ -148,6 +152,46 @@ export const useCreateCertificate = () => { }); }; +export const useCreateCertificateV3 = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (body) => { + const { data } = await apiRequest.post( + "/api/v3/certificates/issue-certificate", + body + ); + return data; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries({ + queryKey: projectKeys.forProjectCertificates(projectSlug) + }); + + queryClient.invalidateQueries({ + queryKey: ["certificate-profiles"] + }); + } + }); +}; + +export const useOrderCertificateWithProfile = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (body) => { + const { data } = await apiRequest.post( + "/api/v3/certificates/order-certificate", + body + ); + return data; + }, + onSuccess: (_, { projectSlug }) => { + queryClient.invalidateQueries({ + queryKey: projectKeys.forProjectCertificates(projectSlug) + }); + } + }); +}; + export const useRenewCa = () => { const queryClient = useQueryClient(); return useMutation({ diff --git a/frontend/src/hooks/api/ca/types.ts b/frontend/src/hooks/api/ca/types.ts index 0443dd5e9..696e72494 100644 --- a/frontend/src/hooks/api/ca/types.ts +++ b/frontend/src/hooks/api/ca/types.ts @@ -155,7 +155,7 @@ export type TCreateCertificateDTO = { pkiCollectionId?: string; friendlyName?: string; commonName: string; - altNames: string; // sans + subjectAltNames: string; // sans ttl: string; // string compatible with ms notBefore?: string; notAfter?: string; @@ -171,6 +171,84 @@ export type TCreateCertificateResponse = { serialNumber: string; }; +export type TCreateCertificateV3DTO = { + projectSlug: string; + profileId: string; + pkiCollectionId?: string; + friendlyName?: string; + commonName?: string; + organization?: string; + organizationUnit?: string; + locality?: string; + state?: string; + country?: string; + email?: string; + streetAddress?: string; + postalCode?: string; + subjectAltNames: string; + ttl: string; + notBefore?: string; + notAfter?: string; + keyUsages: CertKeyUsage[]; + extendedKeyUsages: CertExtendedKeyUsage[]; + signatureAlgorithm?: string; + keyAlgorithm?: string; +}; + +export type TCreateCertificateV3Response = TCreateCertificateResponse & { + projectId: string; + profileName: string; + certificateId: string; +}; + +export type TOrderCertificateDTO = { + projectSlug: string; + profileId: string; + subjectAlternativeNames: Array<{ + type: "dns" | "ip"; + value: string; + }>; + ttl: string; + keyUsages?: CertKeyUsage[]; + extendedKeyUsages?: CertExtendedKeyUsage[]; + notBefore?: string; + notAfter?: string; + commonName?: string; + signatureAlgorithm?: string; + keyAlgorithm?: string; +}; + +export type TOrderCertificateResponse = { + orderId: string; + status: "pending" | "processing" | "valid" | "invalid"; + subjectAlternativeNames: Array<{ + type: "dns" | "ip"; + value: string; + status: "pending" | "processing" | "valid" | "invalid"; + }>; + authorizations: Array<{ + identifier: { + type: "dns" | "ip"; + value: string; + }; + status: "pending" | "processing" | "valid" | "invalid"; + expires?: string; + challenges: Array<{ + type: string; + status: "pending" | "processing" | "valid" | "invalid"; + url: string; + token: string; + validated?: string; + error?: string | Error; + }>; + }>; + certificate?: string; + privateKey?: string; + expires: string; + notBefore: string; + notAfter: string; +}; + export type TRenewCaDTO = { projectSlug: string; caId: string; diff --git a/frontend/src/hooks/api/certificateProfiles/index.ts b/frontend/src/hooks/api/certificateProfiles/index.ts new file mode 100644 index 000000000..dc5c17efa --- /dev/null +++ b/frontend/src/hooks/api/certificateProfiles/index.ts @@ -0,0 +1,14 @@ +export { + useCreateCertificateProfile, + useDeleteCertificateProfile, + useUpdateCertificateProfile +} from "./mutations"; +export { + certificateProfileKeys, + useGetCertificateProfileById, + useGetCertificateProfileBySlug, + useGetProfileCertificates, + useGetProfileMetrics, + useListCertificateProfiles +} from "./queries"; +export type * from "./types"; diff --git a/frontend/src/hooks/api/certificateProfiles/mutations.tsx b/frontend/src/hooks/api/certificateProfiles/mutations.tsx new file mode 100644 index 000000000..ca784ed0d --- /dev/null +++ b/frontend/src/hooks/api/certificateProfiles/mutations.tsx @@ -0,0 +1,71 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { certificateProfileKeys } from "./queries"; +import { + TCertificateProfile, + TCreateCertificateProfileDTO, + TDeleteCertificateProfileDTO, + TUpdateCertificateProfileDTO +} from "./types"; + +export const useCreateCertificateProfile = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (data) => { + const { data: response } = await apiRequest.post<{ + certificateProfile: TCertificateProfile; + }>("/api/v1/pki/certificate-profiles", data); + return response.certificateProfile; + }, + onSuccess: (_, { projectId }) => { + queryClient.invalidateQueries({ + queryKey: certificateProfileKeys.list({ projectId }) + }); + } + }); +}; + +export const useUpdateCertificateProfile = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ profileId, ...data }) => { + const { data: response } = await apiRequest.patch<{ + certificateProfile: TCertificateProfile; + }>(`/api/v1/pki/certificate-profiles/${profileId}`, data); + return response.certificateProfile; + }, + onSuccess: (profile, { profileId }) => { + queryClient.invalidateQueries({ + queryKey: certificateProfileKeys.list({ projectId: profile.projectId }) + }); + queryClient.invalidateQueries({ + queryKey: certificateProfileKeys.getById(profileId) + }); + } + }); +}; + +export const useDeleteCertificateProfile = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ profileId }) => { + const { data: response } = await apiRequest.delete<{ + certificateProfile: TCertificateProfile; + }>(`/api/v1/pki/certificate-profiles/${profileId}`); + return response.certificateProfile; + }, + onSuccess: (profile, { profileId }) => { + queryClient.invalidateQueries({ + queryKey: certificateProfileKeys.list({ projectId: profile.projectId }) + }); + queryClient.removeQueries({ + queryKey: certificateProfileKeys.getById(profileId) + }); + } + }); +}; diff --git a/frontend/src/hooks/api/certificateProfiles/queries.tsx b/frontend/src/hooks/api/certificateProfiles/queries.tsx new file mode 100644 index 000000000..abdc93ddb --- /dev/null +++ b/frontend/src/hooks/api/certificateProfiles/queries.tsx @@ -0,0 +1,162 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { + TCertificateProfile, + TCertificateProfileMetrics, + TCertificateProfileWithDetails, + TGetCertificateProfileByIdDTO, + TGetCertificateProfileBySlugDTO, + TGetProfileCertificatesDTO, + TGetProfileMetricsDTO, + TListCertificateProfilesDTO, + TProfileCertificate +} from "./types"; + +export const certificateProfileKeys = { + list: (params: { + projectId: string; + limit?: number; + offset?: number; + search?: string; + includeMetrics?: boolean; + includeConfigs?: boolean; + enrollmentType?: string; + expiringDays?: number; + }) => ["certificate-profiles", "list", params], + getById: (profileId: string) => ["certificate-profiles", "get-by-id", profileId], + getBySlug: (projectId: string, slug: string) => [ + "certificate-profiles", + "get-by-slug", + projectId, + slug + ], + getCertificates: (profileId: string, params?: Omit) => [ + "certificate-profiles", + "certificates", + profileId, + params + ], + getMetrics: (profileId: string, params?: Omit) => [ + "certificate-profiles", + "metrics", + profileId, + params + ] +}; + +export const useListCertificateProfiles = ({ + projectId, + limit = 20, + offset = 0, + search, + includeMetrics = false, + includeConfigs = false, + enrollmentType, + expiringDays = 7 +}: TListCertificateProfilesDTO) => { + return useQuery({ + queryKey: certificateProfileKeys.list({ + projectId, + limit, + offset, + search, + includeMetrics, + includeConfigs, + enrollmentType, + expiringDays + }), + queryFn: async () => { + const { data } = await apiRequest.get<{ + certificateProfiles: TCertificateProfile[]; + totalCount: number; + }>("/api/v1/pki/certificate-profiles", { + params: { + projectId, + limit, + offset, + search, + includeMetrics, + includeConfigs, + enrollmentType, + expiringDays + } + }); + return data; + }, + enabled: Boolean(projectId) + }); +}; + +export const useGetCertificateProfileById = ({ profileId }: TGetCertificateProfileByIdDTO) => { + return useQuery({ + queryKey: certificateProfileKeys.getById(profileId), + queryFn: async () => { + const { data } = await apiRequest.get<{ + certificateProfile: TCertificateProfileWithDetails; + }>(`/api/v1/pki/certificate-profiles/${profileId}`); + return data.certificateProfile; + }, + enabled: Boolean(profileId) + }); +}; + +export const useGetCertificateProfileBySlug = ({ + projectId, + slug +}: TGetCertificateProfileBySlugDTO) => { + return useQuery({ + queryKey: certificateProfileKeys.getBySlug(projectId, slug), + queryFn: async () => { + const { data } = await apiRequest.get<{ + certificateProfile: TCertificateProfile; + }>(`/api/v1/pki/certificate-profiles/slug/${slug}`, { + params: { projectId } + }); + return data.certificateProfile; + }, + enabled: Boolean(projectId && slug) + }); +}; + +export const useGetProfileCertificates = ({ + profileId, + offset = 0, + limit = 20, + status, + search +}: TGetProfileCertificatesDTO) => { + return useQuery({ + queryKey: certificateProfileKeys.getCertificates(profileId, { offset, limit, status, search }), + queryFn: async () => { + const { data } = await apiRequest.get<{ + certificates: TProfileCertificate[]; + }>(`/api/v1/pki/certificate-profiles/${profileId}/certificates`, { + params: { + offset, + limit, + status, + search + } + }); + return data.certificates; + }, + 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 new file mode 100644 index 000000000..a9b6b7060 --- /dev/null +++ b/frontend/src/hooks/api/certificateProfiles/types.ts @@ -0,0 +1,130 @@ +export type TCertificateProfile = { + id: string; + projectId: string; + caId: string; + certificateTemplateId: string; + slug: string; + description?: string; + enrollmentType: "api" | "est"; + estConfigId?: string; + apiConfigId?: string; + createdAt: string; + updatedAt: string; + metrics?: TCertificateProfileMetrics; +}; + +export type TCertificateProfileWithDetails = TCertificateProfile & { + certificateAuthority?: { + id: string; + projectId: string; + status: string; + name: string; + }; + certificateTemplate?: { + id: string; + projectId: string; + name: string; + description?: string; + }; + estConfig?: { + id: string; + disableBootstrapCaValidation: boolean; + passphrase: string; + caChain: string; + }; + apiConfig?: { + id: string; + autoRenew: boolean; + autoRenewDays?: number; + }; +}; + +export type TCreateCertificateProfileDTO = { + projectId: string; + caId: string; + certificateTemplateId: string; + slug: string; + description?: string; + enrollmentType: "api" | "est"; + estConfig?: { + disableBootstrapCaValidation?: boolean; + passphrase: string; + caChain?: string; + }; + apiConfig?: { + autoRenew?: boolean; + autoRenewDays?: number; + }; +}; + +export type TUpdateCertificateProfileDTO = { + profileId: string; + slug?: string; + description?: string; + estConfig?: { + disableBootstrapCaValidation?: boolean; + passphrase?: string; + caChain?: string; + }; + apiConfig?: { + autoRenew?: boolean; + autoRenewDays?: number; + }; +}; + +export type TDeleteCertificateProfileDTO = { + profileId: string; +}; + +export type TListCertificateProfilesDTO = { + projectId: string; + limit?: number; + offset?: number; + search?: string; + includeMetrics?: boolean; + includeConfigs?: boolean; + enrollmentType?: "api" | "est"; + expiringDays?: number; +}; + +export type TGetCertificateProfileByIdDTO = { + profileId: string; +}; + +export type TGetCertificateProfileBySlugDTO = { + projectId: string; + slug: string; +}; + +export type TCertificateProfileMetrics = { + profileId: string; + totalCertificates: number; + activeCertificates: number; + expiredCertificates: number; + expiringCertificates: number; + revokedCertificates: number; +}; + +export type TProfileCertificate = { + id: string; + serialNumber: string; + cn: string; + status: string; + notBefore: string; + notAfter: string; + isRevoked: boolean; + createdAt: string; +}; + +export type TGetProfileCertificatesDTO = { + profileId: string; + offset?: number; + limit?: number; + status?: "active" | "expired" | "revoked"; + search?: string; +}; + +export type TGetProfileMetricsDTO = { + profileId: string; + expiringDays?: number; +}; diff --git a/frontend/src/hooks/api/certificateTemplates/mutations.tsx b/frontend/src/hooks/api/certificateTemplates/mutations.tsx index 24a7d0e5f..0355d9d87 100644 --- a/frontend/src/hooks/api/certificateTemplates/mutations.tsx +++ b/frontend/src/hooks/api/certificateTemplates/mutations.tsx @@ -7,13 +7,17 @@ import { projectKeys } from "../projects"; import { certTemplateKeys } from "./queries"; import { TCertificateTemplate, + TCertificateTemplateV2WithPolicies, TCreateCertificateTemplateDTO, TCreateCertificateTemplateV2DTO, + TCreateCertificateTemplateV2WithPoliciesDTO, TCreateEstConfigDTO, TDeleteCertificateTemplateDTO, TDeleteCertificateTemplateV2DTO, + TDeleteCertificateTemplateV2WithPoliciesDTO, TUpdateCertificateTemplateDTO, TUpdateCertificateTemplateV2DTO, + TUpdateCertificateTemplateV2WithPoliciesDTO, TUpdateEstConfigDTO } from "./types"; @@ -163,3 +167,72 @@ export const useUpdateEstConfig = () => { } }); }; + +export const useCreateCertificateTemplateV2WithPolicies = () => { + const queryClient = useQueryClient(); + return useMutation< + TCertificateTemplateV2WithPolicies, + object, + TCreateCertificateTemplateV2WithPoliciesDTO + >({ + mutationFn: async (data) => { + const { data: response } = await apiRequest.post<{ + certificateTemplate: TCertificateTemplateV2WithPolicies; + }>("/api/v2/certificate-templates", data); + return response.certificateTemplate; + }, + onSuccess: (_, { projectId }) => { + queryClient.invalidateQueries({ + queryKey: certTemplateKeys.listTemplatesV2({ projectId }) + }); + } + }); +}; + +export const useUpdateCertificateTemplateV2WithPolicies = () => { + const queryClient = useQueryClient(); + return useMutation< + TCertificateTemplateV2WithPolicies, + object, + TUpdateCertificateTemplateV2WithPoliciesDTO + >({ + mutationFn: async ({ templateId, ...data }) => { + const { data: response } = await apiRequest.patch<{ + certificateTemplate: TCertificateTemplateV2WithPolicies; + }>(`/api/v2/certificate-templates/${templateId}`, data); + return response.certificateTemplate; + }, + onSuccess: (template, { templateId }) => { + queryClient.invalidateQueries({ + queryKey: certTemplateKeys.listTemplatesV2({ projectId: template.projectId }) + }); + queryClient.invalidateQueries({ + queryKey: certTemplateKeys.getTemplateV2ById(templateId) + }); + } + }); +}; + +export const useDeleteCertificateTemplateV2WithPolicies = () => { + const queryClient = useQueryClient(); + return useMutation< + TCertificateTemplateV2WithPolicies, + object, + TDeleteCertificateTemplateV2WithPoliciesDTO + >({ + mutationFn: async ({ templateId }) => { + const { data: response } = await apiRequest.delete<{ + certificateTemplate: TCertificateTemplateV2WithPolicies; + }>(`/api/v2/certificate-templates/${templateId}`); + return response.certificateTemplate; + }, + onSuccess: (template, { templateId }) => { + queryClient.invalidateQueries({ + queryKey: certTemplateKeys.listTemplatesV2({ projectId: template.projectId }) + }); + queryClient.removeQueries({ + queryKey: certTemplateKeys.getTemplateV2ById(templateId) + }); + } + }); +}; diff --git a/frontend/src/hooks/api/certificateTemplates/queries.tsx b/frontend/src/hooks/api/certificateTemplates/queries.tsx index 435345ad9..383f4ed71 100644 --- a/frontend/src/hooks/api/certificateTemplates/queries.tsx +++ b/frontend/src/hooks/api/certificateTemplates/queries.tsx @@ -5,8 +5,11 @@ import { apiRequest } from "@app/config/request"; import { TCertificateTemplate, TCertificateTemplateV2, + TCertificateTemplateV2WithPolicies, TEstConfig, - TListCertificateTemplatesDTO + TGetCertificateTemplateV2ByIdDTO, + TListCertificateTemplatesDTO, + TListCertificateTemplatesV2DTO } from "./types"; export const certTemplateKeys = { @@ -16,7 +19,16 @@ export const certTemplateKeys = { projectId, el ], - getEstConfig: (id: string) => [{ id }, "cert-template-est-config"] + getEstConfig: (id: string) => [{ id }, "cert-template-est-config"], + listTemplatesV2: ({ + projectId, + ...el + }: { + limit?: number; + offset?: number; + projectId: string; + }) => ["list-templates-v2", projectId, el], + getTemplateV2ById: (id: string) => ["cert-template-v2", id] }; export const useGetCertTemplate = (id: string) => { @@ -68,3 +80,42 @@ export const useGetEstConfig = (certificateTemplateId: string) => { enabled: Boolean(certificateTemplateId) }); }; + +export const useListCertificateTemplatesV2 = ({ + projectId, + limit = 20, + offset = 0 +}: TListCertificateTemplatesV2DTO) => { + return useQuery({ + queryKey: certTemplateKeys.listTemplatesV2({ projectId, limit, offset }), + queryFn: async () => { + const { data } = await apiRequest.get<{ + certificateTemplates: TCertificateTemplateV2WithPolicies[]; + totalCount: number; + }>("/api/v2/certificate-templates", { + params: { + projectId, + limit, + offset + } + }); + return data; + }, + enabled: Boolean(projectId) + }); +}; + +export const useGetCertificateTemplateV2ById = ({ + templateId +}: TGetCertificateTemplateV2ByIdDTO) => { + return useQuery({ + queryKey: certTemplateKeys.getTemplateV2ById(templateId), + queryFn: async () => { + const { data } = await apiRequest.get<{ + certificateTemplate: TCertificateTemplateV2WithPolicies; + }>(`/api/v2/certificate-templates/${templateId}`); + return data.certificateTemplate; + }, + enabled: Boolean(templateId) + }); +}; diff --git a/frontend/src/hooks/api/certificateTemplates/types.ts b/frontend/src/hooks/api/certificateTemplates/types.ts index 1c2a47178..373bbef2a 100644 --- a/frontend/src/hooks/api/certificateTemplates/types.ts +++ b/frontend/src/hooks/api/certificateTemplates/types.ts @@ -121,3 +121,90 @@ export type TListCertificateTemplatesDTO = { offset?: number; projectId: string; }; + +export type TCertificateTemplateV2Policy = { + subject?: Array<{ + type: "common_name" | "organization" | "country"; + allowed?: string[]; + required?: string[]; + denied?: string[]; + }>; + sans?: Array<{ + type: "dns_name" | "ip_address" | "email" | "uri"; + allowed?: string[]; + required?: string[]; + denied?: string[]; + }>; + keyUsages?: { + allowed?: string[]; + required?: string[]; + denied?: string[]; + }; + extendedKeyUsages?: { + allowed?: string[]; + required?: string[]; + denied?: string[]; + }; + algorithms?: { + signature?: Array< + "SHA256-RSA" | "SHA384-RSA" | "SHA512-RSA" | "SHA256-ECDSA" | "SHA384-ECDSA" | "SHA512-ECDSA" + >; + keyAlgorithm?: Array<"RSA-2048" | "RSA-3072" | "RSA-4096" | "ECDSA-P256" | "ECDSA-P384">; + }; + validity?: { + max?: string; + }; +}; + +export type TCertificateTemplateV2WithPolicies = { + id: string; + projectId: string; + name: string; + description?: string; + subject?: TCertificateTemplateV2Policy["subject"]; + sans?: TCertificateTemplateV2Policy["sans"]; + keyUsages?: TCertificateTemplateV2Policy["keyUsages"]; + extendedKeyUsages?: TCertificateTemplateV2Policy["extendedKeyUsages"]; + algorithms?: TCertificateTemplateV2Policy["algorithms"]; + validity?: TCertificateTemplateV2Policy["validity"]; + createdAt: string; + updatedAt: string; +}; + +export type TCreateCertificateTemplateV2WithPoliciesDTO = { + projectId: string; + name: string; + description?: string; + subject?: TCertificateTemplateV2Policy["subject"]; + sans?: TCertificateTemplateV2Policy["sans"]; + keyUsages?: TCertificateTemplateV2Policy["keyUsages"]; + extendedKeyUsages?: TCertificateTemplateV2Policy["extendedKeyUsages"]; + algorithms?: TCertificateTemplateV2Policy["algorithms"]; + validity?: TCertificateTemplateV2Policy["validity"]; +}; + +export type TUpdateCertificateTemplateV2WithPoliciesDTO = { + templateId: string; + name?: string; + description?: string; + subject?: TCertificateTemplateV2Policy["subject"]; + sans?: TCertificateTemplateV2Policy["sans"]; + keyUsages?: TCertificateTemplateV2Policy["keyUsages"]; + extendedKeyUsages?: TCertificateTemplateV2Policy["extendedKeyUsages"]; + algorithms?: TCertificateTemplateV2Policy["algorithms"]; + validity?: TCertificateTemplateV2Policy["validity"]; +}; + +export type TDeleteCertificateTemplateV2WithPoliciesDTO = { + templateId: string; +}; + +export type TListCertificateTemplatesV2DTO = { + projectId: string; + limit?: number; + offset?: number; +}; + +export type TGetCertificateTemplateV2ByIdDTO = { + templateId: string; +}; diff --git a/frontend/src/hooks/api/certificates/constants.tsx b/frontend/src/hooks/api/certificates/constants.tsx index 0384ea6cd..8647fafd5 100644 --- a/frontend/src/hooks/api/certificates/constants.tsx +++ b/frontend/src/hooks/api/certificates/constants.tsx @@ -24,6 +24,7 @@ export const getCertStatusBadgeVariant = (status: CertStatus) => { export const certKeyAlgorithmToNameMap: { [K in CertKeyAlgorithm]: string } = { [CertKeyAlgorithm.RSA_2048]: "RSA 2048", + [CertKeyAlgorithm.RSA_3072]: "RSA 3072", [CertKeyAlgorithm.RSA_4096]: "RSA 4096", [CertKeyAlgorithm.ECDSA_P256]: "ECDSA P256", [CertKeyAlgorithm.ECDSA_P384]: "ECDSA P384" @@ -31,6 +32,7 @@ export const certKeyAlgorithmToNameMap: { [K in CertKeyAlgorithm]: string } = { export const certKeyAlgorithms = [ { label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.RSA_2048], value: CertKeyAlgorithm.RSA_2048 }, + { label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.RSA_3072], value: CertKeyAlgorithm.RSA_3072 }, { label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.RSA_4096], value: CertKeyAlgorithm.RSA_4096 }, { label: certKeyAlgorithmToNameMap[CertKeyAlgorithm.ECDSA_P256], @@ -96,3 +98,12 @@ export const EXTENDED_KEY_USAGES_OPTIONS = [ { value: CertExtendedKeyUsage.CODE_SIGNING, label: "Code Signing" }, { value: CertExtendedKeyUsage.TIMESTAMPING, label: "Timestamping" } ] as const; + +export const SIGNATURE_ALGORITHMS_OPTIONS = [ + { value: "RSA-SHA256", label: "RSA-SHA256" }, + { value: "RSA-SHA384", label: "RSA-SHA384" }, + { value: "RSA-SHA512", label: "RSA-SHA512" }, + { value: "ECDSA-SHA256", label: "ECDSA-SHA256" }, + { value: "ECDSA-SHA384", label: "ECDSA-SHA384" }, + { value: "ECDSA-SHA512", label: "ECDSA-SHA512" } +] as const; diff --git a/frontend/src/hooks/api/certificates/enums.tsx b/frontend/src/hooks/api/certificates/enums.tsx index 566da7506..6ee03c308 100644 --- a/frontend/src/hooks/api/certificates/enums.tsx +++ b/frontend/src/hooks/api/certificates/enums.tsx @@ -5,6 +5,7 @@ export enum CertStatus { export enum CertKeyAlgorithm { RSA_2048 = "RSA_2048", + RSA_3072 = "RSA_3072", RSA_4096 = "RSA_4096", ECDSA_P256 = "EC_prime256v1", ECDSA_P384 = "EC_secp384r1" @@ -24,22 +25,22 @@ export enum CrlReason { } export enum CertKeyUsage { - DIGITAL_SIGNATURE = "digitalSignature", - KEY_ENCIPHERMENT = "keyEncipherment", - NON_REPUDIATION = "nonRepudiation", - DATA_ENCIPHERMENT = "dataEncipherment", - KEY_AGREEMENT = "keyAgreement", - KEY_CERT_SIGN = "keyCertSign", - CRL_SIGN = "cRLSign", - ENCIPHER_ONLY = "encipherOnly", - DECIPHER_ONLY = "decipherOnly" + DIGITAL_SIGNATURE = "digital_signature", + KEY_ENCIPHERMENT = "key_encipherment", + NON_REPUDIATION = "non_repudiation", + DATA_ENCIPHERMENT = "data_encipherment", + KEY_AGREEMENT = "key_agreement", + KEY_CERT_SIGN = "key_cert_sign", + CRL_SIGN = "crl_sign", + ENCIPHER_ONLY = "encipher_only", + DECIPHER_ONLY = "decipher_only" } export enum CertExtendedKeyUsage { - CLIENT_AUTH = "clientAuth", - SERVER_AUTH = "serverAuth", - CODE_SIGNING = "codeSigning", - EMAIL_PROTECTION = "emailProtection", - TIMESTAMPING = "timeStamping", - OCSP_SIGNING = "ocspSigning" + CLIENT_AUTH = "client_auth", + SERVER_AUTH = "server_auth", + CODE_SIGNING = "code_signing", + EMAIL_PROTECTION = "email_protection", + TIMESTAMPING = "time_stamping", + OCSP_SIGNING = "ocsp_signing" } diff --git a/frontend/src/hooks/api/certificates/mutations.tsx b/frontend/src/hooks/api/certificates/mutations.tsx index 77a3dab72..388295b0a 100644 --- a/frontend/src/hooks/api/certificates/mutations.tsx +++ b/frontend/src/hooks/api/certificates/mutations.tsx @@ -52,6 +52,10 @@ export const useRevokeCert = () => { queryClient.invalidateQueries({ queryKey: pkiSubscriberKeys.allPkiSubscriberCertificates() }); + + queryClient.invalidateQueries({ + queryKey: ["certificate-profiles", "list"] + }); } }); }; diff --git a/frontend/src/hooks/api/certificates/types.ts b/frontend/src/hooks/api/certificates/types.ts index c1dd59eca..1ec3292a3 100644 --- a/frontend/src/hooks/api/certificates/types.ts +++ b/frontend/src/hooks/api/certificates/types.ts @@ -7,7 +7,7 @@ export type TCertificate = { status: CertStatus; friendlyName: string; commonName: string; - altNames: string; + subjectAltNames: string; serialNumber: string; notBefore: string; notAfter: string; diff --git a/frontend/src/hooks/api/migration/index.ts b/frontend/src/hooks/api/migration/index.ts index 0c2adeab0..177955438 100644 --- a/frontend/src/hooks/api/migration/index.ts +++ b/frontend/src/hooks/api/migration/index.ts @@ -1,2 +1,3 @@ export * from "./mutations"; export * from "./queries"; +export * from "./types"; diff --git a/frontend/src/hooks/api/migration/mutations.tsx b/frontend/src/hooks/api/migration/mutations.tsx index b2694b458..00d3148ef 100644 --- a/frontend/src/hooks/api/migration/mutations.tsx +++ b/frontend/src/hooks/api/migration/mutations.tsx @@ -1,8 +1,12 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { dashboardKeys } from "@app/hooks/api/dashboard/queries"; +import { secretKeys } from "@app/hooks/api/secrets/queries"; import { projectKeys } from "../projects"; +import { externalMigrationQueryKeys } from "./queries"; +import { TImportVaultSecretsDTO, TVaultExternalMigrationConfig, VaultImportStatus } from "./types"; export const useImportEnvKey = () => { const queryClient = useQueryClient(); @@ -65,3 +69,97 @@ export const useImportVault = () => { } }); }; + +export const useImportVaultSecrets = () => { + const queryClient = useQueryClient(); + + return useMutation<{ status: VaultImportStatus }, object, TImportVaultSecretsDTO>({ + mutationFn: async (dto) => { + const { data } = await apiRequest.post<{ status: VaultImportStatus }>( + "/api/v3/external-migration/vault/import-secrets", + dto + ); + return data; + }, + onSuccess: (_, { projectId, environment, secretPath }) => { + queryClient.invalidateQueries({ queryKey: dashboardKeys.all() }); + queryClient.invalidateQueries({ + queryKey: secretKeys.getProjectSecret({ + projectId, + environment, + secretPath + }) + }); + } + }); +}; + +export const useCreateVaultExternalMigrationConfig = () => { + const queryClient = useQueryClient(); + + return useMutation< + TVaultExternalMigrationConfig, + Error, + { connectionId: string; namespace: string } + >({ + mutationFn: async ({ connectionId, namespace }) => { + const { data } = await apiRequest.post<{ config: TVaultExternalMigrationConfig }>( + "/api/v3/external-migration/vault/configs", + { + connectionId, + namespace + } + ); + return data.config; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: externalMigrationQueryKeys.vaultConfigs() + }); + } + }); +}; + +export const useUpdateVaultExternalMigrationConfig = () => { + const queryClient = useQueryClient(); + + return useMutation< + TVaultExternalMigrationConfig, + Error, + { id: string; connectionId: string; namespace: string } + >({ + mutationFn: async ({ id, connectionId, namespace }) => { + const { data } = await apiRequest.put<{ config: TVaultExternalMigrationConfig }>( + `/api/v3/external-migration/vault/configs/${id}`, + { + connectionId, + namespace + } + ); + return data.config; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: externalMigrationQueryKeys.vaultConfigs() + }); + } + }); +}; + +export const useDeleteVaultExternalMigrationConfig = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ id }) => { + const { data } = await apiRequest.delete<{ config: TVaultExternalMigrationConfig }>( + `/api/v3/external-migration/vault/configs/${id}` + ); + return data.config; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: externalMigrationQueryKeys.vaultConfigs() + }); + } + }); +}; diff --git a/frontend/src/hooks/api/migration/queries.tsx b/frontend/src/hooks/api/migration/queries.tsx index e96533b09..e4ce6824d 100644 --- a/frontend/src/hooks/api/migration/queries.tsx +++ b/frontend/src/hooks/api/migration/queries.tsx @@ -2,12 +2,35 @@ import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { ExternalMigrationProviders } from "./types"; +import { + ExternalMigrationProviders, + TVaultExternalMigrationConfig, + VaultKubernetesAuthRole +} from "./types"; -const externalMigrationQueryKeys = { +export const externalMigrationQueryKeys = { customMigrationAvailable: (provider: ExternalMigrationProviders) => [ "custom-migration-available", provider + ], + vaultConfigs: () => ["vault-external-migration-configs"], + vaultNamespaces: () => ["vault-namespaces"], + vaultPolicies: (namespace?: string) => ["vault-policies", namespace], + vaultMounts: (namespace?: string) => ["vault-mounts", namespace], + vaultAuthMounts: (namespace?: string, authType?: string) => [ + "vault-auth-mounts", + namespace, + authType + ], + vaultSecretPaths: (namespace?: string, mountPath?: string) => [ + "vault-secret-paths", + namespace, + mountPath + ], + vaultKubernetesAuthRoles: (namespace?: string, mountPath?: string) => [ + "vault-kubernetes-auth-roles", + namespace, + mountPath ] }; @@ -20,3 +43,132 @@ export const useHasCustomMigrationAvailable = (provider: ExternalMigrationProvid ) }); }; + +export const useGetVaultExternalMigrationConfigs = () => { + return useQuery({ + queryKey: externalMigrationQueryKeys.vaultConfigs(), + queryFn: async () => { + const { data } = await apiRequest.get<{ configs: TVaultExternalMigrationConfig[] }>( + "/api/v3/external-migration/vault/configs" + ); + return data.configs; + } + }); +}; + +export const useGetVaultNamespaces = () => { + return useQuery({ + queryKey: externalMigrationQueryKeys.vaultNamespaces(), + queryFn: async () => { + const { data } = await apiRequest.get<{ + namespaces: Array<{ id: string; name: string }>; + }>("/api/v3/external-migration/vault/namespaces"); + return data.namespaces; + } + }); +}; + +export const useGetVaultPolicies = (enabled = true, namespace?: string) => { + return useQuery({ + queryKey: externalMigrationQueryKeys.vaultPolicies(namespace), + queryFn: async () => { + const { data } = await apiRequest.get<{ + policies: Array<{ name: string; rules: string }>; + }>("/api/v3/external-migration/vault/policies", { + params: { + namespace + } + }); + + return data.policies; + }, + enabled + }); +}; + +export const useGetVaultMounts = (enabled = true, namespace?: string) => { + return useQuery({ + queryKey: externalMigrationQueryKeys.vaultMounts(namespace), + queryFn: async () => { + const { data } = await apiRequest.get<{ + mounts: Array<{ path: string; type: string; version: string | null }>; + }>("/api/v3/external-migration/vault/mounts", { + params: { + namespace + } + }); + + return data.mounts; + }, + enabled + }); +}; + +export const useGetVaultSecretPaths = (enabled = true, namespace?: string, mountPath?: string) => { + return useQuery({ + queryKey: externalMigrationQueryKeys.vaultSecretPaths(namespace, mountPath), + queryFn: async () => { + if (!namespace || !mountPath) { + throw new Error("Both namespace and mountPath are required"); + } + + const { data } = await apiRequest.get<{ + secretPaths: string[]; + }>("/api/v3/external-migration/vault/secret-paths", { + params: { + namespace, + mountPath + } + }); + + return data.secretPaths; + }, + enabled: enabled && !!namespace && !!mountPath + }); +}; + +export const useGetVaultAuthMounts = (enabled = true, namespace?: string, authType?: string) => { + return useQuery({ + queryKey: externalMigrationQueryKeys.vaultAuthMounts(namespace, authType), + queryFn: async () => { + const { data } = await apiRequest.get<{ + mounts: Array<{ path: string; type: string }>; + }>("/api/v3/external-migration/vault/auth-mounts", { + params: { + namespace, + ...(authType && { authType }) + } + }); + + return data.mounts; + }, + enabled + }); +}; + +export const useGetVaultKubernetesAuthRoles = ( + enabled = true, + namespace?: string, + mountPath?: string +) => { + return useQuery({ + queryKey: externalMigrationQueryKeys.vaultKubernetesAuthRoles(namespace, mountPath), + queryFn: async () => { + if (!namespace || !mountPath) { + throw new Error("Both namespace and mountPath are required"); + } + + const { data } = await apiRequest.get<{ + roles: VaultKubernetesAuthRole[]; + }>("/api/v3/external-migration/vault/auth-roles/kubernetes", { + params: { + namespace, + mountPath + } + }); + + return data.roles; + }, + enabled: enabled && !!namespace && !!mountPath + }); +}; diff --git a/frontend/src/hooks/api/migration/types.ts b/frontend/src/hooks/api/migration/types.ts index 945f18d8e..f4303ea26 100644 --- a/frontend/src/hooks/api/migration/types.ts +++ b/frontend/src/hooks/api/migration/types.ts @@ -2,3 +2,50 @@ export enum ExternalMigrationProviders { Vault = "vault", EnvKey = "env-key" } + +export enum VaultImportStatus { + Imported = "imported", + ApprovalRequired = "approval-required" +} + +export type TVaultExternalMigrationConfig = { + id: string; + orgId: string; + namespace: string; + connectionId: string | null; + createdAt: string; + updatedAt: string; +}; + +export type TImportVaultSecretsDTO = { + projectId: string; + environment: string; + secretPath: string; + vaultNamespace: string; + vaultSecretPath: string; +}; + +export type VaultKubernetesAuthRole = { + name: string; + bound_service_account_names: string[]; + bound_service_account_namespaces: string[]; + token_ttl?: number; + token_max_ttl?: number; + token_policies?: string[]; + token_bound_cidrs?: string[]; + token_explicit_max_ttl?: number; + token_no_default_policy?: boolean; + token_num_uses?: number; + token_period?: number; + token_type?: string; + audience?: string; + alias_name_source?: string; + mountPath: string; + config: { + kubernetes_host: string; + kubernetes_ca_cert?: string; + issuer?: string; + disable_iss_validation?: boolean; + disable_local_ca_jwt?: boolean; + }; +}; diff --git a/frontend/src/hooks/api/secretFolders/index.tsx b/frontend/src/hooks/api/secretFolders/index.tsx index 0676f5d14..9d2e44621 100644 --- a/frontend/src/hooks/api/secretFolders/index.tsx +++ b/frontend/src/hooks/api/secretFolders/index.tsx @@ -2,6 +2,7 @@ export { useCreateFolder, useDeleteFolder, useGetFoldersByEnv, + useGetOrCreateFolder, useGetProjectFolders, useUpdateFolder } from "./queries"; diff --git a/frontend/src/hooks/api/secretFolders/queries.tsx b/frontend/src/hooks/api/secretFolders/queries.tsx index 3e752eed3..56f7825c0 100644 --- a/frontend/src/hooks/api/secretFolders/queries.tsx +++ b/frontend/src/hooks/api/secretFolders/queries.tsx @@ -140,6 +140,59 @@ export const useGetFoldersByEnv = ({ return { folders, folderNames, isFolderPresentInEnv, getFolderByNameAndEnv }; }; +export const useGetOrCreateFolder = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (dto) => { + const { data: existingFolder } = await apiRequest.get<{ folders: TSecretFolder[] }>( + "/api/v2/folders", + { + params: { + projectId: dto.projectId, + environment: dto.environment, + path: dto.path || "/" + } + } + ); + + const folder = existingFolder.folders.find((f) => f.name === dto.name); + + if (folder) return folder; + + const { data } = await apiRequest.post("/api/v2/folders", { + ...dto, + projectId: dto.projectId + }); + + return data; + }, + onSuccess: (_, { projectId, environment, path }) => { + queryClient.invalidateQueries({ + queryKey: dashboardKeys.getDashboardSecrets({ + projectId, + secretPath: path ?? "/" + }) + }); + queryClient.invalidateQueries({ + queryKey: folderQueryKeys.getSecretFolders({ projectId, environment, path }) + }); + queryClient.invalidateQueries({ + queryKey: secretSnapshotKeys.list({ projectId, environment, directory: path }) + }); + queryClient.invalidateQueries({ + queryKey: secretSnapshotKeys.count({ projectId, environment, directory: path }) + }); + queryClient.invalidateQueries({ + queryKey: commitKeys.count({ projectId, environment, directory: path }) + }); + queryClient.invalidateQueries({ + queryKey: commitKeys.history({ projectId, environment, directory: path }) + }); + } + }); +}; + export const useCreateFolder = () => { const queryClient = useQueryClient(); @@ -170,6 +223,9 @@ export const useCreateFolder = () => { queryClient.invalidateQueries({ queryKey: commitKeys.count({ projectId, environment, directory: path }) }); + queryClient.invalidateQueries({ + queryKey: commitKeys.history({ projectId, environment, directory: path }) + }); } }); }; @@ -218,12 +274,13 @@ export const useDeleteFolder = () => { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ path = "/", folderId, environment, projectId }) => { + mutationFn: async ({ path = "/", folderId, environment, projectId, forceDelete = true }) => { const { data } = await apiRequest.delete(`/api/v2/folders/${folderId}`, { data: { environment, projectId, - path + path, + forceDelete } }); return data; diff --git a/frontend/src/hooks/api/secretFolders/types.ts b/frontend/src/hooks/api/secretFolders/types.ts index e9ee2ae5d..f3e3cecaf 100644 --- a/frontend/src/hooks/api/secretFolders/types.ts +++ b/frontend/src/hooks/api/secretFolders/types.ts @@ -59,6 +59,7 @@ export type TDeleteFolderDTO = { environment: string; folderId: string; path?: string; + forceDelete?: boolean; }; export type TUpdateFolderBatchDTO = { diff --git a/frontend/src/hooks/api/secretSyncs/enums.ts b/frontend/src/hooks/api/secretSyncs/enums.ts index c230cd9c6..efcc04b6d 100644 --- a/frontend/src/hooks/api/secretSyncs/enums.ts +++ b/frontend/src/hooks/api/secretSyncs/enums.ts @@ -28,7 +28,8 @@ export enum SecretSync { Checkly = "checkly", DigitalOceanAppPlatform = "digital-ocean-app-platform", Netlify = "netlify", - Bitbucket = "bitbucket" + Bitbucket = "bitbucket", + LaravelForge = "laravel-forge" } export enum SecretSyncStatus { diff --git a/frontend/src/hooks/api/secretSyncs/types/index.ts b/frontend/src/hooks/api/secretSyncs/types/index.ts index f7acda1f4..3cab195bd 100644 --- a/frontend/src/hooks/api/secretSyncs/types/index.ts +++ b/frontend/src/hooks/api/secretSyncs/types/index.ts @@ -21,6 +21,7 @@ import { TGitLabSync } from "./gitlab-sync"; import { THCVaultSync } from "./hc-vault-sync"; import { THerokuSync } from "./heroku-sync"; import { THumanitecSync } from "./humanitec-sync"; +import { TLaravelForgeSync } from "./laravel-forge-sync"; import { TNetlifySync } from "./netlify-sync"; import { TOCIVaultSync } from "./oci-vault-sync"; import { TRailwaySync } from "./railway-sync"; @@ -69,7 +70,8 @@ export type TSecretSync = | TSupabaseSync | TDigitalOceanAppPlatformSync | TNetlifySync - | TBitbucketSync; + | TBitbucketSync + | TLaravelForgeSync; export type TListSecretSyncs = { secretSyncs: TSecretSync[] }; diff --git a/frontend/src/hooks/api/secretSyncs/types/laravel-forge-sync.ts b/frontend/src/hooks/api/secretSyncs/types/laravel-forge-sync.ts new file mode 100644 index 000000000..cce693a48 --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/types/laravel-forge-sync.ts @@ -0,0 +1,20 @@ +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 TLaravelForgeSync = TRootSecretSync & { + destination: SecretSync.LaravelForge; + destinationConfig: { + orgSlug: string; + orgName: string; + serverId: string; + serverName: string; + siteId: string; + siteName: string; + }; + connection: { + app: AppConnection.LaravelForge; + name: string; + id: string; + }; +}; diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index ede2f8cf1..5c0fa687b 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -47,6 +47,7 @@ export type SubscriptionPlan = { gateway: boolean; externalKms: boolean; pkiEst: boolean; + pkiLegacyTemplates: boolean; enforceMfa: boolean; enforceGoogleSSO: boolean; projectTemplates: boolean; diff --git a/frontend/src/index.css b/frontend/src/index.css index 360894c2f..7c9b7db9b 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -42,7 +42,7 @@ --font-inter: "Inter", sans-serif; --color-org-v1: #30B3FF; --color-namespace-v1: #96ff59; - + --max-width-8xl: 88rem; /* 1408px */ /* Primary */ --color-primary-50: #fffff5; --color-primary-100: #fcfce8; diff --git a/frontend/src/layouts/AdminLayout/AdminLayout.tsx b/frontend/src/layouts/AdminLayout/AdminLayout.tsx index 859ce9627..89e8a761e 100644 --- a/frontend/src/layouts/AdminLayout/AdminLayout.tsx +++ b/frontend/src/layouts/AdminLayout/AdminLayout.tsx @@ -12,7 +12,7 @@ import { RedisBanner } from "@app/layouts/OrganizationLayout/components/RedisBan import { SmtpBanner } from "@app/layouts/OrganizationLayout/components/SmtpBanner"; import { InsecureConnectionBanner } from "../OrganizationLayout/components/InsecureConnectionBanner"; -import { AdminSidebar } from "./Sidebar"; +import { AdminNavBar } from "./AdminNavBar"; export const AdminLayout = () => { const { t } = useTranslation(); @@ -33,9 +33,9 @@ export const AdminLayout = () => { {!isLoading && !serverDetails?.emailConfigured && } {!isLoading && subscription.auditLogs && } {!window.isSecureContext && } -
- -
+
+ +
diff --git a/frontend/src/layouts/AdminLayout/AdminNavBar.tsx b/frontend/src/layouts/AdminLayout/AdminNavBar.tsx new file mode 100644 index 000000000..49cc1edf9 --- /dev/null +++ b/frontend/src/layouts/AdminLayout/AdminNavBar.tsx @@ -0,0 +1,99 @@ +import { faCheckCircle } from "@fortawesome/free-regular-svg-icons"; +import { + faArrowLeft, + faBuilding, + faCog, + faDatabase, + faKey, + faLock, + faPlug, + faUserTie +} from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Link, useMatchRoute } from "@tanstack/react-router"; +import { motion } from "framer-motion"; + +import { Tab, TabList, Tabs, Tooltip } from "@app/components/v2"; + +const generalTabs = [ + { + label: "General", + icon: faCog, + link: "/admin/" + }, + { + label: "Resource Overview", + icon: faBuilding, + link: "/admin/resources/overview" + }, + { + label: "Access Control", + icon: faUserTie, + link: "/admin/access-management" + }, + { + label: "Encryption", + icon: faLock, + link: "/admin/encryption" + }, + { + label: "Authentication", + icon: faCheckCircle, + link: "/admin/authentication" + }, + { + label: "Integrations", + icon: faPlug, + link: "/admin/integrations" + }, + { + label: "Caching", + icon: faDatabase, + link: "/admin/caching" + }, + { + label: "Environment Variables", + icon: faKey, + link: "/admin/environment" + } +]; + +export const AdminNavBar = () => { + const matchRoute = useMatchRoute(); + + return ( +
+ + + +
+ ); +}; diff --git a/frontend/src/layouts/AdminLayout/Sidebar.tsx b/frontend/src/layouts/AdminLayout/Sidebar.tsx deleted file mode 100644 index 185ea1dbf..000000000 --- a/frontend/src/layouts/AdminLayout/Sidebar.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { faCheckCircle } from "@fortawesome/free-regular-svg-icons"; -import { - faBuilding, - faChevronLeft, - faCog, - faDatabase, - faKey, - faLock, - faPlug, - faUserTie -} from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Link, useMatchRoute } from "@tanstack/react-router"; - -import { Menu, MenuGroup, MenuItem } from "@app/components/v2"; - -const generalTabs = [ - { - label: "General", - icon: faCog, - link: "/admin/" - }, - { - label: "Encryption", - icon: faLock, - link: "/admin/encryption" - }, - { - label: "Authentication", - icon: faCheckCircle, - link: "/admin/authentication" - }, - { - label: "Integrations", - icon: faPlug, - link: "/admin/integrations" - }, - { - label: "Caching", - icon: faDatabase, - link: "/admin/caching" - }, - { - label: "Environment Variables", - icon: faKey, - link: "/admin/environment" - } -]; - -const othersTabs = [ - { - label: "Access Controls", - icon: faUserTie, - link: "/admin/access-management" - }, - { - label: "Resource Overview", - icon: faBuilding, - link: "/admin/resources/overview" - } -]; - -export const AdminSidebar = () => { - const matchRoute = useMatchRoute(); - - return ( - - ); -}; diff --git a/frontend/src/layouts/KmsLayout/KmsLayout.tsx b/frontend/src/layouts/KmsLayout/KmsLayout.tsx index 4aa1c1406..c4fdf32eb 100644 --- a/frontend/src/layouts/KmsLayout/KmsLayout.tsx +++ b/frontend/src/layouts/KmsLayout/KmsLayout.tsx @@ -1,9 +1,7 @@ -import { faBook, faCog, faCube, faHome, faLock, faUsers } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Link, Outlet } from "@tanstack/react-router"; +import { Link, Outlet, useLocation } from "@tanstack/react-router"; import { motion } from "framer-motion"; -import { Lottie, Menu, MenuGroup, MenuItem } from "@app/components/v2"; +import { Tab, TabList, Tabs } from "@app/components/v2"; import { useProject, useProjectPermission } from "@app/context"; import { AssumePrivilegeModeBanner } from "../ProjectLayout/components/AssumePrivilegeModeBanner"; @@ -12,138 +10,81 @@ export const KmsLayout = () => { const { currentProject } = useProject(); const { assumedPrivilegeDetails } = useProjectPermission(); + const location = useLocation(); + return (
-
+
-
+ + {({ isActive }) => KMIP} + + + {({ isActive }) => ( + + Access Control + + )} + + + {({ isActive }) => Audit Logs} + + + {({ isActive }) => Settings} + + + -
- {assumedPrivilegeDetails && } - -
+
+ {assumedPrivilegeDetails && } +
+
); diff --git a/frontend/src/layouts/OrganizationLayout/OrganizationLayout.tsx b/frontend/src/layouts/OrganizationLayout/OrganizationLayout.tsx index f2866259c..c1cc10e41 100644 --- a/frontend/src/layouts/OrganizationLayout/OrganizationLayout.tsx +++ b/frontend/src/layouts/OrganizationLayout/OrganizationLayout.tsx @@ -13,7 +13,7 @@ import { useFetchServerStatus } from "@app/hooks/api"; import { AuditLogBanner } from "./components/AuditLogBanner"; import { InsecureConnectionBanner } from "./components/InsecureConnectionBanner"; import { Navbar } from "./components/NavBar"; -import { OrgSidebar } from "./components/OrgSidebar"; +import { OrgNavBar } from "./components/OrgNavBar"; import { RedisBanner } from "./components/RedisBanner"; import { SmtpBanner } from "./components/SmtpBanner"; @@ -41,16 +41,16 @@ export const OrganizationLayout = () => { className={`dark hidden ${containerHeight} w-full flex-col overflow-x-hidden bg-bunker-800 transition-all md:flex`} > - {!isLoading && !serverDetails?.redisConfigured && } - {!isLoading && !serverDetails?.emailConfigured && } - {!isLoading && subscription.auditLogs && } - {!window.isSecureContext && } -
- +
+ + {!isLoading && !isInsideProject && !serverDetails?.redisConfigured && } + {!isLoading && !isInsideProject && !serverDetails?.emailConfigured && } + {!isLoading && !isInsideProject && subscription.auditLogs && } + {!window.isSecureContext && !isInsideProject && }
diff --git a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx index 596202bf6..f3182ab83 100644 --- a/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/NavBar/Navbar.tsx @@ -9,6 +9,7 @@ import { faEnvelope, faExclamationTriangle, faGlobe, + faInfinity, faInfo, faInfoCircle, faServer, @@ -43,7 +44,7 @@ import { envConfig } from "@app/config/env"; import { useOrganization, useSubscription, useUser } from "@app/context"; import { isInfisicalCloud } from "@app/helpers/platform"; import { useToggle } from "@app/hooks"; -import { projectKeys, useGetOrganizations, useLogoutUser } from "@app/hooks/api"; +import { projectKeys, useGetOrganizations, useGetOrgTrialUrl, useLogoutUser } from "@app/hooks/api"; import { authKeys, selectOrganization } from "@app/hooks/api/auth/queries"; import { MfaMethod } from "@app/hooks/api/auth/types"; import { getAuthToken } from "@app/hooks/api/reactQuery"; @@ -162,6 +163,8 @@ export const Navbar = () => { await navigateUserToOrg(navigate, orgId); }; + const { mutateAsync } = useGetOrgTrialUrl(); + const logout = useLogoutUser(); const logOutUser = async () => { try { @@ -201,144 +204,192 @@ export const Navbar = () => { const isServerAdminPanel = location.pathname.startsWith("/admin"); - const isOrgScope = breadcrumbs?.length === 1; // TODO: scott/akhil is this adequate? + const isOrgScope = location.pathname.startsWith("/organization"); // TODO: scott/akhil is this adequate? return ( -
-
- - infisical logo - -
-

/

- {isServerAdminPanel ? ( - <> - -
- -
-
Server Console
+
+
+
+ + infisical logo -

/

- {breadcrumbs ? ( - // scott: remove /admin as we show server console above - - ) : null} - - ) : ( - <> -
- - -
- - - {currentOrg?.name} - -
- {getPlan(subscription)} -
- {subscription.cardDeclined && ( - +

/

+ {isServerAdminPanel ? ( + <> + +
+ +
+
Server Console
+ +

/

+ {breadcrumbs ? ( + // scott: remove /admin as we show server console above + + ) : null} + + ) : ( + <> +
+ + +
+ -
- -
- - )} -
- - -
- - - -
-
- -
organizations
- {orgs?.map((org) => { - return ( - - - - ); - })} -
- } onClick={logOutUser}> - Log Out - - - -
-

/

- {breadcrumbs ? ( - - ) : null} - + + )} +
+ + +
+ + + +
+
+ +
+ organizations +
+ {orgs?.map((org) => { + return ( + + + + ); + })} +
+ } + onClick={logOutUser} + > + Log Out + + + +
+ {!isOrgScope && ( + <> +

/

+ {breadcrumbs ? ( + + ) : null} + + )} + + )} +
+ {subscription && subscription.slug === "starter" && !subscription.has_used_trial && ( + + + + )} + {user.superAdmin && !location.pathname.startsWith("/admin") && ( + + + Server Console + )} -
diff --git a/frontend/src/layouts/OrganizationLayout/components/NavBar/Notification.tsx b/frontend/src/layouts/OrganizationLayout/components/NavBar/Notification.tsx index 82f5d0fdd..ade478c69 100644 --- a/frontend/src/layouts/OrganizationLayout/components/NavBar/Notification.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/NavBar/Notification.tsx @@ -16,15 +16,15 @@ export const Notification = ({ notification, onDelete }: Props) => { return (
-
+
{!notification.isRead && ( - + )} {notification.title}} @@ -45,7 +45,7 @@ export const Notification = ({ notification, onDelete }: Props) => { )}
-
+
{ + const { popUp, handlePopUpToggle } = usePopUp(["createOrg"] as const); + + const { pathname } = useLocation(); + + return ( + <> + {!isHidden && ( +
+ + + +
+ )} + handlePopUpToggle("createOrg", false)} + /> + + ); +}; diff --git a/frontend/src/layouts/OrganizationLayout/components/OrgNavBar/index.tsx b/frontend/src/layouts/OrganizationLayout/components/OrgNavBar/index.tsx new file mode 100644 index 000000000..06509e919 --- /dev/null +++ b/frontend/src/layouts/OrganizationLayout/components/OrgNavBar/index.tsx @@ -0,0 +1 @@ +export { OrgNavBar } from "./OrgNavBar"; diff --git a/frontend/src/layouts/OrganizationLayout/components/OrgSidebar/OrgSidebar.tsx b/frontend/src/layouts/OrganizationLayout/components/OrgSidebar/OrgSidebar.tsx deleted file mode 100644 index 51e14721e..000000000 --- a/frontend/src/layouts/OrganizationLayout/components/OrgSidebar/OrgSidebar.tsx +++ /dev/null @@ -1,214 +0,0 @@ -import { - faBook, - faCog, - faInfinity, - faMoneyBill, - faNetworkWired, - faPlug, - faShare, - faTable, - faUsers, - faUserTie -} from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Link } from "@tanstack/react-router"; -import { AnimatePresence, motion } from "framer-motion"; - -import { CreateOrgModal } from "@app/components/organization/CreateOrgModal"; -import { Menu, MenuGroup, MenuItem, Tooltip } from "@app/components/v2"; -import { useOrganization, useSubscription, useUser } from "@app/context"; -import { usePopUp } from "@app/hooks"; -import { useGetOrgTrialUrl } from "@app/hooks/api"; - -type Props = { - isHidden?: boolean; -}; - -export const OrgSidebar = ({ isHidden }: Props) => { - const { subscription } = useSubscription(); - - const { user } = useUser(); - const { mutateAsync } = useGetOrgTrialUrl(); - - const { currentOrg } = useOrganization(); - - const { popUp, handlePopUpToggle } = usePopUp(["createOrg"] as const); - - return ( - <> - - {!isHidden && ( - -
- } - > - Server Console - - - )} - - - - )} - - handlePopUpToggle("createOrg", false)} - /> - - ); -}; diff --git a/frontend/src/layouts/OrganizationLayout/components/OrgSidebar/index.tsx b/frontend/src/layouts/OrganizationLayout/components/OrgSidebar/index.tsx deleted file mode 100644 index 315d7ffab..000000000 --- a/frontend/src/layouts/OrganizationLayout/components/OrgSidebar/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { OrgSidebar } from "./OrgSidebar"; diff --git a/frontend/src/layouts/PamLayout/PamLayout.tsx b/frontend/src/layouts/PamLayout/PamLayout.tsx index 48f6af116..f34c29d0d 100644 --- a/frontend/src/layouts/PamLayout/PamLayout.tsx +++ b/frontend/src/layouts/PamLayout/PamLayout.tsx @@ -1,19 +1,9 @@ import { useEffect } from "react"; -import { - faBook, - faBoxOpen, - faCog, - faDisplay, - faHome, - faUser, - faUsers -} from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Link, Outlet } from "@tanstack/react-router"; +import { Link, Outlet, useLocation } from "@tanstack/react-router"; import { motion } from "framer-motion"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; -import { Lottie, Menu, MenuGroup, MenuItem } from "@app/components/v2"; +import { Tab, TabList, Tabs } from "@app/components/v2"; import { useProject, useProjectPermission, useSubscription } from "@app/context"; import { usePopUp } from "@app/hooks"; @@ -23,7 +13,7 @@ export const PamLayout = () => { const { currentProject } = useProject(); const { subscription } = useSubscription(); const { assumedPrivilegeDetails } = useProjectPermission(); - + const location = useLocation(); const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["upgradePlan"]); useEffect(() => { @@ -35,152 +25,85 @@ export const PamLayout = () => { return ( <>
-
+
-
+ + {({ isActive }) => Resources} + + + {({ isActive }) => Sessions} + + + {({ isActive }) => ( + + Access Control + + )} + + + {({ isActive }) => Audit Logs} + + + {({ isActive }) => Settings} + + + -
- {assumedPrivilegeDetails && } - -
+
+ {assumedPrivilegeDetails && } +
+
{ return ( <> -
+
{!window.isSecureContext && }
- -
+
diff --git a/frontend/src/layouts/PkiManagerLayout/PkiManagerLayout.tsx b/frontend/src/layouts/PkiManagerLayout/PkiManagerLayout.tsx index 93fa04ac9..2c088c293 100644 --- a/frontend/src/layouts/PkiManagerLayout/PkiManagerLayout.tsx +++ b/frontend/src/layouts/PkiManagerLayout/PkiManagerLayout.tsx @@ -1,249 +1,188 @@ import { useTranslation } from "react-i18next"; -import { - faBell, - faBook, - faCertificate, - faCog, - faFileLines, - faHome, - faMobile, - faPlug, - faPuzzlePiece, - faSitemap, - faStamp, - faUsers -} from "@fortawesome/free-solid-svg-icons"; +import { faMobile } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Link, Outlet } from "@tanstack/react-router"; +import { Link, Outlet, useLocation } from "@tanstack/react-router"; import { motion } from "framer-motion"; -import { Lottie, Menu, MenuGroup, MenuItem } from "@app/components/v2"; -import { useProject, useProjectPermission } from "@app/context"; +import { Tab, TabList, Tabs } from "@app/components/v2"; +import { useProject, useProjectPermission, useSubscription } from "@app/context"; +import { + useListWorkspaceCertificateTemplates, + useListWorkspacePkiSubscribers +} from "@app/hooks/api"; import { AssumePrivilegeModeBanner } from "../ProjectLayout/components/AssumePrivilegeModeBanner"; export const PkiManagerLayout = () => { const { currentProject } = useProject(); const { assumedPrivilegeDetails } = useProjectPermission(); + const { subscription } = useSubscription(); const { t } = useTranslation(); + const { data: subscribers = [] } = useListWorkspacePkiSubscribers(currentProject?.id || ""); + const { data: templatesData } = useListWorkspaceCertificateTemplates({ + projectId: currentProject?.id || "" + }); + const templates = templatesData?.certificateTemplates || []; + + const hasExistingSubscribers = subscribers.length > 0; + const hasExistingTemplates = templates.length > 0; + const showLegacySection = + subscription.pkiLegacyTemplates || hasExistingSubscribers || hasExistingTemplates; + + const location = useLocation(); return ( <>
-
+
-
+ + {({ isActive }) => ( + + Certificates + + )} + + + {({ isActive }) => ( + + Certificate Authorities + + )} + + + {({ isActive }) => Alerting} + + + {({ isActive }) => Integrations} + + + {({ isActive }) => ( + App Connections + )} + + {showLegacySection && ( + <> + {(subscription.pkiLegacyTemplates || hasExistingSubscribers) && ( + + {({ isActive }) => ( + Subscribers (Legacy) + )} + + )} + {(subscription.pkiLegacyTemplates || hasExistingTemplates) && ( + + {({ isActive }) => ( + + Certificate Templates (Legacy) + + )} + + )} + + )} + + {({ isActive }) => ( + + Access Control + + )} + + + {({ isActive }) => Audit Logs} + + + {({ isActive }) => Settings} + + + -
- {assumedPrivilegeDetails && } - -
+
+ {assumedPrivilegeDetails && } +
+
diff --git a/frontend/src/layouts/ProjectLayout/components/AssumePrivilegeModeBanner/AssumePrivilegeModeBanner.tsx b/frontend/src/layouts/ProjectLayout/components/AssumePrivilegeModeBanner/AssumePrivilegeModeBanner.tsx index 71af774ca..45978e3d7 100644 --- a/frontend/src/layouts/ProjectLayout/components/AssumePrivilegeModeBanner/AssumePrivilegeModeBanner.tsx +++ b/frontend/src/layouts/ProjectLayout/components/AssumePrivilegeModeBanner/AssumePrivilegeModeBanner.tsx @@ -15,7 +15,7 @@ export const AssumePrivilegeModeBanner = () => { if (!assumedPrivilegeDetails) return null; return ( -
+
You are currently viewing the project with privileges of{" "} @@ -24,7 +24,7 @@ export const AssumePrivilegeModeBanner = () => { {assumedPrivilegeDetails?.actorName}
-
+
+ +
+ + )} + + + ); +}; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateModal.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateModal.tsx index 918aa7303..22718222a 100644 --- a/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateModal.tsx +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/CertificateModal.tsx @@ -46,9 +46,8 @@ const schema = z.object({ certificateTemplateId: z.string().optional(), caId: z.string(), collectionId: z.string().optional(), - friendlyName: z.string(), commonName: z.string().trim().min(1), - altNames: z.string(), + subjectAltNames: z.string(), ttl: z.string().trim(), keyUsages: z.object({ [CertKeyUsage.DIGITAL_SIGNATURE]: z.boolean().optional(), @@ -139,9 +138,8 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { if (cert) { reset({ caId: cert.caId, - friendlyName: cert.friendlyName, commonName: cert.commonName, - altNames: cert.altNames, + subjectAltNames: cert.subjectAltNames, certificateTemplateId: cert.certificateTemplateId ?? CERT_TEMPLATE_NONE_VALUE, ttl: "", keyUsages: Object.fromEntries((cert.keyUsages || []).map((name) => [name, true])), @@ -152,9 +150,8 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { } else { reset({ caId: "", - friendlyName: "", commonName: "", - altNames: "", + subjectAltNames: "", ttl: "", certificateTemplateId: CERT_TEMPLATE_NONE_VALUE, keyUsages: { @@ -182,10 +179,9 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { const onFormSubmit = async ({ caId, - friendlyName, collectionId, commonName, - altNames, + subjectAltNames, ttl, keyUsages, extendedKeyUsages @@ -198,9 +194,8 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { certificateTemplateId: selectedCertTemplate ? selectedCertTemplateId : undefined, projectSlug: currentProject.slug, pkiCollectionId: collectionId, - friendlyName, commonName, - altNames, + subjectAltNames, ttl, keyUsages: Object.entries(keyUsages) .filter(([, value]) => value) @@ -359,20 +354,6 @@ export const CertificateModal = ({ popUp, handlePopUpToggle }: Props) => { /> )} - ( - - - - )} - /> { ( { const { currentProject } = useProject(); + const { subscription } = useSubscription(); const { mutateAsync: deleteCert } = useDeleteCert(); + const isLegacyTemplatesEnabled = subscription.pkiLegacyTemplates; + const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([ + "certificateIssuance", "certificate", "certificateImport", "certificateCert", @@ -73,7 +79,9 @@ export const CertificatesSection = () => { colorSchema="primary" type="submit" leftIcon={} - onClick={() => handlePopUpOpen("certificate")} + onClick={() => + handlePopUpOpen(isLegacyTemplatesEnabled ? "certificate" : "certificateIssuance") + } isDisabled={!isAllowed} > Issue @@ -83,7 +91,11 @@ export const CertificatesSection = () => {
- + {isLegacyTemplatesEnabled ? ( + + ) : ( + + )} diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/KeyUsageSection.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/KeyUsageSection.tsx new file mode 100644 index 000000000..747c6c09f --- /dev/null +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/KeyUsageSection.tsx @@ -0,0 +1,78 @@ +import { Control, Controller } from "react-hook-form"; + +import { + AccordionContent, + AccordionItem, + AccordionTrigger, + Checkbox, + FormLabel +} from "@app/components/v2"; + +type KeyUsageOption = { + label: string; + value: string; +}; + +type KeyUsageSectionProps = { + control: Control; + title: string; + accordionValue: string; + namePrefix: "keyUsages" | "extendedKeyUsages"; + options: KeyUsageOption[]; + requiredUsages: string[]; +}; + +export const KeyUsageSection = ({ + control, + title, + accordionValue, + namePrefix, + options, + requiredUsages +}: KeyUsageSectionProps) => { + if (options.length === 0) return null; + + return ( + + {title} + +
+ {options.map(({ label, value }) => { + const isRequired = requiredUsages.includes(value); + return ( + ( +
+ { + if (!isRequired) { + field.onChange(checked); + } + }} + isDisabled={isRequired} + /> +
+ + {isRequired && (Required)} +
+
+ )} + /> + ); + })} +
+
+
+ ); +}; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/SubjectAltNamesField.tsx b/frontend/src/pages/cert-manager/CertificatesPage/components/SubjectAltNamesField.tsx new file mode 100644 index 000000000..4614539e0 --- /dev/null +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/SubjectAltNamesField.tsx @@ -0,0 +1,99 @@ +import { Control, Controller } from "react-hook-form"; +import { faPlus, faTrash } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { Button, FormControl, IconButton, Input, Select, SelectItem } from "@app/components/v2"; +import { CertSubjectAlternativeNameType } from "@app/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/certificate-constants"; + +import { getSanPlaceholder, getSanTypeLabels, SubjectAltName } from "./certificateUtils"; + +type SubjectAltNamesFieldProps = { + control: Control; + allowedSanTypes: CertSubjectAlternativeNameType[]; + error?: string; +}; + +export const SubjectAltNamesField = ({ + control, + allowedSanTypes, + error +}: SubjectAltNamesFieldProps) => { + const sanTypeLabels = getSanTypeLabels(); + + return ( + ( + +
+ {value.map((san: SubjectAltName, index: number) => ( + // eslint-disable-next-line react/no-array-index-key +
+ + { + const newValue = [...value]; + newValue[index] = { ...san, value: e.target.value }; + onChange(newValue); + }} + placeholder={getSanPlaceholder(san.type)} + className="flex-1" + /> + { + const newValue = value.filter((_: any, i: number) => i !== index); + onChange(newValue); + }} + > + + +
+ ))} + +
+
+ )} + /> + ); +}; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/certificateUtils.ts b/frontend/src/pages/cert-manager/CertificatesPage/components/certificateUtils.ts new file mode 100644 index 000000000..7f16b87e0 --- /dev/null +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/certificateUtils.ts @@ -0,0 +1,51 @@ +import { CertSubjectAlternativeNameType } from "@app/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/certificate-constants"; + +export const getSanPlaceholder = (sanType: CertSubjectAlternativeNameType): string => { + switch (sanType) { + case CertSubjectAlternativeNameType.DNS_NAME: + return "example.com or *.example.com"; + case CertSubjectAlternativeNameType.IP_ADDRESS: + return "192.168.1.1"; + case CertSubjectAlternativeNameType.EMAIL: + return "admin@example.com"; + case CertSubjectAlternativeNameType.URI: + return "https://example.com"; + default: + return "Enter value"; + } +}; + +export const getSanTypeLabels = () => ({ + [CertSubjectAlternativeNameType.DNS_NAME]: "DNS", + [CertSubjectAlternativeNameType.IP_ADDRESS]: "IP", + [CertSubjectAlternativeNameType.EMAIL]: "Email", + [CertSubjectAlternativeNameType.URI]: "URI" +}); + +export type SubjectAltName = { + type: CertSubjectAlternativeNameType; + value: string; +}; + +export const formatSubjectAltNames = (subjectAltNames: SubjectAltName[]) => { + return subjectAltNames + .filter((san) => san.value.trim()) + .map((san) => ({ + type: san.type, + value: san.value.trim() + })); +}; + +export const filterUsages = >(usages: T): string[] => { + return Object.entries(usages) + .filter(([, value]) => value) + .map(([key]) => key); +}; + +export const getAttributeValue = ( + subjectAttributes: Array<{ type: string; value: string }> | undefined, + type: string +): string => { + const foundAttr = subjectAttributes?.find((attr) => attr.type === type); + return foundAttr?.value || ""; +}; diff --git a/frontend/src/pages/cert-manager/CertificatesPage/components/useCertificateTemplate.ts b/frontend/src/pages/cert-manager/CertificatesPage/components/useCertificateTemplate.ts new file mode 100644 index 000000000..871ad00a4 --- /dev/null +++ b/frontend/src/pages/cert-manager/CertificatesPage/components/useCertificateTemplate.ts @@ -0,0 +1,179 @@ +import { useEffect, useMemo, useState } from "react"; +import { UseFormSetValue, UseFormWatch } from "react-hook-form"; + +import { + EXTENDED_KEY_USAGES_OPTIONS, + KEY_USAGES_OPTIONS +} from "@app/hooks/api/certificates/constants"; +import { + CertSubjectAlternativeNameType, + mapTemplateKeyAlgorithmToApi, + mapTemplateSignatureAlgorithmToApi +} from "@app/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/certificate-constants"; + +export type TemplateConstraints = { + allowedKeyUsages: string[]; + allowedExtendedKeyUsages: string[]; + requiredKeyUsages: string[]; + requiredExtendedKeyUsages: string[]; + allowedSignatureAlgorithms: string[]; + allowedKeyAlgorithms: string[]; + allowedSanTypes: CertSubjectAlternativeNameType[]; + shouldShowSanSection: boolean; + shouldShowSubjectSection: boolean; +}; + +export const useCertificateTemplate = ( + templateData: any, + selectedProfile: any, + isModalOpen: boolean, + setValue: UseFormSetValue, + watch: UseFormWatch +) => { + const [constraints, setConstraints] = useState({ + allowedKeyUsages: [], + allowedExtendedKeyUsages: [], + requiredKeyUsages: [], + requiredExtendedKeyUsages: [], + allowedSignatureAlgorithms: [], + allowedKeyAlgorithms: [], + allowedSanTypes: [ + CertSubjectAlternativeNameType.DNS_NAME, + CertSubjectAlternativeNameType.IP_ADDRESS, + CertSubjectAlternativeNameType.EMAIL, + CertSubjectAlternativeNameType.URI + ], + shouldShowSanSection: true, + shouldShowSubjectSection: true + }); + + const filteredKeyUsages = useMemo(() => { + return KEY_USAGES_OPTIONS.filter(({ value }) => constraints.allowedKeyUsages.includes(value)); + }, [constraints.allowedKeyUsages]); + + const filteredExtendedKeyUsages = useMemo(() => { + return EXTENDED_KEY_USAGES_OPTIONS.filter(({ value }) => + constraints.allowedExtendedKeyUsages.includes(value) + ); + }, [constraints.allowedExtendedKeyUsages]); + + const availableSignatureAlgorithms = useMemo(() => { + return constraints.allowedSignatureAlgorithms.map((templateAlgorithm) => { + const apiAlgorithm = mapTemplateSignatureAlgorithmToApi(templateAlgorithm); + return { + value: apiAlgorithm, + label: apiAlgorithm + }; + }); + }, [constraints.allowedSignatureAlgorithms]); + + const availableKeyAlgorithms = useMemo(() => { + return constraints.allowedKeyAlgorithms.map((templateAlgorithm) => { + const apiAlgorithm = mapTemplateKeyAlgorithmToApi(templateAlgorithm); + return { + value: apiAlgorithm, + label: apiAlgorithm + }; + }); + }, [constraints.allowedKeyAlgorithms]); + + const resetConstraints = () => { + setConstraints({ + allowedKeyUsages: [], + allowedExtendedKeyUsages: [], + requiredKeyUsages: [], + requiredExtendedKeyUsages: [], + allowedSignatureAlgorithms: [], + allowedKeyAlgorithms: [], + allowedSanTypes: [ + CertSubjectAlternativeNameType.DNS_NAME, + CertSubjectAlternativeNameType.IP_ADDRESS, + CertSubjectAlternativeNameType.EMAIL, + CertSubjectAlternativeNameType.URI + ], + shouldShowSanSection: true, + shouldShowSubjectSection: true + }); + }; + + useEffect(() => { + if (templateData && selectedProfile && isModalOpen) { + const newConstraints: TemplateConstraints = { + allowedSignatureAlgorithms: templateData.algorithms?.signature || [], + allowedKeyAlgorithms: templateData.algorithms?.keyAlgorithm || [], + allowedKeyUsages: [ + ...(templateData.keyUsages?.required || []), + ...(templateData.keyUsages?.allowed || []) + ], + allowedExtendedKeyUsages: [ + ...(templateData.extendedKeyUsages?.required || []), + ...(templateData.extendedKeyUsages?.allowed || []) + ], + requiredKeyUsages: templateData.keyUsages?.required || [], + requiredExtendedKeyUsages: templateData.extendedKeyUsages?.required || [], + allowedSanTypes: [], + shouldShowSanSection: true, + shouldShowSubjectSection: true + }; + + // Set TTL if available + if (templateData.validity?.max) { + setValue("ttl", templateData.validity.max); + } + + // Handle SAN types + if (templateData.sans && templateData.sans.length > 0) { + const sanTypes: CertSubjectAlternativeNameType[] = []; + templateData.sans.forEach((sanPolicy: any) => { + if (!sanTypes.includes(sanPolicy.type)) { + sanTypes.push(sanPolicy.type); + } + }); + newConstraints.allowedSanTypes = sanTypes; + newConstraints.shouldShowSanSection = true; + } else { + newConstraints.allowedSanTypes = []; + newConstraints.shouldShowSanSection = false; + setValue("subjectAltNames", []); + } + + // Handle subject section + if (templateData.subject && templateData.subject.length > 0) { + newConstraints.shouldShowSubjectSection = true; + const currentSubjectAttrs = watch("subjectAttributes"); + if (!currentSubjectAttrs || currentSubjectAttrs.length === 0) { + setValue("subjectAttributes", [{ type: "common_name", value: "" }]); + } + } else { + newConstraints.shouldShowSubjectSection = false; + setValue("subjectAttributes", undefined); + } + + setConstraints(newConstraints); + + // Set initial required usages + const initialKeyUsages: Record = {}; + const initialExtendedKeyUsages: Record = {}; + + (templateData.keyUsages?.required || []).forEach((usage: string) => { + initialKeyUsages[usage] = true; + }); + + (templateData.extendedKeyUsages?.required || []).forEach((usage: string) => { + initialExtendedKeyUsages[usage] = true; + }); + + setValue("keyUsages", initialKeyUsages); + setValue("extendedKeyUsages", initialExtendedKeyUsages); + } + }, [templateData, selectedProfile, setValue, watch, isModalOpen]); + + return { + constraints, + filteredKeyUsages, + filteredExtendedKeyUsages, + availableSignatureAlgorithms, + availableKeyAlgorithms, + resetConstraints + }; +}; diff --git a/frontend/src/pages/cert-manager/IntegrationsListPage/IntegrationsListPage.tsx b/frontend/src/pages/cert-manager/IntegrationsListPage/IntegrationsListPage.tsx index 6545636f0..8358c28a3 100644 --- a/frontend/src/pages/cert-manager/IntegrationsListPage/IntegrationsListPage.tsx +++ b/frontend/src/pages/cert-manager/IntegrationsListPage/IntegrationsListPage.tsx @@ -7,6 +7,7 @@ import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; import { ROUTE_PATHS } from "@app/const/routes"; import { ProjectPermissionSub, useProject } from "@app/context"; import { ProjectPermissionPkiSyncActions } from "@app/context/ProjectPermissionContext/types"; +import { ProjectType } from "@app/hooks/api/projects/types"; import { IntegrationsListPageTabs } from "@app/types/integrations"; import { PkiSyncsTab } from "./components"; @@ -42,16 +43,18 @@ export const IntegrationsListPage = () => { -
+
- + - Certificate Syncs + + Certificate Syncs + { }; return ( -
+
{data && ( -
- +
+ + + Certificates + +
diff --git a/frontend/src/pages/cert-manager/PkiSubscriberDetailsByIDPage/PkiSubscriberDetailsByIDPage.tsx b/frontend/src/pages/cert-manager/PkiSubscriberDetailsByIDPage/PkiSubscriberDetailsByIDPage.tsx index 2a7fdf56d..1f723b395 100644 --- a/frontend/src/pages/cert-manager/PkiSubscriberDetailsByIDPage/PkiSubscriberDetailsByIDPage.tsx +++ b/frontend/src/pages/cert-manager/PkiSubscriberDetailsByIDPage/PkiSubscriberDetailsByIDPage.tsx @@ -1,6 +1,8 @@ import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; -import { useNavigate, useParams } from "@tanstack/react-router"; +import { faChevronLeft } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Link, useNavigate, useParams } from "@tanstack/react-router"; import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; @@ -22,6 +24,7 @@ import { useProject } from "@app/context"; import { useDeletePkiSubscriber, useGetPkiSubscriber } from "@app/hooks/api"; +import { ProjectType } from "@app/hooks/api/projects/types"; import { usePopUp } from "@app/hooks/usePopUp"; import { PkiSubscriberModal } from "../PkiSubscribersPage/components/PkiSubscriberModal"; @@ -75,10 +78,24 @@ const Page = () => { }; return ( -
+
{data && ( -
- +
+ + + Subscribers + +
diff --git a/frontend/src/pages/cert-manager/PkiSubscribersPage/PkiSubscribersPage.tsx b/frontend/src/pages/cert-manager/PkiSubscribersPage/PkiSubscribersPage.tsx index bdb525b59..f38839be9 100644 --- a/frontend/src/pages/cert-manager/PkiSubscribersPage/PkiSubscribersPage.tsx +++ b/frontend/src/pages/cert-manager/PkiSubscribersPage/PkiSubscribersPage.tsx @@ -2,6 +2,7 @@ import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; import { PageHeader } from "@app/components/v2"; +import { ProjectType } from "@app/hooks/api/projects/types"; import { PkiSubscriberSection } from "./components"; @@ -13,10 +14,10 @@ export const PkiSubscribersPage = () => { {t("common.head-title", { title: "PKI Subscribers" })}
-
-
+
+
diff --git a/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberSection.tsx b/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberSection.tsx index 984ef45ae..cb4b9ef39 100644 --- a/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberSection.tsx +++ b/frontend/src/pages/cert-manager/PkiSubscribersPage/components/PkiSubscriberSection.tsx @@ -7,7 +7,8 @@ import { Button, DeleteActionModal } from "@app/components/v2"; import { ProjectPermissionPkiSubscriberActions, ProjectPermissionSub, - useProject + useProject, + useSubscription } from "@app/context"; import { useDeletePkiSubscriber, useUpdatePkiSubscriber } from "@app/hooks/api"; import { PkiSubscriberStatus } from "@app/hooks/api/pkiSubscriber/types"; @@ -18,7 +19,10 @@ import { PkiSubscribersTable } from "./PkiSubscribersTable"; export const PkiSubscriberSection = () => { const { currentProject } = useProject(); + const { subscription } = useSubscription(); const projectId = currentProject.id; + + const canCreateLegacySubscribers = subscription.pkiLegacyTemplates; const { mutateAsync: deletePkiSubscriber } = useDeletePkiSubscriber(); const { mutateAsync: updatePkiSubscriber } = useUpdatePkiSubscriber(); @@ -100,23 +104,25 @@ export const PkiSubscriberSection = () => { /> - - {(isAllowed) => ( - - )} - + {canCreateLegacySubscribers && ( + + {(isAllowed) => ( + + )} + + )}
diff --git a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/PkiSyncDetailsByIDPage.tsx b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/PkiSyncDetailsByIDPage.tsx index 26d2b43f9..76cc0ec7b 100644 --- a/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/PkiSyncDetailsByIDPage.tsx +++ b/frontend/src/pages/cert-manager/PkiSyncDetailsByIDPage/PkiSyncDetailsByIDPage.tsx @@ -68,8 +68,8 @@ const PageContent = () => { return ( <> -
-
+
+
- )} - +
+ {subscription?.pkiLegacyTemplates && ( +
+

Templates

+
+ + {(isAllowed) => ( + + )} + +
-
+ )} @@ -267,7 +271,7 @@ export const PkiTemplateListPage = () => { onDeleteApproved={() => onRemovePkiSubscriberSubmit()} /> -
+
{ + const { t } = useTranslation(); + const { currentProject } = useProject(); + const [activeTab, setActiveTab] = useState(TabSections.CertificateProfiles); + + if (!currentProject) { + return ; + } + + return ( + + {(isAllowed) => { + if (!isAllowed) { + return ( +
+
+

You don't have permission to access certificate policies.

+
+
+ ); + } + + return ( +
+ + {t("common.head-title", { title: "Certificate Policies" })} + +
+ + + setActiveTab(value as TabSections)} + > + + + Certificate Profiles + + + Certificate Templates + + + + + + + + + + + +
+
+ ); + }} +
+ ); +}; diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx new file mode 100644 index 000000000..d034aeda1 --- /dev/null +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CertificateProfilesTab.tsx @@ -0,0 +1,127 @@ +import { useState } from "react"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { Button, DeleteActionModal } from "@app/components/v2"; +import { useProjectPermission } from "@app/context"; +import { + ProjectPermissionActions, + ProjectPermissionSub +} from "@app/context/ProjectPermissionContext/types"; +import { + TCertificateProfileWithDetails, + useDeleteCertificateProfile +} from "@app/hooks/api/certificateProfiles"; + +import { CreateProfileModal } from "./CreateProfileModal"; +import { ProfileList } from "./ProfileList"; + +export const CertificateProfilesTab = () => { + const { permission } = useProjectPermission(); + + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selectedProfile, setSelectedProfile] = useState( + null + ); + + const deleteProfile = useDeleteCertificateProfile(); + + const canCreateProfile = permission.can( + ProjectPermissionActions.Create, + ProjectPermissionSub.CertificateAuthorities + ); + + const handleCreateProfile = () => { + setIsCreateModalOpen(true); + }; + + const handleEditProfile = (profile: TCertificateProfileWithDetails) => { + setSelectedProfile(profile); + setIsEditModalOpen(true); + }; + + const handleDeleteProfile = (profile: TCertificateProfileWithDetails) => { + setSelectedProfile(profile); + setIsDeleteModalOpen(true); + }; + + const handleDeleteConfirm = async () => { + if (!selectedProfile) return; + + try { + await deleteProfile.mutateAsync({ + profileId: selectedProfile.id + }); + setIsDeleteModalOpen(false); + setSelectedProfile(null); + createNotification({ + text: `Certificate profile "${selectedProfile.slug}" deleted successfully`, + type: "success" + }); + } catch (error) { + console.error( + `Failed to delete profile "${selectedProfile.slug}" (ID: ${selectedProfile.id}):`, + error + ); + } + }; + + return ( +
+
+
+

Certificate Profiles

+

+ Unified certificate issuance configurations combining CA, template, and enrollment + method +

+
+ + {canCreateProfile && ( + + )} +
+ + + + setIsCreateModalOpen(false)} /> + + {selectedProfile && ( + <> + { + setIsEditModalOpen(false); + setSelectedProfile(null); + }} + profile={selectedProfile} + mode="edit" + /> + + { + setIsDeleteModalOpen(isOpen); + if (!isOpen) { + setSelectedProfile(null); + } + }} + deleteKey={selectedProfile.slug} + onDeleteApproved={handleDeleteConfirm} + /> + + )} +
+ ); +}; diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx new file mode 100644 index 000000000..3f7553f83 --- /dev/null +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx @@ -0,0 +1,609 @@ +import { useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + Checkbox, + FormControl, + Input, + Modal, + ModalContent, + Select, + SelectItem, + TextArea +} from "@app/components/v2"; +import { useProject } from "@app/context"; +import { useListCasByProjectId } from "@app/hooks/api/ca/queries"; +import { + TCertificateProfileWithDetails, + TCreateCertificateProfileDTO, + TUpdateCertificateProfileDTO, + useCreateCertificateProfile, + useUpdateCertificateProfile +} from "@app/hooks/api/certificateProfiles"; +import { useListCertificateTemplatesV2 } from "@app/hooks/api/certificateTemplates/queries"; + +const createSchema = z + .object({ + slug: z + .string() + .trim() + .min(1, "Profile slug is required") + .max(255, "Profile slug must be less than 255 characters") + .regex( + /^[a-zA-Z0-9-_]+$/, + "Profile slug must contain only letters, numbers, hyphens, and underscores" + ), + description: z + .string() + .trim() + .max(1000, "Description must be less than 1000 characters") + .optional(), + enrollmentType: z.enum(["api", "est"]), + certificateAuthorityId: z.string().min(1, "Certificate Authority is required"), + certificateTemplateId: z.string().min(1, "Certificate Template is required"), + estConfig: z + .object({ + disableBootstrapCaValidation: z.boolean().optional(), + passphrase: z.string().min(1, "EST passphrase is required"), + caChain: z.string().min(1, "EST CA chain is required").optional() + }) + .refine( + (data) => { + if (!data.disableBootstrapCaValidation && !data.caChain) { + return false; + } + return true; + }, + { + message: "EST CA chain is required when bootstrap CA validation is enabled", + path: ["caChain"] + } + ) + .optional(), + apiConfig: z + .object({ + autoRenew: z.boolean().optional(), + autoRenewDays: z.number().min(1).max(365).optional() + }) + .optional() + }) + .refine( + (data) => { + if (data.enrollmentType === "est" && !data.estConfig) { + return false; + } + if (data.enrollmentType === "api" && !data.apiConfig) { + return false; + } + return true; + }, + { + message: "Configuration is required for selected enrollment type" + } + ); + +const editSchema = z + .object({ + slug: z + .string() + .trim() + .min(1, "Profile slug is required") + .max(255, "Profile slug must be less than 255 characters") + .regex( + /^[a-zA-Z0-9-_]+$/, + "Profile slug must contain only letters, numbers, hyphens, and underscores" + ), + description: z + .string() + .trim() + .max(1000, "Description must be less than 1000 characters") + .optional(), + enrollmentType: z.enum(["api", "est"]), + certificateAuthorityId: z.string().optional(), + certificateTemplateId: z.string().optional(), + estConfig: z + .object({ + disableBootstrapCaValidation: z.boolean().optional(), + passphrase: z.string().optional(), + caChain: z.string().optional() + }) + .optional(), + apiConfig: z + .object({ + autoRenew: z.boolean().optional(), + autoRenewDays: z.number().min(1).max(365).optional() + }) + .optional() + }) + .refine( + (data) => { + if (data.enrollmentType === "est" && !data.estConfig) { + return false; + } + if (data.enrollmentType === "api" && !data.apiConfig) { + return false; + } + return true; + }, + { + message: "Configuration is required for selected enrollment type" + } + ); + +export type FormData = z.infer; + +interface Props { + isOpen: boolean; + onClose: () => void; + profile?: TCertificateProfileWithDetails; + mode?: "create" | "edit"; +} + +export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }: Props) => { + const { currentProject } = useProject(); + + const { data: caData } = useListCasByProjectId(currentProject?.id || ""); + const { data: templateData } = useListCertificateTemplatesV2({ + projectId: currentProject?.id || "", + limit: 100, + offset: 0 + }); + + const createProfile = useCreateCertificateProfile(); + const updateProfile = useUpdateCertificateProfile(); + + const isEdit = mode === "edit" && profile; + + const certificateAuthorities = caData || []; + const certificateTemplates = templateData?.certificateTemplates || []; + + const { control, handleSubmit, reset, watch, setValue, formState } = useForm({ + resolver: zodResolver(isEdit ? editSchema : createSchema), + defaultValues: isEdit + ? { + slug: profile.slug, + description: profile.description || "", + enrollmentType: profile.enrollmentType, + certificateAuthorityId: profile.caId, + certificateTemplateId: profile.certificateTemplateId, + estConfig: + profile.enrollmentType === "est" + ? { + disableBootstrapCaValidation: + profile.estConfig?.disableBootstrapCaValidation || false, + passphrase: profile.estConfig?.passphrase || "", + caChain: profile.estConfig?.caChain || "" + } + : undefined, + apiConfig: + profile.enrollmentType === "api" + ? { + autoRenew: profile.apiConfig?.autoRenew || false, + autoRenewDays: profile.apiConfig?.autoRenewDays || 30 + } + : undefined + } + : { + slug: "", + description: "", + enrollmentType: "api", + certificateAuthorityId: "", + certificateTemplateId: "", + apiConfig: { + autoRenew: false, + autoRenewDays: 30 + } + } + }); + + const watchedEnrollmentType = watch("enrollmentType"); + const watchedDisableBootstrapValidation = watch("estConfig.disableBootstrapCaValidation"); + const watchedAutoRenew = watch("apiConfig.autoRenew"); + + useEffect(() => { + if (isEdit && profile) { + reset({ + slug: profile.slug, + description: profile.description || "", + enrollmentType: profile.enrollmentType, + certificateAuthorityId: profile.caId, + certificateTemplateId: profile.certificateTemplateId, + estConfig: + profile.enrollmentType === "est" + ? { + disableBootstrapCaValidation: + profile.estConfig?.disableBootstrapCaValidation || false, + passphrase: profile.estConfig?.passphrase || "", + caChain: profile.estConfig?.caChain || "" + } + : undefined, + apiConfig: + profile.enrollmentType === "api" + ? { + autoRenew: profile.apiConfig?.autoRenew || false, + autoRenewDays: profile.apiConfig?.autoRenewDays || 30 + } + : undefined + }); + } + }, [isEdit, profile, reset]); + + const onFormSubmit = async (data: FormData) => { + try { + if (!currentProject?.id && !isEdit) return; + + if (isEdit) { + const updateData: TUpdateCertificateProfileDTO = { + profileId: profile.id, + slug: data.slug, + description: data.description + }; + + if (data.enrollmentType === "est" && data.estConfig) { + updateData.estConfig = data.estConfig; + } else if (data.enrollmentType === "api" && data.apiConfig) { + updateData.apiConfig = data.apiConfig; + } + + await updateProfile.mutateAsync(updateData); + } else { + if (!currentProject?.id) { + throw new Error("Project ID is required for creating a profile"); + } + + const createData: TCreateCertificateProfileDTO = { + projectId: currentProject.id, + slug: data.slug, + description: data.description, + enrollmentType: data.enrollmentType, + caId: data.certificateAuthorityId, + certificateTemplateId: data.certificateTemplateId + }; + + if (data.enrollmentType === "est" && data.estConfig) { + createData.estConfig = { + passphrase: data.estConfig.passphrase, + caChain: data.estConfig.caChain || undefined, + disableBootstrapCaValidation: data.estConfig.disableBootstrapCaValidation + }; + } else if (data.enrollmentType === "api" && data.apiConfig) { + createData.apiConfig = data.apiConfig; + } + + await createProfile.mutateAsync(createData); + } + + createNotification({ + text: `Certificate profile ${isEdit ? "updated" : "created"} successfully`, + type: "success" + }); + + reset(); + onClose(); + } catch (error) { + console.error(`Error ${isEdit ? "updating" : "creating"} profile:`, error); + createNotification({ + text: `Failed to ${isEdit ? "update" : "create"} certificate profile`, + type: "error" + }); + } + }; + + return ( + { + if (!open) { + reset(); + } + onClose(); + }} + > + +
+ ( + + + + )} + /> + + ( + +