feat: POC for ACME done

This commit is contained in:
Sheen Capadngan
2025-05-16 02:58:05 +08:00
parent c2949964b3
commit 6d10afc9d2
28 changed files with 3519 additions and 273 deletions

1877
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -131,6 +131,7 @@
"@aws-sdk/client-elasticache": "^3.637.0",
"@aws-sdk/client-iam": "^3.525.0",
"@aws-sdk/client-kms": "^3.609.0",
"@aws-sdk/client-route-53": "^3.810.0",
"@aws-sdk/client-secrets-manager": "^3.504.0",
"@aws-sdk/client-sts": "^3.600.0",
"@casl/ability": "^6.5.0",
@@ -174,6 +175,7 @@
"@slack/oauth": "^3.0.2",
"@slack/web-api": "^7.8.0",
"@ucast/mongo2js": "^1.3.4",
"acme-client": "^5.4.0",
"ajv": "^8.12.0",
"argon2": "^0.31.2",
"aws-sdk": "^2.1553.0",

View File

@@ -971,20 +971,6 @@ export const registerRoutes = async (
projectDAL
});
const pkiSubscriberService = pkiSubscriberServiceFactory({
pkiSubscriberDAL,
certificateAuthorityDAL,
certificateAuthorityCertDAL,
certificateAuthoritySecretDAL,
certificateAuthorityCrlDAL,
certificateDAL,
certificateBodyDAL,
certificateSecretDAL,
projectDAL,
kmsService,
permissionService
});
const projectTemplateService = projectTemplateServiceFactory({
licenseService,
permissionService,
@@ -1690,6 +1676,23 @@ export const registerRoutes = async (
internalCertificateAuthorityService
});
const pkiSubscriberService = pkiSubscriberServiceFactory({
pkiSubscriberDAL,
certificateAuthorityDAL,
certificateAuthorityCertDAL,
certificateAuthoritySecretDAL,
certificateAuthorityCrlDAL,
certificateDAL,
certificateBodyDAL,
certificateSecretDAL,
projectDAL,
kmsService,
permissionService,
appConnectionDAL,
appConnectionService,
externalCertificateAuthorityDAL
});
await secretRotationV2QueueServiceFactory({
secretRotationV2Service,
secretRotationV2DAL,

View File

@@ -278,6 +278,76 @@ export const registerPkiSubscriberRouter = async (server: FastifyZodProvider) =>
}
});
server.route({
method: "POST",
url: "/:subscriberName/order-certificate",
config: {
rateLimit: writeLimit
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
schema: {
hide: false,
tags: [ApiDocsTags.PkiSubscribers],
description: "Issue certificate",
params: z.object({
subscriberName: z.string().describe(PKI_SUBSCRIBERS.ISSUE_CERT.subscriberName)
}),
body: z.object({
projectId: z.string().trim().describe(PKI_SUBSCRIBERS.ISSUE_CERT.projectId)
}),
response: {
200: z.object({
certificate: z.string().trim().describe(PKI_SUBSCRIBERS.ISSUE_CERT.certificate),
issuingCaCertificate: z.string().trim().describe(PKI_SUBSCRIBERS.ISSUE_CERT.issuingCaCertificate),
certificateChain: z.string().trim().describe(PKI_SUBSCRIBERS.ISSUE_CERT.certificateChain),
privateKey: z.string().trim().describe(PKI_SUBSCRIBERS.ISSUE_CERT.privateKey),
serialNumber: z.string().trim().describe(PKI_SUBSCRIBERS.ISSUE_CERT.serialNumber)
})
}
},
handler: async (req) => {
await server.services.pkiSubscriber.issueSubscriberCert({
subscriberName: req.params.subscriberName,
projectId: req.body.projectId,
actor: req.permission.type,
actorId: req.permission.id,
actorAuthMethod: req.permission.authMethod,
actorOrgId: req.permission.orgId
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: subscriber.projectId,
event: {
type: EventType.ISSUE_PKI_SUBSCRIBER_CERT,
metadata: {
subscriberId: subscriber.id,
name: subscriber.name,
serialNumber
}
}
});
await server.services.telemetry.sendPostHogEvents({
event: PostHogEventTypes.IssueCert,
distinctId: getTelemetryDistinctId(req),
properties: {
subscriberId: subscriber.id,
commonName: subscriber.commonName,
...req.auditLogInfo
}
});
return {
certificate,
certificateChain,
issuingCaCertificate,
privateKey,
serialNumber
};
}
});
server.route({
method: "POST",
url: "/:subscriberName/issue-certificate",

View File

@@ -0,0 +1,73 @@
/* eslint-disable @typescript-eslint/no-floating-promises */
import { z } from "zod";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { ApiDocsTags } from "@app/lib/api-docs";
import { readLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
import { AcmeCertificateAuthoritySchema } from "@app/services/certificate-authority/acme/acme-certificate-authority-schemas";
import { CaType } from "@app/services/certificate-authority/certificate-authority-enums";
import { InternalCertificateAuthoritySchema } from "@app/services/certificate-authority/internal/internal-certificate-authority-schemas";
const CertificateAuthoritySchema = z.discriminatedUnion("type", [
InternalCertificateAuthoritySchema,
AcmeCertificateAuthoritySchema
]);
export const registerCaRouter = async (server: FastifyZodProvider) => {
server.route({
method: "GET",
url: "/",
config: {
rateLimit: readLimit
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
schema: {
hide: false,
tags: [ApiDocsTags.PkiCertificateAuthorities],
description: "Get Certificate Authorities",
querystring: z.object({
projectId: z.string()
}),
response: {
200: z.object({
certificateAuthorities: CertificateAuthoritySchema.array()
})
}
},
handler: async (req) => {
const internalCas = await server.services.certificateAuthority.listCertificateAuthoritiesByProjectId(
{
projectId: req.query.projectId,
type: CaType.INTERNAL
},
req.permission
);
const acmeCas = await server.services.certificateAuthority.listCertificateAuthoritiesByProjectId(
{
projectId: req.query.projectId,
type: CaType.ACME
},
req.permission
);
// await server.services.auditLog.createAuditLog({
// ...req.auditLogInfo,
// projectId: ca.projectId,
// event: {
// type: EventType.GET_CA,
// metadata: {
// caId: ca.id,
// dn: ca.dn
// }
// }
// });
return {
certificateAuthorities: [...(internalCas ?? []), ...(acmeCas ?? [])]
};
}
});
};

View File

@@ -1,3 +1,4 @@
import { registerCaRouter } from "./certificate-authority-router";
import { registerGroupProjectRouter } from "./group-project-router";
import { registerIdentityOrgRouter } from "./identity-org-router";
import { registerIdentityProjectRouter } from "./identity-project-router";
@@ -14,6 +15,7 @@ export const registerV2Routes = async (server: FastifyZodProvider) => {
await server.register(registerUserRouter, { prefix: "/users" });
await server.register(registerServiceTokenRouter, { prefix: "/service-token" });
await server.register(registerPasswordRouter, { prefix: "/password" });
await server.register(registerCaRouter, { prefix: "/pki/ca" });
await server.register(
async (orgRouter) => {
await orgRouter.register(registerOrgRouter);

View File

@@ -1,15 +1,37 @@
import { ChangeResourceRecordSetsCommand, Route53Client } from "@aws-sdk/client-route-53";
import * as x509 from "@peculiar/x509";
import acme from "acme-client";
import { KeyObject } from "crypto";
import { TableName, TPkiSubscribers } from "@app/db/schemas";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { OrgServiceActor } from "@app/lib/types";
import { TAppConnectionDALFactory } from "@app/services/app-connection/app-connection-dal";
import { AppConnection } from "@app/services/app-connection/app-connection-enums";
import { AppConnection, AWSRegion } from "@app/services/app-connection/app-connection-enums";
import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service";
import { getAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-fns";
import { TAwsConnection, TAwsConnectionConfig } from "@app/services/app-connection/aws/aws-connection-types";
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";
import {
CertExtendedKeyUsage,
CertKeyAlgorithm,
CertKeyUsage,
CertStatus
} from "@app/services/certificate/certificate-types";
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 { TCertificateAuthorityDALFactory } from "../certificate-authority-dal";
import { CaStatus, CaType } from "../certificate-authority-enums";
import { keyAlgorithmToAlgCfg } from "../certificate-authority-fns";
import { TCertificateAuthority } from "../certificate-authority-types";
import { TExternalCertificateAuthorityDALFactory } from "../external-certificate-authority-dal";
import { AcmeDnsProvider } from "./acme-certificate-authority-enums";
import {
TAcmeCertificateAuthority,
TCreateAcmeCertificateAuthorityDTO,
TUpdateAcmeCertificateAuthorityDTO
} from "./acme-certificate-authority-types";
@@ -19,16 +41,113 @@ type TAcmeCertificateAuthorityFnsDeps = {
appConnectionService: Pick<TAppConnectionServiceFactory, "connectAppConnectionById">;
certificateAuthorityDAL: Pick<
TCertificateAuthorityDALFactory,
"create" | "transaction" | "findByIdWithAssociatedCa" | "updateById"
"create" | "transaction" | "findByIdWithAssociatedCa" | "updateById" | "findWithAssociatedCa"
>;
externalCertificateAuthorityDAL: Pick<TExternalCertificateAuthorityDALFactory, "create" | "update">;
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction">;
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "create">;
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "create">;
kmsService: Pick<TKmsServiceFactory, "encryptWithKmsKey" | "generateKmsKey">;
projectDAL: Pick<TProjectDALFactory, "findById" | "findOne" | "updateById" | "transaction">;
};
type DBConfigurationColumn = {
dnsProvider: string;
directoryUrl: string;
accountEmail: string;
};
export const castDbEntryToAcmeCertificateAuthority = (
ca: Awaited<ReturnType<TCertificateAuthorityDALFactory["findByIdWithAssociatedCa"]>>
): TAcmeCertificateAuthority => {
if (!ca.externalCa) {
throw new BadRequestError({ message: "Malformed ACME certificate authority" });
}
const dbConfigurationCol = ca.externalCa.configuration as DBConfigurationColumn;
return {
id: ca.id,
type: CaType.ACME,
disableDirectIssuance: ca.disableDirectIssuance,
name: ca.externalCa.name,
projectId: ca.projectId,
configuration: {
dnsAppConnectionId: ca.externalCa.dnsAppConnectionId as string,
dnsProvider: dbConfigurationCol.dnsProvider as AcmeDnsProvider,
directoryUrl: dbConfigurationCol.directoryUrl,
accountEmail: dbConfigurationCol.accountEmail
},
status: ca.externalCa.status as CaStatus
};
};
export const route53InsertTxtRecord = async (connection: TAwsConnectionConfig, domain: string, value: string) => {
const config = await getAwsConnectionConfig(connection, AWSRegion.US_WEST_1); // REGION is irrelevant because Route53 is global
const route53Client = new Route53Client({
credentials: config.credentials!,
region: config.region
});
const command = new ChangeResourceRecordSetsCommand({
HostedZoneId: "Z040441124N1GOOMCQYX1", // SHEEN TODO: Get this from user input
ChangeBatch: {
Comment: "Set ACME challenge TXT record",
Changes: [
{
Action: "UPSERT",
ResourceRecordSet: {
Name: domain,
Type: "TXT",
TTL: 30,
ResourceRecords: [{ Value: value }]
}
}
]
}
});
await route53Client.send(command);
};
export const route53DeleteTxtRecord = async (connection: TAwsConnectionConfig, domain: string, value: string) => {
const config = await getAwsConnectionConfig(connection, AWSRegion.US_WEST_1); // REGION is irrelevant because Route53 is global
const route53Client = new Route53Client({
credentials: config.credentials!,
region: config.region
});
const command = new ChangeResourceRecordSetsCommand({
HostedZoneId: "Z040441124N1GOOMCQYX1", // SHEEN TODO: same here
ChangeBatch: {
Comment: "Delete ACME challenge TXT record",
Changes: [
{
Action: "DELETE",
ResourceRecordSet: {
Name: domain,
Type: "TXT",
TTL: 30,
ResourceRecords: [{ Value: value }]
}
}
]
}
});
await route53Client.send(command);
};
export const AcmeCertificateAuthorityFns = ({
appConnectionDAL,
appConnectionService,
certificateAuthorityDAL,
externalCertificateAuthorityDAL
externalCertificateAuthorityDAL,
certificateDAL,
certificateBodyDAL,
certificateSecretDAL,
kmsService,
projectDAL
}: TAcmeCertificateAuthorityFnsDeps) => {
const createCertificateAuthority = async ({
name,
@@ -192,8 +311,150 @@ export const AcmeCertificateAuthorityFns = ({
};
};
const listCertificateAuthorities = async ({ projectId }: { projectId: string }) => {
const cas = await certificateAuthorityDAL.findWithAssociatedCa({
[`${TableName.CertificateAuthority}.projectId` as "projectId"]: projectId,
[`${TableName.ExternalCertificateAuthority}.type` as "type"]: CaType.ACME
});
return cas.map(castDbEntryToAcmeCertificateAuthority);
};
// SHEEN TODO: need to execute this from a job
const orderCertificate = async (
subscriber: TPkiSubscribers,
ca: Awaited<ReturnType<TCertificateAuthorityDALFactory["findByIdWithAssociatedCa"]>>,
actor: OrgServiceActor
) => {
const acmeCa = castDbEntryToAcmeCertificateAuthority(ca);
// SHEEN TODO: need to save this in credentials field and reuse
const privateRsaKey = await acme.crypto.createPrivateRsaKey();
const acmeClient = new acme.Client({
directoryUrl: acmeCa.configuration.directoryUrl,
accountKey: privateRsaKey
});
const alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048);
const leafKeys = await crypto.subtle.generateKey(alg, true, ["sign", "verify"]);
const skLeafObj = KeyObject.from(leafKeys.privateKey);
const skLeaf = skLeafObj.export({ format: "pem", type: "pkcs8" }) as string;
const [, certificateCsr] = await acme.crypto.createCsr(
{
altNames: subscriber.subjectAlternativeNames,
commonName: subscriber.commonName
},
skLeaf
);
// SHEEN TODO: need to update this to remove dependence on ACTOR
const appConnection = await appConnectionDAL.findById(acmeCa.configuration.dnsAppConnectionId);
const connection = await appConnectionService.connectAppConnectionById(
appConnection.app as AppConnection,
acmeCa.configuration.dnsAppConnectionId,
actor
);
const pem = await acmeClient.auto({
csr: certificateCsr,
email: acmeCa.configuration.accountEmail,
challengePriority: ["dns-01"],
termsOfServiceAgreed: true,
challengeCreateFn: async (authz, challenge, keyAuthorization) => {
if (challenge.type !== "dns-01") {
throw new Error("Unsupported challenge type");
}
const recordName = `_acme-challenge.${authz.identifier.value}`; // e.g., "_acme-challenge.example.com"
const recordValue = `"${keyAuthorization}"`; // must be double quoted
if (acmeCa.configuration.dnsProvider === AcmeDnsProvider.Route53) {
await route53InsertTxtRecord(connection as TAwsConnection, recordName, recordValue);
}
},
challengeRemoveFn: async (authz, challenge, keyAuthorization) => {
const recordName = `_acme-challenge.${authz.identifier.value}`; // e.g., "_acme-challenge.example.com"
const recordValue = `"${keyAuthorization}"`; // must be double quoted
if (acmeCa.configuration.dnsProvider === AcmeDnsProvider.Route53) {
await route53DeleteTxtRecord(connection as TAwsConnection, recordName, recordValue);
}
}
});
console.log("PEM IS", pem);
const [leafCert, parentCert] = acme.crypto.splitPemChain(pem);
const certObj = new x509.X509Certificate(leafCert);
const certificateManagerKmsId = await getProjectKmsCertificateKeyId({
projectId: ca.projectId,
projectDAL,
kmsService
});
const kmsEncryptor = await kmsService.encryptWithKmsKey({
kmsId: certificateManagerKmsId
});
const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({
plainText: Buffer.from(new Uint8Array(certObj.rawData))
});
const certificateChainPem = parentCert.trim();
const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({
plainText: Buffer.from(certificateChainPem)
});
const { cipherTextBlob: encryptedPrivateKey } = await kmsEncryptor({
plainText: Buffer.from(skLeaf)
});
await certificateDAL.transaction(async (tx) => {
const cert = await certificateDAL.create(
{
caId: ca.id,
pkiSubscriberId: subscriber.id,
status: CertStatus.ACTIVE,
friendlyName: subscriber.commonName,
commonName: subscriber.commonName,
altNames: subscriber.subjectAlternativeNames.join(","),
serialNumber: certObj.serialNumber,
notBefore: certObj.notBefore,
notAfter: certObj.notAfter,
keyUsages: subscriber.keyUsages as CertKeyUsage[],
extendedKeyUsages: subscriber.extendedKeyUsages as CertExtendedKeyUsage[],
caCertId: "s" // SHEEN TODO: merge Andrey's PR and then remove this
},
tx
);
await certificateBodyDAL.create(
{
certId: cert.id,
encryptedCertificate,
encryptedCertificateChain
},
tx
);
await certificateSecretDAL.create(
{
certId: cert.id,
encryptedPrivateKey
},
tx
);
});
};
return {
createCertificateAuthority,
updateCertificateAuthority
updateCertificateAuthority,
listCertificateAuthorities,
orderCertificate
};
};

View File

@@ -3,7 +3,7 @@ import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { CertificateAuthoritiesSchema, TableName, TCertificateAuthorities } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify, selectAllTableCols, TFindOpt } from "@app/lib/knex";
import { buildFindFilter, ormify, selectAllTableCols, TFindOpt } from "@app/lib/knex";
export type TCertificateAuthorityDALFactory = ReturnType<typeof certificateAuthorityDALFactory>;
@@ -142,7 +142,7 @@ export const certificateAuthorityDALFactory = (db: TDbClient) => {
};
const findWithAssociatedCa = async (
filter: Parameters<(typeof caOrm)["find"]>[0] & { dn?: string },
filter: Parameters<(typeof caOrm)["find"]>[0] & { dn?: string; type?: string },
{ offset, limit, sort = [["createdAt", "desc"]] }: TFindOpt<TCertificateAuthorities> = {},
tx?: Knex
) => {
@@ -158,7 +158,8 @@ export const certificateAuthorityDALFactory = (db: TDbClient) => {
`${TableName.CertificateAuthority}.id`,
`${TableName.ExternalCertificateAuthority}.certificateAuthorityId`
)
.where(filter)
// eslint-disable-next-line @typescript-eslint/no-misused-promises
.where(buildFindFilter(filter))
.select(selectAllTableCols(TableName.CertificateAuthority))
.select(
db.ref("id").withSchema(TableName.InternalCertificateAuthority).as("internalCaId"),

View File

@@ -218,17 +218,12 @@ export const certificateAuthorityServiceFactory = ({
ProjectPermissionSub.CertificateAuthorities
);
const cas = await certificateAuthorityDAL.findWithAssociatedCa({
[`${TableName.CertificateAuthority}.projectId` as "projectId"]: finalProjectId,
...(type === CaType.INTERNAL && {
$notNull: [`${TableName.InternalCertificateAuthority}.id` as "id"]
}),
...(type !== CaType.INTERNAL && {
[`${TableName.ExternalCertificateAuthority}.type` as "type"]: type
})
});
if (type === CaType.INTERNAL) {
const cas = await certificateAuthorityDAL.findWithAssociatedCa({
[`${TableName.CertificateAuthority}.projectId` as "projectId"]: finalProjectId,
$notNull: [`${TableName.InternalCertificateAuthority}.id` as "id"]
});
return cas
.filter((ca): ca is typeof ca & { internalCa: NonNullable<typeof ca.internalCa> } => Boolean(ca.internalCa))
.map((ca) => ({
@@ -242,17 +237,9 @@ export const certificateAuthorityServiceFactory = ({
})) as TCertificateAuthority[];
}
return cas
.filter((ca): ca is typeof ca & { externalCa: NonNullable<typeof ca.externalCa> } => Boolean(ca.externalCa))
.map((ca) => ({
id: ca.id,
type,
disableDirectIssuance: ca.disableDirectIssuance,
name: ca.externalCa.name,
projectId: ca.projectId,
configuration: ca.externalCa.configuration,
status: ca.externalCa.status
})) as TCertificateAuthority[];
if (type === CaType.ACME) {
return acmeFns.listCertificateAuthorities({ projectId: finalProjectId });
}
};
const updateCertificateAuthority = async (

View File

@@ -0,0 +1,265 @@
import * as x509 from "@peculiar/x509";
import { KeyObject } from "crypto";
import { z } from "zod";
import { TPkiSubscribers } from "@app/db/schemas";
import { TCertificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal";
import { getConfig } from "@app/lib/config/env";
import { BadRequestError } 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";
import {
CertExtendedKeyUsage,
CertKeyAlgorithm,
CertKeyUsage,
CertStatus
} from "@app/services/certificate/certificate-types";
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 { TCertificateAuthorityCertDALFactory } from "../certificate-authority-cert-dal";
import { TCertificateAuthorityDALFactory } from "../certificate-authority-dal";
import { CaStatus } from "../certificate-authority-enums";
import {
createSerialNumber,
getCaCertChain,
getCaCredentials,
keyAlgorithmToAlgCfg
} from "../certificate-authority-fns";
import { TCertificateAuthoritySecretDALFactory } from "../certificate-authority-secret-dal";
type TInternalCertificateAuthorityFnsDeps = {
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa" | "findById">;
certificateAuthorityCertDAL: Pick<TCertificateAuthorityCertDALFactory, "findById">;
certificateAuthoritySecretDAL: Pick<TCertificateAuthoritySecretDALFactory, "findOne">;
certificateAuthorityCrlDAL: Pick<TCertificateAuthorityCrlDALFactory, "findOne">;
projectDAL: Pick<TProjectDALFactory, "findById" | "transaction" | "findOne" | "updateById">;
kmsService: Pick<TKmsServiceFactory, "decryptWithKmsKey" | "encryptWithKmsKey" | "generateKmsKey">;
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction">;
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "create">;
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "create">;
};
export const InternalCertificateAuthorityFns = ({
certificateAuthorityDAL,
certificateAuthorityCertDAL,
projectDAL,
kmsService,
certificateAuthoritySecretDAL,
certificateAuthorityCrlDAL,
certificateDAL,
certificateBodyDAL,
certificateSecretDAL
}: TInternalCertificateAuthorityFnsDeps) => {
const issueCertificate = async (
subscriber: TPkiSubscribers,
ca: Awaited<ReturnType<TCertificateAuthorityDALFactory["findByIdWithAssociatedCa"]>>
) => {
if (ca.internalCa?.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.disableDirectIssuance) {
throw new BadRequestError({ message: "Certificate template is required for issuance" });
}
const caCert = await certificateAuthorityCertDAL.findById(ca.internalCa.activeCaCertId);
const certificateManagerKmsId = await getProjectKmsCertificateKeyId({
projectId: ca.projectId,
projectDAL,
kmsService
});
const kmsDecryptor = await kmsService.decryptWithKmsKey({
kmsId: certificateManagerKmsId
});
const decryptedCaCert = await kmsDecryptor({
cipherTextBlob: caCert.encryptedCertificate
});
const caCertObj = new x509.X509Certificate(decryptedCaCert);
const notBeforeDate = new Date();
const notAfterDate = new Date(new Date().getTime() + ms(subscriber.ttl));
const caCertNotBeforeDate = new Date(caCertObj.notBefore);
const caCertNotAfterDate = new Date(caCertObj.notAfter);
// check not before constraint
if (notBeforeDate < caCertNotBeforeDate) {
throw new BadRequestError({ message: "notBefore date is before CA certificate's notBefore date" });
}
// check not after constraint
if (notAfterDate > caCertNotAfterDate) {
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.subtle.generateKey(alg, true, ["sign", "verify"]);
const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({
name: `CN=${subscriber.commonName}`,
keys: leafKeys,
signingAlgorithm: alg,
extensions: [
// eslint-disable-next-line no-bitwise
new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment)
],
attributes: [new x509.ChallengePasswordAttribute("password")]
});
const { caPrivateKey, caSecret } = await getCaCredentials({
caId: ca.id,
certificateAuthorityDAL,
certificateAuthoritySecretDAL,
projectDAL,
kmsService
});
const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id });
const appCfg = getConfig();
const distributionPointUrl = `${appCfg.SITE_URL}/api/v1/pki/crl/${caCrl.id}/der`;
const caIssuerUrl = `${appCfg.SITE_URL}/api/v1/pki/ca/${ca.id}/certificates/${caCert.id}/der`;
const extensions: x509.Extension[] = [
new x509.BasicConstraintsExtension(false),
new x509.CRLDistributionPointsExtension([distributionPointUrl]),
await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false),
await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey),
new x509.AuthorityInfoAccessExtension({
caIssuers: new x509.GeneralName("url", caIssuerUrl)
}),
new x509.CertificatePolicyExtension(["2.5.29.32.0"]) // anyPolicy
];
const selectedKeyUsages = subscriber.keyUsages as CertKeyUsage[];
// eslint-disable-next-line no-bitwise
const keyUsagesBitValue = selectedKeyUsages.reduce((accum, keyUsage) => accum | x509.KeyUsageFlags[keyUsage], 0);
if (keyUsagesBitValue) {
extensions.push(new x509.KeyUsagesExtension(keyUsagesBitValue, true));
}
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({
serialNumber,
subject: csrObj.subject,
issuer: caCertObj.subject,
notBefore: notBeforeDate,
notAfter: notAfterDate,
signingKey: caPrivateKey,
publicKey: csrObj.publicKey,
signingAlgorithm: alg,
extensions
});
const skLeafObj = KeyObject.from(leafKeys.privateKey);
const skLeaf = skLeafObj.export({ format: "pem", type: "pkcs8" }) as string;
const kmsEncryptor = await kmsService.encryptWithKmsKey({
kmsId: certificateManagerKmsId
});
const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({
plainText: Buffer.from(new Uint8Array(leafCert.rawData))
});
const { cipherTextBlob: encryptedPrivateKey } = await kmsEncryptor({
plainText: Buffer.from(skLeaf)
});
const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({
caCertId: caCert.id,
certificateAuthorityDAL,
certificateAuthorityCertDAL,
projectDAL,
kmsService
});
const certificateChainPem = `${issuingCaCertificate}\n${caCertChain}`.trim();
const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({
plainText: Buffer.from(certificateChainPem)
});
await certificateDAL.transaction(async (tx) => {
const cert = await certificateDAL.create(
{
caId: ca.id,
caCertId: caCert.id,
pkiSubscriberId: subscriber.id,
status: CertStatus.ACTIVE,
friendlyName: subscriber.commonName,
commonName: subscriber.commonName,
altNames: subscriber.subjectAlternativeNames.join(","),
serialNumber,
notBefore: notBeforeDate,
notAfter: notAfterDate,
keyUsages: selectedKeyUsages,
extendedKeyUsages: subscriber.extendedKeyUsages as CertExtendedKeyUsage[]
},
tx
);
await certificateBodyDAL.create(
{
certId: cert.id,
encryptedCertificate,
encryptedCertificateChain
},
tx
);
await certificateSecretDAL.create(
{
certId: cert.id,
encryptedPrivateKey
},
tx
);
});
return {
certificate: leafCert.toString("pem"),
certificateChain: certificateChainPem,
issuingCaCertificate,
privateKey: skLeaf,
serialNumber,
ca,
subscriber
};
};
return {
issueCertificate
};
};

View File

@@ -10,13 +10,34 @@ import {
} from "../certificate-authority-schemas";
import { validateCaDateField } from "../certificate-authority-validators";
const InternalCertificateAuthorityConfigurationSchema = z
const InternalCertificateAuthorityConfigurationSchema = z.object({
type: z.nativeEnum(InternalCaType),
friendlyName: z.string().optional(),
commonName: z.string().trim(),
organization: z.string().trim(),
ou: z.string().trim(),
dn: z.string().trim(),
parentCaId: z.string().uuid().nullable(),
serialNumber: z.string().trim(),
activeCaCertId: z.string().uuid().nullable(),
country: z.string().trim(),
province: z.string().trim(),
locality: z.string().trim(),
notBefore: z.date().optional(),
notAfter: z.date().optional(),
maxPathLength: z.number().min(-1),
keyAlgorithm: z.nativeEnum(CertKeyAlgorithm)
});
const CreateInternalCertificateAuthorityConfigurationSchema = z
.object({
type: z.nativeEnum(InternalCaType),
friendlyName: z.string().optional(),
commonName: z.string().trim(),
organization: z.string().trim(),
ou: z.string().trim(),
dn: z.string().trim(),
parentCaId: z.string().uuid().optional(),
country: z.string().trim(),
province: z.string().trim(),
locality: z.string().trim(),
@@ -48,11 +69,11 @@ export const InternalCertificateAuthoritySchema = BaseCertificateAuthoritySchema
export const CreateInternalCertificateAuthoritySchema = GenericCreateCertificateAuthorityFieldsSchema(
CaType.INTERNAL
).extend({
configuration: InternalCertificateAuthorityConfigurationSchema
configuration: CreateInternalCertificateAuthorityConfigurationSchema
});
export const UpdateInternalCertificateAuthoritySchema = GenericUpdateCertificateAuthorityFieldsSchema(
CaType.INTERNAL
).extend({
configuration: InternalCertificateAuthorityConfigurationSchema.optional()
configuration: CreateInternalCertificateAuthorityConfigurationSchema.optional()
});

View File

@@ -1,8 +1,6 @@
/* eslint-disable no-bitwise */
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";
@@ -14,10 +12,8 @@ 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";
import {
CertExtendedKeyUsage,
CertExtendedKeyUsageOIDToName,
@@ -27,7 +23,7 @@ import {
} 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 { CaStatus } from "@app/services/certificate-authority/certificate-authority-enums";
import { CaStatus, CaType } from "@app/services/certificate-authority/certificate-authority-enums";
import {
createSerialNumber,
expandInternalCa,
@@ -42,6 +38,12 @@ import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subsc
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns";
import { TAppConnectionDALFactory } from "../app-connection/app-connection-dal";
import { TAppConnectionServiceFactory } from "../app-connection/app-connection-service";
import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal";
import { AcmeCertificateAuthorityFns } from "../certificate-authority/acme/acme-certificate-authority-fns";
import { TExternalCertificateAuthorityDALFactory } from "../certificate-authority/external-certificate-authority-dal";
import { InternalCertificateAuthorityFns } from "../certificate-authority/internal/internal-certificate-authority-fns";
import {
PkiSubscriberStatus,
TCreatePkiSubscriberDTO,
@@ -49,22 +51,29 @@ import {
TGetPkiSubscriberDTO,
TIssuePkiSubscriberCertDTO,
TListPkiSubscriberCertsDTO,
TOrderPkiSubscriberCertDTO,
TSignPkiSubscriberCertDTO,
TUpdatePkiSubscriberDTO
} from "./pki-subscriber-types";
type TPkiSubscriberServiceFactoryDep = {
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById">;
appConnectionService: Pick<TAppConnectionServiceFactory, "connectAppConnectionById">;
externalCertificateAuthorityDAL: Pick<TExternalCertificateAuthorityDALFactory, "create" | "update">;
pkiSubscriberDAL: Pick<
TPkiSubscriberDALFactory,
"create" | "findById" | "updateById" | "deleteById" | "transaction" | "find" | "findOne"
>;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa" | "findById">;
certificateAuthorityDAL: Pick<
TCertificateAuthorityDALFactory,
"findByIdWithAssociatedCa" | "findById" | "transaction" | "create" | "updateById" | "findWithAssociatedCa"
>;
certificateAuthorityCertDAL: Pick<TCertificateAuthorityCertDALFactory, "findById">;
certificateAuthoritySecretDAL: Pick<TCertificateAuthoritySecretDALFactory, "findOne">;
certificateAuthorityCrlDAL: Pick<TCertificateAuthorityCrlDALFactory, "findOne">;
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction" | "countCertificatesForPkiSubscriber" | "find">;
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "create">;
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "create">;
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "create">;
projectDAL: Pick<TProjectDALFactory, "findOne" | "updateById" | "transaction" | "findById" | "find">;
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "decryptWithKmsKey" | "encryptWithKmsKey">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
@@ -79,12 +88,39 @@ export const pkiSubscriberServiceFactory = ({
certificateAuthoritySecretDAL,
certificateAuthorityCrlDAL,
certificateDAL,
certificateBodyDAL,
certificateSecretDAL,
certificateBodyDAL,
projectDAL,
kmsService,
permissionService
permissionService,
appConnectionDAL,
appConnectionService,
externalCertificateAuthorityDAL
}: TPkiSubscriberServiceFactoryDep) => {
const internalCaFns = InternalCertificateAuthorityFns({
certificateAuthorityDAL,
certificateAuthorityCertDAL,
certificateAuthoritySecretDAL,
certificateAuthorityCrlDAL,
certificateDAL,
certificateBodyDAL,
certificateSecretDAL,
projectDAL,
kmsService
});
const acmeCaFns = AcmeCertificateAuthorityFns({
appConnectionDAL,
appConnectionService,
certificateAuthorityDAL,
externalCertificateAuthorityDAL,
certificateDAL,
certificateBodyDAL,
certificateSecretDAL,
kmsService,
projectDAL
});
const createSubscriber = async ({
name,
commonName,
@@ -252,28 +288,26 @@ export const pkiSubscriberServiceFactory = ({
return subscriber;
};
const issueSubscriberCert = async ({
const orderSubscriberCert = async ({
subscriberName,
projectId,
actorId,
actorAuthMethod,
actor,
actorOrgId
}: TIssuePkiSubscriberCertDTO) => {
}: TOrderPkiSubscriberCertDTO) => {
const subscriber = await pkiSubscriberDAL.findOne({
name: subscriberName,
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.findByIdWithAssociatedCa(subscriber.caId);
if (!ca?.internalCa) 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
@@ -288,201 +322,74 @@ export const pkiSubscriberServiceFactory = ({
if (subscriber.status !== PkiSubscriberStatus.ACTIVE)
throw new BadRequestError({ message: "Subscriber is not active" });
if (ca.internalCa?.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.disableDirectIssuance) {
throw new BadRequestError({ message: "Certificate template is required for issuance" });
}
const caCert = await certificateAuthorityCertDAL.findById(ca.internalCa.activeCaCertId);
const certificateManagerKmsId = await getProjectKmsCertificateKeyId({
projectId: ca.projectId,
projectDAL,
kmsService
});
const kmsDecryptor = await kmsService.decryptWithKmsKey({
kmsId: certificateManagerKmsId
});
const decryptedCaCert = await kmsDecryptor({
cipherTextBlob: caCert.encryptedCertificate
});
const caCertObj = new x509.X509Certificate(decryptedCaCert);
const notBeforeDate = new Date();
const notAfterDate = new Date(new Date().getTime() + ms(subscriber.ttl));
const caCertNotBeforeDate = new Date(caCertObj.notBefore);
const caCertNotAfterDate = new Date(caCertObj.notAfter);
// check not before constraint
if (notBeforeDate < caCertNotBeforeDate) {
throw new BadRequestError({ message: "notBefore date is before CA certificate's notBefore date" });
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(subscriber.caId);
if (ca.internalCa?.id) {
throw new BadRequestError({ message: "CA does not support ordering certificates" });
}
// check not after constraint
if (notAfterDate > caCertNotAfterDate) {
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.subtle.generateKey(alg, true, ["sign", "verify"]);
const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({
name: `CN=${subscriber.commonName}`,
keys: leafKeys,
signingAlgorithm: alg,
extensions: [
// eslint-disable-next-line no-bitwise
new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature | x509.KeyUsageFlags.keyEncipherment)
],
attributes: [new x509.ChallengePasswordAttribute("password")]
});
const { caPrivateKey, caSecret } = await getCaCredentials({
caId: ca.id,
certificateAuthorityDAL,
certificateAuthoritySecretDAL,
projectDAL,
kmsService
});
const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id });
const appCfg = getConfig();
const distributionPointUrl = `${appCfg.SITE_URL}/api/v1/pki/crl/${caCrl.id}/der`;
const caIssuerUrl = `${appCfg.SITE_URL}/api/v1/pki/ca/${ca.id}/certificates/${caCert.id}/der`;
const extensions: x509.Extension[] = [
new x509.BasicConstraintsExtension(false),
new x509.CRLDistributionPointsExtension([distributionPointUrl]),
await x509.AuthorityKeyIdentifierExtension.create(caCertObj, false),
await x509.SubjectKeyIdentifierExtension.create(csrObj.publicKey),
new x509.AuthorityInfoAccessExtension({
caIssuers: new x509.GeneralName("url", caIssuerUrl)
}),
new x509.CertificatePolicyExtension(["2.5.29.32.0"]) // anyPolicy
];
const selectedKeyUsages = subscriber.keyUsages as CertKeyUsage[];
const keyUsagesBitValue = selectedKeyUsages.reduce((accum, keyUsage) => accum | x509.KeyUsageFlags[keyUsage], 0);
if (keyUsagesBitValue) {
extensions.push(new x509.KeyUsagesExtension(keyUsagesBitValue, true));
}
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}` });
if (ca.externalCa?.id && ca.externalCa.type === CaType.ACME) {
return acmeCaFns.orderCertificate(subscriber, ca, {
type: actor,
id: actorId,
authMethod: actorAuthMethod,
orgId: actorOrgId
});
const altNamesExtension = new x509.SubjectAlternativeNameExtension(altNamesArray, false);
extensions.push(altNamesExtension);
}
const serialNumber = createSerialNumber();
const leafCert = await x509.X509CertificateGenerator.create({
serialNumber,
subject: csrObj.subject,
issuer: caCertObj.subject,
notBefore: notBeforeDate,
notAfter: notAfterDate,
signingKey: caPrivateKey,
publicKey: csrObj.publicKey,
signingAlgorithm: alg,
extensions
throw new BadRequestError({ message: "Unsupported CA type" });
};
const issueSubscriberCert = async ({
subscriberName,
projectId,
actorId,
actorAuthMethod,
actor,
actorOrgId
}: TIssuePkiSubscriberCertDTO) => {
const subscriber = await pkiSubscriberDAL.findOne({
name: subscriberName,
projectId
});
const skLeafObj = KeyObject.from(leafKeys.privateKey);
const skLeaf = skLeafObj.export({ format: "pem", type: "pkcs8" }) as string;
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 kmsEncryptor = await kmsService.encryptWithKmsKey({
kmsId: certificateManagerKmsId
});
const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({
plainText: Buffer.from(new Uint8Array(leafCert.rawData))
});
const { cipherTextBlob: encryptedPrivateKey } = await kmsEncryptor({
plainText: Buffer.from(skLeaf)
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: subscriber.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
const { caCert: issuingCaCertificate, caCertChain } = await getCaCertChain({
caCertId: caCert.id,
certificateAuthorityDAL,
certificateAuthorityCertDAL,
projectDAL,
kmsService
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPkiSubscriberActions.IssueCert,
subject(ProjectPermissionSub.PkiSubscribers, {
name: subscriber.name
})
);
const certificateChainPem = `${issuingCaCertificate}\n${caCertChain}`.trim();
if (subscriber.status !== PkiSubscriberStatus.ACTIVE)
throw new BadRequestError({ message: "Subscriber is not active" });
const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({
plainText: Buffer.from(certificateChainPem)
});
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(subscriber.caId);
if (ca.internalCa?.id) {
return internalCaFns.issueCertificate(subscriber, ca);
}
await certificateDAL.transaction(async (tx) => {
const cert = await certificateDAL.create(
{
caId: ca.id,
caCertId: caCert.id,
pkiSubscriberId: subscriber.id,
status: CertStatus.ACTIVE,
friendlyName: subscriber.commonName,
commonName: subscriber.commonName,
altNames: subscriber.subjectAlternativeNames.join(","),
serialNumber,
notBefore: notBeforeDate,
notAfter: notAfterDate,
keyUsages: selectedKeyUsages,
extendedKeyUsages: subscriber.extendedKeyUsages as CertExtendedKeyUsage[]
},
tx
);
if (ca.externalCa?.id && ca.externalCa.type === CaType.ACME) {
return acmeCaFns.orderCertificate(subscriber, ca, {
type: actor,
id: actorId,
authMethod: actorAuthMethod,
orgId: actorOrgId
});
}
await certificateBodyDAL.create(
{
certId: cert.id,
encryptedCertificate,
encryptedCertificateChain
},
tx
);
await certificateSecretDAL.create(
{
certId: cert.id,
encryptedPrivateKey
},
tx
);
});
return {
certificate: leafCert.toString("pem"),
certificateChain: certificateChainPem,
issuingCaCertificate,
privateKey: skLeaf,
serialNumber,
ca,
subscriber
};
throw new BadRequestError({ message: "CA does not support immediate issuance of certificates" });
};
const signSubscriberCert = async ({
@@ -803,6 +710,7 @@ export const pkiSubscriberServiceFactory = ({
deleteSubscriber,
issueSubscriberCert,
signSubscriberCert,
listSubscriberCerts
listSubscriberCerts,
orderSubscriberCert
};
};

View File

@@ -42,6 +42,10 @@ export type TIssuePkiSubscriberCertDTO = {
subscriberName: string;
} & TProjectPermission;
export type TOrderPkiSubscriberCertDTO = {
subscriberName: string;
} & TProjectPermission;
export type TSignPkiSubscriberCertDTO = {
subscriberName: string;
csr: string;

View File

@@ -918,6 +918,7 @@ export const projectServiceFactory = ({
const cas = await certificateAuthorityDAL.findWithAssociatedCa(
{
[`${TableName.CertificateAuthority}.projectId` as "projectId"]: projectId,
$notNull: [`${TableName.InternalCertificateAuthority}.id` as "id"],
...(status && { [`${TableName.InternalCertificateAuthority}.status` as "status"]: status }),
...(friendlyName && {
[`${TableName.InternalCertificateAuthority}.friendlyName` as "friendlyName"]: friendlyName

View File

@@ -1,10 +1,10 @@
import { SshCaStatus } from "../sshCa";
import { SshCertTemplateStatus } from "../sshCertificateTemplates";
import { CaStatus, CaType } from "./enums";
import { CaStatus, InternalCaType } from "./enums";
export const caTypeToNameMap: { [K in CaType]: string } = {
[CaType.ROOT]: "Root",
[CaType.INTERMEDIATE]: "Intermediate"
export const caTypeToNameMap: { [K in InternalCaType]: string } = {
[InternalCaType.ROOT]: "Root",
[InternalCaType.INTERMEDIATE]: "Intermediate"
};
export const caStatusToNameMap: { [K in CaStatus]: string } = {

View File

@@ -1,4 +1,9 @@
export enum CaType {
INTERNAL = "internal",
ACME = "acme"
}
export enum InternalCaType {
ROOT = "root",
INTERMEDIATE = "intermediate"
}
@@ -12,3 +17,7 @@ export enum CaStatus {
export enum CaRenewalType {
EXISTING = "existing"
}
export enum AcmeDnsProvider {
ROUTE53 = "route53"
}

View File

@@ -1,7 +1,8 @@
export { CaRenewalType, CaStatus, CaType } from "./enums";
export { AcmeDnsProvider, CaRenewalType, CaStatus, CaType, InternalCaType } from "./enums";
export {
useCreateCa,
useCreateCertificate,
useCreateUnifiedCa,
useDeleteCa,
useImportCaCertificate,
useRenewCa,
@@ -10,9 +11,12 @@ export {
} from "./mutations";
export {
useGetCaById,
useGetCaByTypeAndId,
useGetCaCert,
useGetCaCerts,
useGetCaCertTemplates,
useGetCaCrls,
useGetCaCsr
useGetCaCsr,
useListCasByProjectId,
useListCasByTypeAndProjectId
} from "./queries";

View File

@@ -9,6 +9,7 @@ import {
TCreateCaDTO,
TCreateCertificateDTO,
TCreateCertificateResponse,
TCreateUnifiedCertificateAuthorityDTO,
TDeleteCaDTO,
TImportCaCertificateDTO,
TImportCaCertificateResponse,
@@ -16,9 +17,28 @@ import {
TRenewCaResponse,
TSignIntermediateDTO,
TSignIntermediateResponse,
TUnifiedCertificateAuthority,
TUpdateCaDTO
} from "./types";
export const useCreateUnifiedCa = () => {
const queryClient = useQueryClient();
return useMutation<TUnifiedCertificateAuthority, object, TCreateUnifiedCertificateAuthorityDTO>({
mutationFn: async (body) => {
const { data } = await apiRequest.post<TUnifiedCertificateAuthority>(
`/api/v1/pki/ca/${body.type}`,
body
);
return data;
},
onSuccess: (_, { type, projectId }) => {
queryClient.invalidateQueries({
queryKey: caKeys.listCasByTypeAndProjectId(type, projectId)
});
}
});
};
export const useCreateCa = () => {
const queryClient = useQueryClient();
return useMutation<TCertificateAuthority, object, TCreateCaDTO>({

View File

@@ -3,10 +3,14 @@ import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { TCertificateTemplate } from "../certificateTemplates/types";
import { TCertificateAuthority } from "./types";
import { CaType } from "./enums";
import { TCertificateAuthority, TUnifiedCertificateAuthority } from "./types";
export const caKeys = {
getCaById: (caId: string) => [{ caId }, "ca"],
getCaByTypeAndId: (type: CaType, caId: string) => [{ type, caId }, "ca"],
listCasByTypeAndProjectId: (type: CaType, projectId: string) => [{ type, projectId }, "cas"],
listCasByProjectId: (projectId: string) => [{ projectId }, "cas"],
getCaCerts: (caId: string) => [{ caId }, "ca-cert"],
getCaCrls: (caId: string) => [{ caId }, "ca-crls"],
getCaCert: (caId: string) => [{ caId }, "ca-cert"],
@@ -16,6 +20,47 @@ export const caKeys = {
getCaEstConfig: (caId: string) => [{ caId }, "ca-est-config"]
};
export const useGetCaByTypeAndId = (type: CaType, caId: string) => {
return useQuery({
queryKey: caKeys.getCaByTypeAndId(type, caId),
queryFn: async () => {
const {
data: { certificateAuthority }
} = await apiRequest.get<{ certificateAuthority: TUnifiedCertificateAuthority }>(
`/api/v1/pki/ca/${type}/${caId}`
);
return certificateAuthority;
},
enabled: Boolean(caId)
});
};
export const useListCasByTypeAndProjectId = (type: CaType, projectId: string) => {
return useQuery({
queryKey: caKeys.listCasByTypeAndProjectId(type, projectId),
queryFn: async () => {
const { data } = await apiRequest.get<{
certificateAuthorities: TUnifiedCertificateAuthority[];
}>(`/api/v1/pki/ca/${type}?projectId=${projectId}`);
return data.certificateAuthorities;
}
});
};
export const useListCasByProjectId = (projectId: string) => {
return useQuery({
queryKey: caKeys.listCasByProjectId(projectId),
queryFn: async () => {
const { data } = await apiRequest.get<{
certificateAuthorities: TUnifiedCertificateAuthority[];
}>(`/api/v2/pki/ca?projectId=${projectId}`);
return data.certificateAuthorities;
}
});
};
export const useGetCaById = (caId: string) => {
return useQuery({
queryKey: caKeys.getCaById(caId),

View File

@@ -1,11 +1,59 @@
import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "../certificates/enums";
import { CaRenewalType, CaStatus, CaType } from "./enums";
import { AcmeDnsProvider, CaRenewalType, CaStatus, CaType, InternalCaType } from "./enums";
export type TAcmeCertificateAuthority = {
id: string;
projectId: string;
type: CaType.ACME;
status: CaStatus;
name: string;
disableDirectIssuance: boolean;
configuration: {
dnsAppConnectionId: string;
dnsProvider: AcmeDnsProvider;
directoryUrl: string;
accountEmail: string;
};
};
export type TInternalCertificateAuthority = {
id: string;
projectId: string;
type: CaType.INTERNAL;
status: CaStatus;
name: string;
disableDirectIssuance: boolean;
configuration: {
type: InternalCaType;
friendlyName?: string;
commonName: string;
organization: string;
ou: string;
country: string;
province: string;
locality: string;
maxPathLength: number;
keyAlgorithm: CertKeyAlgorithm;
notAfter?: string;
notBefore?: string;
dn: string;
parentCaId?: string;
serialNumber: string;
activeCaCertId: string;
};
};
export type TUnifiedCertificateAuthority =
| TAcmeCertificateAuthority
| TInternalCertificateAuthority;
export type TCreateUnifiedCertificateAuthorityDTO = Omit<TUnifiedCertificateAuthority, "id">;
export type TCertificateAuthority = {
id: string;
parentCaId?: string;
projectId: string;
type: CaType;
type: InternalCaType;
status: CaStatus;
friendlyName: string;
organization: string;

View File

@@ -6,7 +6,7 @@ import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, IconButton, Tooltip } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { useTimedReset } from "@app/hooks";
import { CaStatus, CaType, useGetCaById } from "@app/hooks/api";
import { CaStatus, InternalCaType, useGetCaById } from "@app/hooks/api";
import { caStatusToNameMap, caTypeToNameMap } from "@app/hooks/api/ca/constants";
import { certKeyAlgorithmToNameMap } from "@app/hooks/api/certificates/constants";
import { UsePopUpState } from "@app/hooks/usePopUp";
@@ -82,7 +82,7 @@ export const CaDetailsSection = ({ caId, handlePopUpOpen }: Props) => {
</div>
</div>
</div>
{ca.type === CaType.INTERMEDIATE && ca.status !== CaStatus.PENDING_CERTIFICATE && (
{ca.type === InternalCaType.INTERMEDIATE && ca.status !== CaStatus.PENDING_CERTIFICATE && (
<div className="mb-4">
<p className="text-sm font-semibold text-mineshaft-300">Parent CA ID</p>
<div className="group flex align-top">
@@ -156,7 +156,7 @@ export const CaDetailsSection = ({ caId, handlePopUpOpen }: Props) => {
colorSchema="primary"
type="submit"
onClick={() => {
if (ca.type === CaType.INTERMEDIATE && !ca.parentCaId) {
if (ca.type === InternalCaType.INTERMEDIATE && !ca.parentCaId) {
// intermediate CA with external parent CA
handlePopUpOpen("installCaCert", {
caId,

View File

@@ -5,6 +5,7 @@ import { ProjectPermissionCan } from "@app/components/permissions";
import { PageHeader } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { ExternalCaSection } from "./components/ExternalCaSection";
import { CaSection } from "./components";
export const CertificateAuthoritiesPage = () => {
@@ -25,6 +26,7 @@ export const CertificateAuthoritiesPage = () => {
a={ProjectPermissionSub.CertificateAuthorities}
>
<CaSection />
<ExternalCaSection />
</ProjectPermissionCan>
</div>
</div>

View File

@@ -17,7 +17,7 @@ import {
// DatePicker
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { CaType, useCreateCa, useGetCaById, useUpdateCa } from "@app/hooks/api/ca";
import { InternalCaType, useCreateCa, useGetCaById, useUpdateCa } from "@app/hooks/api/ca";
import { certKeyAlgorithms } from "@app/hooks/api/certificates/constants";
import { CertKeyAlgorithm } from "@app/hooks/api/certificates/enums";
import { UsePopUpState } from "@app/hooks/usePopUp";
@@ -35,7 +35,7 @@ const getDateTenYearsFromToday = () => {
const schema = z
.object({
type: z.enum([CaType.ROOT, CaType.INTERMEDIATE]),
type: z.enum([InternalCaType.ROOT, InternalCaType.INTERMEDIATE]),
friendlyName: z.string(),
organization: z.string(),
ou: z.string(),
@@ -63,8 +63,8 @@ type Props = {
};
const caTypes = [
{ label: "Root", value: CaType.ROOT },
{ label: "Intermediate", value: CaType.INTERMEDIATE }
{ label: "Root", value: InternalCaType.ROOT },
{ label: "Intermediate", value: InternalCaType.INTERMEDIATE }
];
export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
@@ -85,7 +85,7 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
type: CaType.ROOT,
type: InternalCaType.ROOT,
friendlyName: "",
organization: "",
ou: "",
@@ -119,7 +119,7 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
});
} else {
reset({
type: CaType.ROOT,
type: InternalCaType.ROOT,
friendlyName: "",
organization: "",
ou: "",
@@ -212,7 +212,7 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
<Controller
control={control}
name="type"
defaultValue={CaType.ROOT}
defaultValue={InternalCaType.ROOT}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="CA Type" errorText={error?.message} isError={Boolean(error)}>
<Select
@@ -231,7 +231,7 @@ export const CaModal = ({ popUp, handlePopUpToggle }: Props) => {
</FormControl>
)}
/>
{caType === CaType.ROOT && (
{caType === InternalCaType.ROOT && (
<>
{/* <Controller
name="notAfter"

View File

@@ -72,7 +72,7 @@ export const CaSection = () => {
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Certificate Authorities</p>
<p className="text-xl font-semibold text-mineshaft-100">Internal Certificate Authorities</p>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.CertificateAuthorities}

View File

@@ -0,0 +1,350 @@
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,
FilterableSelect,
FormControl,
Input,
Modal,
ModalContent,
Select,
SelectItem,
Switch
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { useListAvailableAppConnections } from "@app/hooks/api/appConnections";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import {
AcmeDnsProvider,
CaStatus,
CaType,
useCreateUnifiedCa,
useGetCaById,
useUpdateCa
} from "@app/hooks/api/ca";
import { UsePopUpState } from "@app/hooks/usePopUp";
const schema = z
.object({
type: z.enum([CaType.ACME]),
name: z.string(),
disableDirectIssuance: z.boolean(),
status: z.enum([CaStatus.ACTIVE, CaStatus.DISABLED]),
configuration: z.object({
dnsAppConnection: z.object({
id: z.string(),
name: z.string()
}),
dnsProvider: z.nativeEnum(AcmeDnsProvider),
directoryUrl: z.string(),
accountEmail: z.string()
})
})
.required();
export type FormData = z.infer<typeof schema>;
type Props = {
popUp: UsePopUpState<["ca"]>;
handlePopUpToggle: (popUpName: keyof UsePopUpState<["ca"]>, state?: boolean) => void;
};
const caTypes = [{ label: "ACME", value: CaType.ACME }];
export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => {
const { currentWorkspace } = useWorkspace();
const { data: ca } = useGetCaById((popUp?.ca?.data as { caId: string })?.caId || "");
// SHEEN TODO: finish up CA management
const { mutateAsync: createMutateAsync } = useCreateUnifiedCa();
const { mutateAsync: updateMutateAsync } = useUpdateCa();
const {
control,
handleSubmit,
reset,
formState: { isSubmitting },
watch
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
type: CaType.ACME,
name: "",
status: CaStatus.ACTIVE,
disableDirectIssuance: false,
configuration: {
dnsAppConnection: {
id: "",
name: ""
},
dnsProvider: AcmeDnsProvider.ROUTE53,
directoryUrl: "",
accountEmail: ""
}
}
});
const caType = watch("type");
const dnsProvider = watch("configuration.dnsProvider");
const { data: availableConnections, isPending } = useListAvailableAppConnections(
AppConnection.AWS,
{
enabled: dnsProvider === AcmeDnsProvider.ROUTE53
}
);
useEffect(() => {
if (ca) {
// reset({
// type: ca.type,
// name: ca.name,
// disableDirectIssuance: ca.disableDirectIssuance
// });
} else {
reset({
type: CaType.ACME,
name: "",
status: CaStatus.ACTIVE,
disableDirectIssuance: false,
configuration: {
dnsAppConnection: {
id: "",
name: ""
},
dnsProvider: AcmeDnsProvider.ROUTE53,
directoryUrl: "",
accountEmail: ""
}
});
}
}, [ca]);
const onFormSubmit = async ({
type,
name,
disableDirectIssuance,
status,
configuration
}: FormData) => {
try {
if (!currentWorkspace?.slug) return;
if (ca) {
// update
// await updateMutateAsync({
// projectSlug: currentWorkspace.slug,
// caId: ca.id,
// name,
// disableDirectIssuance,
// status
// });
} else {
// create
await createMutateAsync({
projectId: currentWorkspace.id,
name,
type,
status,
disableDirectIssuance,
configuration: {
...configuration,
dnsAppConnectionId: configuration.dnsAppConnection.id
}
});
}
reset();
handlePopUpToggle("ca", false);
createNotification({
text: `Successfully ${ca ? "updated" : "created"} CA`,
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to create CA",
type: "error"
});
}
};
return (
<Modal
isOpen={popUp?.ca?.isOpen}
onOpenChange={(isOpen) => {
reset();
handlePopUpToggle("ca", isOpen);
}}
>
<ModalContent title={`${ca ? "View" : "Create"} External CA`}>
<form onSubmit={handleSubmit(onFormSubmit)}>
{ca && (
<FormControl label="CA ID">
<Input value={ca.id} isDisabled className="bg-white/[0.07]" />
</FormControl>
)}
<Controller
control={control}
name="type"
defaultValue={CaType.ACME}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="Type" errorText={error?.message} isError={Boolean(error)}>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
isDisabled={Boolean(ca)}
>
{caTypes.map(({ label, value }) => (
<SelectItem value={String(value || "")} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl label="Name" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} placeholder="my-external-ca" isDisabled={Boolean(ca)} />
</FormControl>
)}
/>
{caType === CaType.ACME && (
<>
<Controller
control={control}
name="configuration.dnsProvider"
defaultValue={AcmeDnsProvider.ROUTE53}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
label="DNS Provider"
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
isDisabled={Boolean(ca)}
>
<SelectItem
value={String(AcmeDnsProvider.ROUTE53)}
key={AcmeDnsProvider.ROUTE53}
>
Route53
</SelectItem>
</Select>
</FormControl>
)}
/>
<Controller
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
tooltipText={`${dnsProvider === AcmeDnsProvider.ROUTE53 ? "Route53" : ""} requires an AWS App Connection. This can be created from the Organization Settings page.`}
isError={Boolean(error)}
errorText={error?.message}
label="DNS App Connection"
>
<FilterableSelect
value={value}
onChange={(newValue) => {
onChange(newValue);
}}
isLoading={isPending}
options={availableConnections}
placeholder="Select connection..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id}
/>
</FormControl>
)}
control={control}
name="configuration.dnsAppConnection"
/>
<Controller
control={control}
defaultValue=""
name="configuration.directoryUrl"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Directory URL"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
placeholder="https://acme-v02.api.letsencrypt.org/directory"
/>
</FormControl>
)}
/>
<Controller
control={control}
defaultValue=""
name="configuration.accountEmail"
render={({ field, fieldState: { error } }) => (
<FormControl
label="Account Email"
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="user@infisical.com" />
</FormControl>
)}
/>
</>
)}
<Controller
control={control}
name="disableDirectIssuance"
render={({ field, fieldState: { error } }) => {
return (
<FormControl isError={Boolean(error)} errorText={error?.message} className="my-8">
<Switch
id="is-active"
onCheckedChange={(value) => field.onChange(value)}
isChecked={field.value}
>
<p className="w-full">Disable Direct Issuance</p>
</Switch>
</FormControl>
);
}}
/>
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
{popUp?.ca?.data ? "Update" : "Create"}
</Button>
<Button
colorSchema="secondary"
variant="plain"
onClick={() => handlePopUpToggle("ca", false)}
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};

View File

@@ -0,0 +1,126 @@
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import { Button, DeleteActionModal } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { CaStatus, useDeleteCa, useUpdateCa } from "@app/hooks/api";
import { usePopUp } from "@app/hooks/usePopUp";
import { ExternalCaModal } from "./ExternalCaModal";
import { ExternalCaTable } from "./ExternalCaTable";
export const ExternalCaSection = () => {
const { currentWorkspace } = useWorkspace();
const { mutateAsync: deleteCa } = useDeleteCa();
const { mutateAsync: updateCa } = useUpdateCa();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
"ca",
"deleteCa",
"caStatus", // enable / disable
"upgradePlan"
] as const);
const onRemoveCaSubmit = async (caId: string) => {
try {
if (!currentWorkspace?.slug) return;
await deleteCa({ caId, projectSlug: currentWorkspace.slug });
createNotification({
text: "Successfully deleted CA",
type: "success"
});
handlePopUpClose("deleteCa");
} catch {
createNotification({
text: "Failed to delete CA",
type: "error"
});
}
};
const onUpdateCaStatus = async ({ caId, status }: { caId: string; status: CaStatus }) => {
try {
if (!currentWorkspace?.slug) return;
await updateCa({ caId, projectSlug: currentWorkspace.slug, status });
createNotification({
text: `Successfully ${status === CaStatus.ACTIVE ? "enabled" : "disabled"} CA`,
type: "success"
});
handlePopUpClose("caStatus");
} catch (err) {
console.error(err);
createNotification({
text: `Failed to ${status === CaStatus.ACTIVE ? "enabled" : "disabled"} CA`,
type: "error"
});
}
};
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100">External Certificate Authorities</p>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.CertificateAuthorities}
>
{(isAllowed) => (
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("ca")}
isDisabled={!isAllowed}
>
Create CA
</Button>
)}
</ProjectPermissionCan>
</div>
<ExternalCaModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<ExternalCaTable handlePopUpOpen={handlePopUpOpen} />
<DeleteActionModal
isOpen={popUp.deleteCa.isOpen}
title={`Are you sure want to remove the CA ${
(popUp?.deleteCa?.data as { dn: string })?.dn || ""
} from the project?`}
subTitle="This action will delete other CAs and certificates below it in your CA hierarchy."
onChange={(isOpen) => handlePopUpToggle("deleteCa", isOpen)}
deleteKey="confirm"
onDeleteApproved={() => onRemoveCaSubmit((popUp?.deleteCa?.data as { caId: string })?.caId)}
/>
<DeleteActionModal
isOpen={popUp.caStatus.isOpen}
title={`Are you sure want to ${
(popUp?.caStatus?.data as { status: string })?.status === CaStatus.ACTIVE
? "enable"
: "disable"
} the CA ${(popUp?.caStatus?.data as { dn: string })?.dn || ""} from the project?`}
subTitle={
(popUp?.caStatus?.data as { status: string })?.status === CaStatus.ACTIVE
? "This action will allow the CA to start issuing certificates again."
: "This action will prevent the CA from issuing new certificates."
}
onChange={(isOpen) => handlePopUpToggle("caStatus", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onUpdateCaStatus(popUp?.caStatus?.data as { caId: string; status: CaStatus })
}
/>
<UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text={(popUp.upgradePlan?.data as { description: string })?.description}
/>
</div>
);
};

View File

@@ -0,0 +1,165 @@
import { faBan, faCertificate, faEllipsis, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Badge,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { CaStatus, CaType, useListCasByTypeAndProjectId } from "@app/hooks/api";
import { caStatusToNameMap, getCaStatusBadgeVariant } from "@app/hooks/api/ca/constants";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
handlePopUpOpen: (
popUpName: keyof UsePopUpState<["ca", "deleteCa", "caStatus", "upgradePlan"]>,
data?: {
caId?: string;
dn?: string;
status?: CaStatus;
description?: string;
}
) => void;
};
export const ExternalCaTable = ({ handlePopUpOpen }: Props) => {
const navigate = useNavigate();
const { currentWorkspace } = useWorkspace();
const { data, isPending } = useListCasByTypeAndProjectId(CaType.ACME, currentWorkspace.id);
return (
<div>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Name</Th>
<Th>Type</Th>
<Th>Status</Th>
<Th />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={3} innerKey="project-cas" />}
{!isPending &&
data &&
data.length > 0 &&
data.map((ca) => {
return (
<Tr
className="h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
key={`ca-${ca.id}`}
onClick={() =>
navigate({
to: `/${ProjectType.CertificateManager}/$projectId/ca/$caId` as const,
params: {
projectId: currentWorkspace.id,
caId: ca.id
}
})
}
>
<Td>{ca.name}</Td>
<Td>{ca.type}</Td>
<Td>
<Badge variant={getCaStatusBadgeVariant(ca.status)}>
{caStatusToNameMap[ca.status]}
</Badge>
</Td>
<Td className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<Tooltip content="More options">
<FontAwesomeIcon size="lg" icon={faEllipsis} />
</Tooltip>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
{(ca.status === CaStatus.ACTIVE || ca.status === CaStatus.DISABLED) && (
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.CertificateAuthorities}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("caStatus", {
caId: ca.id,
status:
ca.status === CaStatus.ACTIVE
? CaStatus.DISABLED
: CaStatus.ACTIVE
});
}}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faBan} />}
>
{`${ca.status === CaStatus.ACTIVE ? "Disable" : "Enable"} CA`}
</DropdownMenuItem>
)}
</ProjectPermissionCan>
)}
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.CertificateAuthorities}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
// handlePopUpOpen("deleteCa", {
// caId: ca.id,
// dn: ca.dn
// });
}}
disabled={!isAllowed}
icon={<FontAwesomeIcon icon={faTrash} />}
>
Delete CA
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isPending && data?.length === 0 && (
<EmptyState
title="No external certificate authorities have been created"
icon={faCertificate}
/>
)}
</TableContainer>
</div>
);
};

View File

@@ -20,10 +20,10 @@ import {
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import {
CaStatus,
CaType,
useCreatePkiSubscriber,
useGetPkiSubscriber,
useListWorkspaceCas,
useListCasByProjectId,
useListWorkspacePkiSubscribers,
useUpdatePkiSubscriber
} from "@app/hooks/api";
@@ -74,10 +74,7 @@ export const PkiSubscriberModal = ({ popUp, handlePopUpToggle }: Props) => {
const { currentWorkspace } = useWorkspace();
const projectId = currentWorkspace.id;
const { data: subscribers } = useListWorkspacePkiSubscribers(projectId);
const { data: cas } = useListWorkspaceCas({
projectSlug: currentWorkspace?.slug ?? "",
status: CaStatus.ACTIVE
});
const { data: cas } = useListCasByProjectId(projectId);
const { data: pkiSubscriber } = useGetPkiSubscriber({
subscriberName:
@@ -276,11 +273,16 @@ export const PkiSubscriberModal = ({ popUp, handlePopUpToggle }: Props) => {
onValueChange={(e) => onChange(e)}
className="w-full"
>
{(cas || []).map(({ id, dn }) => (
<SelectItem value={id} key={`ca-${id}`}>
{dn}
</SelectItem>
))}
{(cas || []).map(({ id, name, type, configuration }) => {
const displayName =
type === CaType.INTERNAL ? `${name} (${configuration.dn})` : name;
return (
<SelectItem value={id} key={`ca-${id}`}>
{displayName}
</SelectItem>
);
})}
</Select>
</FormControl>
)}