diff --git a/backend/src/db/migrations/20250508160957_pki-subscriber.ts b/backend/src/db/migrations/20250508160957_pki-subscriber.ts index a7fbf0047..0e1b50f03 100644 --- a/backend/src/db/migrations/20250508160957_pki-subscriber.ts +++ b/backend/src/db/migrations/20250508160957_pki-subscriber.ts @@ -10,8 +10,8 @@ export async function up(knex: Knex): Promise { t.timestamps(true, true, true); 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).onDelete("CASCADE"); + t.uuid("caId").nullable(); + t.foreign("caId").references("id").inTable(TableName.CertificateAuthority).onDelete("SET NULL"); t.string("name").notNullable(); t.string("commonName").notNullable(); t.specificType("subjectAlternativeNames", "text[]").notNullable(); diff --git a/backend/src/db/schemas/pki-subscribers.ts b/backend/src/db/schemas/pki-subscribers.ts index 3ce7ec648..08db19806 100644 --- a/backend/src/db/schemas/pki-subscribers.ts +++ b/backend/src/db/schemas/pki-subscribers.ts @@ -12,7 +12,7 @@ export const PkiSubscribersSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), projectId: z.string(), - caId: z.string().uuid(), + caId: z.string().uuid().nullable().optional(), name: z.string(), commonName: z.string(), subjectAlternativeNames: z.string().array(), diff --git a/backend/src/services/pki-subscriber/pki-subscriber-service.ts b/backend/src/services/pki-subscriber/pki-subscriber-service.ts index d171093eb..5b15786b1 100644 --- a/backend/src/services/pki-subscriber/pki-subscriber-service.ts +++ b/backend/src/services/pki-subscriber/pki-subscriber-service.ts @@ -2,6 +2,7 @@ import { ForbiddenError, subject } from "@casl/ability"; import * as x509 from "@peculiar/x509"; import crypto, { KeyObject } from "crypto"; +import { z } from "zod"; import { ActionProjectType } from "@app/db/schemas"; import { TCertificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal"; @@ -13,6 +14,7 @@ import { import { getConfig } from "@app/lib/config/env"; import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { ms } from "@app/lib/ms"; +import { isFQDN } from "@app/lib/validator/validate-url"; import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal"; @@ -262,6 +264,7 @@ export const pkiSubscriberServiceFactory = ({ projectId }); if (!subscriber) throw new NotFoundError({ message: `PKI subscriber named '${subscriberName}' not found` }); + if (!subscriber.caId) throw new BadRequestError({ message: "Subscriber does not have an assigned issuing CA" }); const ca = await certificateAuthorityDAL.findById(subscriber.caId); if (!ca) throw new NotFoundError({ message: `CA with ID '${subscriber.caId}' not found` }); @@ -365,7 +368,32 @@ export const pkiSubscriberServiceFactory = ({ extensions.push(new x509.KeyUsagesExtension(keyUsagesBitValue, true)); } - const selectedExtendedKeyUsages = subscriber.extendedKeyUsages as CertExtendedKeyUsage[]; + if (subscriber.extendedKeyUsages.length) { + const extendedKeyUsagesExtension = new x509.ExtendedKeyUsageExtension( + subscriber.extendedKeyUsages.map((eku) => x509.ExtendedKeyUsage[eku as CertExtendedKeyUsage]), + true + ); + extensions.push(extendedKeyUsagesExtension); + } + + let altNamesArray: { type: "email" | "dns"; value: string }[] = []; + + if (subscriber.subjectAlternativeNames?.length) { + altNamesArray = subscriber.subjectAlternativeNames.map((altName) => { + if (z.string().email().safeParse(altName).success) { + return { type: "email", value: altName }; + } + + if (isFQDN(altName, { allow_wildcard: true })) { + return { type: "dns", value: altName }; + } + + throw new BadRequestError({ message: `Invalid SAN entry: ${altName}` }); + }); + + const altNamesExtension = new x509.SubjectAlternativeNameExtension(altNamesArray, false); + extensions.push(altNamesExtension); + } const serialNumber = createSerialNumber(); const leafCert = await x509.X509CertificateGenerator.create({ @@ -421,7 +449,7 @@ export const pkiSubscriberServiceFactory = ({ notBefore: notBeforeDate, notAfter: notAfterDate, keyUsages: selectedKeyUsages, - extendedKeyUsages: selectedExtendedKeyUsages + extendedKeyUsages: subscriber.extendedKeyUsages as CertExtendedKeyUsage[] }, tx ); @@ -470,6 +498,8 @@ export const pkiSubscriberServiceFactory = ({ projectId }); if (!subscriber) throw new NotFoundError({ message: `PKI subscriber named '${subscriberName}' not found` }); + if (!subscriber.caId) throw new BadRequestError({ message: "Subscriber does not have an assigned issuing CA" }); + const ca = await certificateAuthorityDAL.findById(subscriber.caId); if (!ca) throw new NotFoundError({ message: `CA with ID '${subscriber.caId}' not found` }); @@ -731,13 +761,11 @@ export const pkiSubscriberServiceFactory = ({ projectId }); if (!subscriber) throw new NotFoundError({ message: `PKI subscriber named '${subscriberName}' not found` }); - const ca = await certificateAuthorityDAL.findById(subscriber.caId); - if (!ca) throw new NotFoundError({ message: `CA with ID '${subscriber.caId}' not found` }); const { permission } = await permissionService.getProjectPermission({ actor, actorId, - projectId: ca.projectId, + projectId: subscriber.projectId, actorAuthMethod, actorOrgId, actionProjectType: ActionProjectType.CertificateManager diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index 9cf94daa7..8cfa20697 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -1087,17 +1087,14 @@ export const projectServiceFactory = ({ const subscribers = await pkiSubscriberDAL.find({ projectId }); for (const subscriber of subscribers) { - try { - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionPkiSubscriberActions.Read, - subject(ProjectPermissionSub.PkiSubscribers, { - name: subscriber.name - }) - ); - + const canRead = permission.can( + ProjectPermissionPkiSubscriberActions.Read, + subject(ProjectPermissionSub.PkiSubscribers, { + name: subscriber.name + }) + ); + if (canRead) { allowedSubscribers.push(subscriber); - } catch { - // intentionally ignore subscribers where user lacks access } } @@ -1203,17 +1200,15 @@ export const projectServiceFactory = ({ const hosts = await sshHostDAL.findSshHostsWithLoginMappings(projectId); for (const host of hosts) { - try { - ForbiddenError.from(permission).throwUnlessCan( - ProjectPermissionSshHostActions.Read, - subject(ProjectPermissionSub.SshHosts, { - hostname: host.hostname - }) - ); + const canRead = permission.can( + ProjectPermissionSshHostActions.Read, + subject(ProjectPermissionSub.SshHosts, { + hostname: host.hostname + }) + ); + if (canRead) { allowedHosts.push(host); - } catch { - // intentionally ignore projects where user lacks access } } diff --git a/docs/documentation/platform/pki/subscribers.mdx b/docs/documentation/platform/pki/subscribers.mdx index 64dc229ee..3aebe50e2 100644 --- a/docs/documentation/platform/pki/subscribers.mdx +++ b/docs/documentation/platform/pki/subscribers.mdx @@ -12,10 +12,9 @@ In Infisical PKI, subscribers are logical representations of entities such as de ```mermaid graph TD - A[Root CA] --> B[Intermediate CA] - B --> C1[Certificate] +A[Issuing CA] --> C1[Certificate] C1 --> S1[Subscriber] - B --> C2[Certificate] + A --> C2[Certificate] C2 --> S2[Subscriber] ``` @@ -25,7 +24,7 @@ graph TD The typical workflow for managing subscribers consists of the following steps: -1. Creating a subscriber and defining attributes to be included on the X.509 certificates issued for it including common name, subject alternative names, TLL, etc. +1. Creating a subscriber and defining which (issuing) CA will issue X.509 certificates for it as well as attributes to be included on the certificates including common name, subject alternative names, TLL, etc. 2. Requesting for a certificate against the subscriber with or without a certificate signing request (CSR). 3. Managing certificate lifecycle events such as certificate renewal and revocation. As part of the certificate revocation flow, you can also query for a Certificate Revocation List [CRL](https://en.wikipedia.org/wiki/Certificate_revocation_list), a time-stamped, signed diff --git a/frontend/src/helpers/project.ts b/frontend/src/helpers/project.ts index cc5c6fa91..3e0d0f52e 100644 --- a/frontend/src/helpers/project.ts +++ b/frontend/src/helpers/project.ts @@ -61,6 +61,9 @@ export const initProjectHelper = async ({ projectName }: { projectName: string } return project; }; export const getProjectHomePage = (workspace: Workspace) => { + if (workspace.type === ProjectType.CertificateManager) { + return `/${workspace.type}/$projectId/subscribers` as const; + } return `/${workspace.type}/$projectId/overview` as const; }; diff --git a/frontend/src/hooks/api/certificates/mutations.tsx b/frontend/src/hooks/api/certificates/mutations.tsx index 7e9cf4f91..74d6b5cbb 100644 --- a/frontend/src/hooks/api/certificates/mutations.tsx +++ b/frontend/src/hooks/api/certificates/mutations.tsx @@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; +import { pkiSubscriberKeys } from "../pkiSubscriber/queries"; import { workspaceKeys } from "../workspace"; import { TCertificate, TDeleteCertDTO, TRevokeCertDTO } from "./types"; @@ -42,6 +43,9 @@ export const useRevokeCert = () => { queryClient.invalidateQueries({ queryKey: workspaceKeys.forWorkspaceCertificates(projectSlug) }); + queryClient.invalidateQueries({ + queryKey: pkiSubscriberKeys.allPkiSubscriberCertificates() + }); } }); }; diff --git a/frontend/src/hooks/api/pkiSubscriber/queries.tsx b/frontend/src/hooks/api/pkiSubscriber/queries.tsx index dcaa4de57..d9948ed5c 100644 --- a/frontend/src/hooks/api/pkiSubscriber/queries.tsx +++ b/frontend/src/hooks/api/pkiSubscriber/queries.tsx @@ -77,8 +77,8 @@ export const useGetPkiSubscriberCertificates = ({ queryKey: pkiSubscriberKeys.specificPkiSubscriberCertificates({ subscriberName, projectId, - offset: 0, - limit: 25 + offset, + limit }), queryFn: async () => { const params = new URLSearchParams({ diff --git a/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx b/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx index af90f79de..35534d4a2 100644 --- a/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx +++ b/frontend/src/layouts/ProjectLayout/ProjectLayout.tsx @@ -118,7 +118,9 @@ export const ProjectLayout = () => { )} { const { currentWorkspace } = useWorkspace(); const projectId = currentWorkspace.id; + const { permission } = useProjectPermission(); const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(PER_PAGE_INIT); @@ -74,6 +77,13 @@ export const PkiSubscriberCertificatesTable = ({ subscriberName, handlePopUpOpen return Valid; }; + const canListPkiSubscriberCerts = permission.can( + ProjectPermissionPkiSubscriberActions.ListCerts, + subject(ProjectPermissionSub.PkiSubscribers, { + name: subscriberName + }) + ); + return (
@@ -156,7 +166,7 @@ export const PkiSubscriberCertificatesTable = ({ subscriberName, handlePopUpOpen )} {!isPending && !data?.certificates?.length && ( )} diff --git a/frontend/src/pages/cert-manager/PkiSubscriberDetailsByIDPage/components/PkiSubscriberDetailsSection.tsx b/frontend/src/pages/cert-manager/PkiSubscriberDetailsByIDPage/components/PkiSubscriberDetailsSection.tsx index e8b9c816e..5899f5004 100644 --- a/frontend/src/pages/cert-manager/PkiSubscriberDetailsByIDPage/components/PkiSubscriberDetailsSection.tsx +++ b/frontend/src/pages/cert-manager/PkiSubscriberDetailsByIDPage/components/PkiSubscriberDetailsSection.tsx @@ -14,6 +14,7 @@ import { } from "@app/context"; import { useTimedReset } from "@app/hooks"; import { useGetPkiSubscriber, useIssuePkiSubscriberCert } from "@app/hooks/api"; +import { pkiSubscriberStatusToNameMap } from "@app/hooks/api/pkiSubscriber/constants"; import { UsePopUpState } from "@app/hooks/usePopUp"; import { CertificateContent } from "../../CertificatesPage/components/CertificateContent"; @@ -44,6 +45,7 @@ export const PkiSubscriberDetailsSection = ({ subscriberName, handlePopUpOpen }: subscriberName, projectId }); + const { mutateAsync: issuePkiSubscriberCert, isPending: isIssuingCert } = useIssuePkiSubscriberCert(); @@ -136,6 +138,12 @@ export const PkiSubscriberDetailsSection = ({ subscriberName, handlePopUpOpen }:

Name

{pkiSubscriber.name}

+
+

Status

+

+ {pkiSubscriberStatusToNameMap[pkiSubscriber.status]} +

+

Common Name

{pkiSubscriber.commonName}

diff --git a/frontend/src/routeTree.gen.ts b/frontend/src/routeTree.gen.ts index 76ee2256a..181af1dc4 100644 --- a/frontend/src/routeTree.gen.ts +++ b/frontend/src/routeTree.gen.ts @@ -964,8 +964,8 @@ const certManagerSettingsPageRouteRoute = const certManagerCertificatesPageRouteRoute = certManagerCertificatesPageRouteImport.update({ - id: '/overview', - path: '/overview', + id: '/certificates', + path: '/certificates', getParentRoute: () => certManagerLayoutRoute, } as any) @@ -2265,10 +2265,10 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof certManagerCertificateAuthoritiesPageRouteImport parentRoute: typeof certManagerLayoutImport } - '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/overview': { - id: '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/overview' - path: '/overview' - fullPath: '/cert-manager/$projectId/overview' + '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/certificates': { + id: '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/certificates' + path: '/certificates' + fullPath: '/cert-manager/$projectId/certificates' preLoaderRoute: typeof certManagerCertificatesPageRouteImport parentRoute: typeof certManagerLayoutImport } @@ -4056,7 +4056,7 @@ export interface FileRoutesByFullPath { '/organization/ssh/settings': typeof organizationSshSettingsPageRouteRoute '/cert-manager/$projectId/alerting': typeof certManagerAlertingPageRouteRoute '/cert-manager/$projectId/certificate-authorities': typeof certManagerCertificateAuthoritiesPageRouteRoute - '/cert-manager/$projectId/overview': typeof certManagerCertificatesPageRouteRoute + '/cert-manager/$projectId/certificates': typeof certManagerCertificatesPageRouteRoute '/cert-manager/$projectId/settings': typeof certManagerSettingsPageRouteRoute '/kms/$projectId/kmip': typeof kmsKmipPageRouteRoute '/kms/$projectId/overview': typeof kmsOverviewPageRouteRoute @@ -4242,7 +4242,7 @@ export interface FileRoutesByTo { '/organization/ssh/settings': typeof organizationSshSettingsPageRouteRoute '/cert-manager/$projectId/alerting': typeof certManagerAlertingPageRouteRoute '/cert-manager/$projectId/certificate-authorities': typeof certManagerCertificateAuthoritiesPageRouteRoute - '/cert-manager/$projectId/overview': typeof certManagerCertificatesPageRouteRoute + '/cert-manager/$projectId/certificates': typeof certManagerCertificatesPageRouteRoute '/cert-manager/$projectId/settings': typeof certManagerSettingsPageRouteRoute '/kms/$projectId/kmip': typeof kmsKmipPageRouteRoute '/kms/$projectId/overview': typeof kmsOverviewPageRouteRoute @@ -4444,7 +4444,7 @@ export interface FileRoutesById { '/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout': typeof sshLayoutRouteWithChildren '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/alerting': typeof certManagerAlertingPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/certificate-authorities': typeof certManagerCertificateAuthoritiesPageRouteRoute - '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/overview': typeof certManagerCertificatesPageRouteRoute + '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/certificates': typeof certManagerCertificatesPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/settings': typeof certManagerSettingsPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/kms/$projectId/_kms-layout/kmip': typeof kmsKmipPageRouteRoute '/_authenticate/_inject-org-details/_org-layout/kms/$projectId/_kms-layout/overview': typeof kmsOverviewPageRouteRoute @@ -4640,7 +4640,7 @@ export interface FileRouteTypes { | '/organization/ssh/settings' | '/cert-manager/$projectId/alerting' | '/cert-manager/$projectId/certificate-authorities' - | '/cert-manager/$projectId/overview' + | '/cert-manager/$projectId/certificates' | '/cert-manager/$projectId/settings' | '/kms/$projectId/kmip' | '/kms/$projectId/overview' @@ -4825,7 +4825,7 @@ export interface FileRouteTypes { | '/organization/ssh/settings' | '/cert-manager/$projectId/alerting' | '/cert-manager/$projectId/certificate-authorities' - | '/cert-manager/$projectId/overview' + | '/cert-manager/$projectId/certificates' | '/cert-manager/$projectId/settings' | '/kms/$projectId/kmip' | '/kms/$projectId/overview' @@ -5025,7 +5025,7 @@ export interface FileRouteTypes { | '/_authenticate/_inject-org-details/_org-layout/ssh/$projectId/_ssh-layout' | '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/alerting' | '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/certificate-authorities' - | '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/overview' + | '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/certificates' | '/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/settings' | '/_authenticate/_inject-org-details/_org-layout/kms/$projectId/_kms-layout/kmip' | '/_authenticate/_inject-org-details/_org-layout/kms/$projectId/_kms-layout/overview' @@ -5575,7 +5575,7 @@ export const routeTree = rootRoute "children": [ "/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/alerting", "/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/certificate-authorities", - "/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/overview", + "/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/certificates", "/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/settings", "/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/access-management", "/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/subscribers", @@ -5640,7 +5640,7 @@ export const routeTree = rootRoute "filePath": "cert-manager/CertificateAuthoritiesPage/route.tsx", "parent": "/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout" }, - "/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/overview": { + "/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout/certificates": { "filePath": "cert-manager/CertificatesPage/route.tsx", "parent": "/_authenticate/_inject-org-details/_org-layout/cert-manager/$projectId/_cert-manager-layout" }, diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts index b9eeb6392..ac3ddaa2f 100644 --- a/frontend/src/routes.ts +++ b/frontend/src/routes.ts @@ -292,7 +292,7 @@ const certManagerRoutes = route("/cert-manager/$projectId", [ index("cert-manager/PkiSubscribersPage/route.tsx"), route("/$subscriberName", "cert-manager/PkiSubscriberDetailsByIDPage/route.tsx") ]), - route("/overview", "cert-manager/CertificatesPage/route.tsx"), + route("/certificates", "cert-manager/CertificatesPage/route.tsx"), route("/certificate-authorities", "cert-manager/CertificateAuthoritiesPage/route.tsx"), route("/alerting", "cert-manager/AlertingPage/route.tsx"), route("/ca/$caId", "cert-manager/CertAuthDetailsByIDPage/route.tsx"),