mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-08 13:28:27 +00:00
Merge remote-tracking branch 'origin/main' into feat/PKI-67
This commit is contained in:
@@ -22,6 +22,28 @@ Feature: Challenge
|
||||
And I parse the full-chain certificate from order finalized_order as cert
|
||||
And the value cert with jq ".subject.common_name" should be equal to "localhost"
|
||||
|
||||
Scenario: Validate challenge with retry
|
||||
Given I have an ACME cert profile as "acme_profile"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/directory"
|
||||
Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account
|
||||
When I create certificate signing request as csr
|
||||
Then I add names to certificate signing request csr
|
||||
"""
|
||||
{
|
||||
"COMMON_NAME": "localhost"
|
||||
}
|
||||
"""
|
||||
And I create a RSA private key pair as cert_key
|
||||
And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format
|
||||
And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order
|
||||
And I select challenge with type http-01 for domain localhost from order in order as challenge
|
||||
And I wait 45 seconds and serve challenge response for challenge at localhost
|
||||
And I tell ACME server that challenge is ready to be verified
|
||||
And I poll and finalize the ACME order order as finalized_order
|
||||
And the value finalized_order.body with jq ".status" should be equal to "valid"
|
||||
And I parse the full-chain certificate from order finalized_order as cert
|
||||
And the value cert with jq ".subject.common_name" should be equal to "localhost"
|
||||
|
||||
Scenario: Validate challenges for multiple domains
|
||||
Given I have an ACME cert profile as "acme_profile"
|
||||
When I have an ACME client connecting to "{BASE_URL}/api/v1/cert-manager/acme/profiles/{acme_profile.id}/directory"
|
||||
@@ -63,13 +85,12 @@ Feature: Challenge
|
||||
When I create certificate signing request as csr
|
||||
Then I add names to certificate signing request csr
|
||||
"""
|
||||
{
|
||||
"COMMON_NAME": "localhost"
|
||||
}
|
||||
{}
|
||||
"""
|
||||
And I add subject alternative name to certificate signing request csr
|
||||
"""
|
||||
[
|
||||
"localhost",
|
||||
"infisical.com"
|
||||
]
|
||||
"""
|
||||
@@ -82,56 +103,19 @@ Feature: Challenge
|
||||
|
||||
# the localhost auth should be valid
|
||||
And I memorize order with jq ".authorizations | map(select(.body.identifier.value == "localhost")) | first | .uri" as localhost_auth
|
||||
And I peak and memorize the next nonce as nonce
|
||||
When I send a raw ACME request to "{localhost_auth}"
|
||||
"""
|
||||
{
|
||||
"protected": {
|
||||
"alg": "RS256",
|
||||
"nonce": "{nonce}",
|
||||
"url": "{localhost_auth}",
|
||||
"kid": "{acme_account.uri}"
|
||||
}
|
||||
}
|
||||
"""
|
||||
Then the value response.status_code should be equal to 200
|
||||
And the value response with jq ".status" should be equal to "valid"
|
||||
And I wait until the status of authorization localhost_auth becomes valid
|
||||
|
||||
# the infisical.com auth should still be pending
|
||||
And I memorize order with jq ".authorizations | map(select(.body.identifier.value == "infisical.com")) | first | .uri" as infisical_auth
|
||||
And I memorize response.headers with jq ".["replay-nonce"]" as nonce
|
||||
When I send a raw ACME request to "{infisical_auth}"
|
||||
"""
|
||||
{
|
||||
"protected": {
|
||||
"alg": "RS256",
|
||||
"nonce": "{nonce}",
|
||||
"url": "{infisical_auth}",
|
||||
"kid": "{acme_account.uri}"
|
||||
}
|
||||
}
|
||||
"""
|
||||
Then the value response.status_code should be equal to 200
|
||||
And the value response with jq ".status" should be equal to "pending"
|
||||
And I post-as-get {infisical_auth} as infisical_auth_resp
|
||||
And the value infisical_auth_resp with jq ".status" should be equal to "pending"
|
||||
|
||||
# the order should be pending as well
|
||||
And I memorize response.headers with jq ".["replay-nonce"]" as nonce
|
||||
When I send a raw ACME request to "{order.uri}"
|
||||
"""
|
||||
{
|
||||
"protected": {
|
||||
"alg": "RS256",
|
||||
"nonce": "{nonce}",
|
||||
"url": "{order.uri}",
|
||||
"kid": "{acme_account.uri}"
|
||||
}
|
||||
}
|
||||
"""
|
||||
Then the value response.status_code should be equal to 200
|
||||
And the value response with jq ".status" should be equal to "pending"
|
||||
And I post-as-get {order.uri} as order_resp
|
||||
And the value order_resp with jq ".status" should be equal to "pending"
|
||||
|
||||
# finalize should not be allowed when all auths are not valid yet
|
||||
And I memorize response.headers with jq ".["replay-nonce"]" as nonce
|
||||
And I get a new-nonce as nonce
|
||||
When I send a raw ACME request to "{order.body.finalize}"
|
||||
"""
|
||||
{
|
||||
@@ -185,8 +169,10 @@ Feature: Challenge
|
||||
Then the value response.status_code should be equal to 201
|
||||
And I memorize response with jq ".finalize" as finalize_url
|
||||
And I memorize response.headers with jq ".["replay-nonce"]" as nonce
|
||||
And I memorize response.headers with jq ".["location"]" as order_uri
|
||||
And I memorize response as order
|
||||
And I pass all challenges with type http-01 for order in order
|
||||
And I wait until the status of order order_uri becomes ready
|
||||
And I encode CSR csr_pem as JOSE Base-64 DER as base64_csr_der
|
||||
When I send a raw ACME request to "{finalize_url}"
|
||||
"""
|
||||
|
||||
@@ -2,6 +2,8 @@ import json
|
||||
import logging
|
||||
import re
|
||||
import urllib.parse
|
||||
import time
|
||||
import threading
|
||||
|
||||
import acme.client
|
||||
import jq
|
||||
@@ -724,6 +726,15 @@ def step_impl(context: Context, var_path: str, jq_query, var_name: str):
|
||||
context.vars[var_name] = value
|
||||
|
||||
|
||||
@then("I get a new-nonce as {var_name}")
|
||||
def step_impl(context: Context, var_name: str):
|
||||
acme_client = context.acme_client
|
||||
nonce = acme_client.net._get_nonce(
|
||||
url=None, new_nonce_url=acme_client.directory.newNonce
|
||||
)
|
||||
context.vars[var_name] = json_util.encode_b64jose(nonce)
|
||||
|
||||
|
||||
@then("I peak and memorize the next nonce as {var_name}")
|
||||
def step_impl(context: Context, var_name: str):
|
||||
acme_client = context.acme_client
|
||||
@@ -797,22 +808,39 @@ def select_challenge(
|
||||
return challenges[0]
|
||||
|
||||
|
||||
def serve_challenge(
|
||||
def serve_challenges(
|
||||
context: Context,
|
||||
challenge: messages.ChallengeBody,
|
||||
challenges: list[messages.ChallengeBody],
|
||||
wait_time: int | None = None,
|
||||
):
|
||||
if hasattr(context, "web_server"):
|
||||
context.web_server.shutdown_and_server_close()
|
||||
|
||||
response, validation = challenge.response_and_validation(
|
||||
context.acme_client.net.key
|
||||
)
|
||||
resource = standalone.HTTP01RequestHandler.HTTP01Resource(
|
||||
chall=challenge.chall, response=response, validation=validation
|
||||
)
|
||||
resources = set()
|
||||
for challenge in challenges:
|
||||
response, validation = challenge.response_and_validation(
|
||||
context.acme_client.net.key
|
||||
)
|
||||
resources.add(
|
||||
standalone.HTTP01RequestHandler.HTTP01Resource(
|
||||
chall=challenge.chall, response=response, validation=validation
|
||||
)
|
||||
)
|
||||
# TODO: make port configurable
|
||||
servers = standalone.HTTP01DualNetworkedServers(("0.0.0.0", 8087), {resource})
|
||||
servers.serve_forever()
|
||||
servers = standalone.HTTP01DualNetworkedServers(("0.0.0.0", 8087), resources)
|
||||
if wait_time is None:
|
||||
servers.serve_forever()
|
||||
else:
|
||||
|
||||
def wait_and_start():
|
||||
logger.info("Waiting %s seconds before we start serving.", wait_time)
|
||||
time.sleep(wait_time)
|
||||
logger.info("Start server now")
|
||||
servers.serve_forever()
|
||||
|
||||
thread = threading.Thread(target=wait_and_start)
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
context.web_server = servers
|
||||
|
||||
|
||||
@@ -865,6 +893,7 @@ def step_impl(
|
||||
f"Expected OrderResource but got {type(order)!r} at {order_var_path!r}"
|
||||
)
|
||||
|
||||
challenges = {}
|
||||
for domain in order.body.identifiers:
|
||||
logger.info(
|
||||
"Selecting challenge for domain %s with type %s ...",
|
||||
@@ -889,18 +918,28 @@ def step_impl(
|
||||
domain.value,
|
||||
challenge_type,
|
||||
)
|
||||
serve_challenge(context=context, challenge=challenge)
|
||||
challenges[domain] = challenge
|
||||
|
||||
serve_challenges(context=context, challenges=list(challenges.values()))
|
||||
for domain, challenge in challenges.items():
|
||||
logger.info(
|
||||
"Notifying challenge for domain %s with type %s ...", domain, challenge_type
|
||||
)
|
||||
notify_challenge_ready(context=context, challenge=challenge)
|
||||
|
||||
|
||||
@then(
|
||||
"I wait {wait_time} seconds and serve challenge response for {var_path} at {hostname}"
|
||||
)
|
||||
def step_impl(context: Context, wait_time: str, var_path: str, hostname: str):
|
||||
challenge = eval_var(context, var_path, as_json=False)
|
||||
serve_challenges(context=context, challenges=[challenge], wait_time=int(wait_time))
|
||||
|
||||
|
||||
@then("I serve challenge response for {var_path} at {hostname}")
|
||||
def step_impl(context: Context, var_path: str, hostname: str):
|
||||
challenge = eval_var(context, var_path, as_json=False)
|
||||
serve_challenge(context=context, challenge=challenge)
|
||||
serve_challenges(context=context, challenges=[challenge])
|
||||
|
||||
|
||||
@then("I tell ACME server that {var_path} is ready to be verified")
|
||||
@@ -909,6 +948,47 @@ def step_impl(context: Context, var_path: str):
|
||||
notify_challenge_ready(context=context, challenge=challenge)
|
||||
|
||||
|
||||
@then("I wait until the status of order {order_var} becomes {status}")
|
||||
def step_impl(context: Context, order_var: str, status: str):
|
||||
acme_client = context.acme_client
|
||||
attempt_count = 6
|
||||
while attempt_count:
|
||||
order = eval_var(context, order_var, as_json=False)
|
||||
response = acme_client._post_as_get(
|
||||
order.uri if isinstance(order, messages.OrderResource) else order
|
||||
)
|
||||
order = messages.Order.from_json(response.json())
|
||||
if order.status.name == status:
|
||||
return
|
||||
attempt_count -= 1
|
||||
time.sleep(10)
|
||||
raise TimeoutError(f"The status of order doesn't become {status} before timeout")
|
||||
|
||||
|
||||
@then("I wait until the status of authorization {auth_var} becomes {status}")
|
||||
def step_impl(context: Context, auth_var: str, status: str):
|
||||
acme_client = context.acme_client
|
||||
attempt_count = 6
|
||||
while attempt_count:
|
||||
auth = eval_var(context, auth_var, as_json=False)
|
||||
response = acme_client._post_as_get(
|
||||
auth.uri if isinstance(auth, messages.Authorization) else auth
|
||||
)
|
||||
auth = messages.Authorization.from_json(response.json())
|
||||
if auth.status.name == status:
|
||||
return
|
||||
attempt_count -= 1
|
||||
time.sleep(10)
|
||||
raise TimeoutError(f"The status of auth doesn't become {status} before timeout")
|
||||
|
||||
|
||||
@then("I post-as-get {uri} as {resp_var}")
|
||||
def step_impl(context: Context, uri: str, resp_var: str):
|
||||
acme_client = context.acme_client
|
||||
response = acme_client._post_as_get(replace_vars(uri, vars=context.vars))
|
||||
context.vars[resp_var] = response.json()
|
||||
|
||||
|
||||
@then("I poll and finalize the ACME order {var_path} as {finalized_var}")
|
||||
def step_impl(context: Context, var_path: str, finalized_var: str):
|
||||
order = eval_var(context, var_path, as_json=False)
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"outputPath": "binary"
|
||||
},
|
||||
"scripts": {
|
||||
"assets:export": "./scripts/export-assets.sh",
|
||||
"binary:build": "npm run binary:clean && npm run build:frontend && npm run build && npm run binary:babel-frontend && npm run binary:babel-backend && npm run binary:rename-imports",
|
||||
"binary:package": "pkg --no-bytecode --public-packages \"*\" --public --target host .",
|
||||
"binary:babel-backend": " babel ./dist -d ./dist",
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/bin/sh
|
||||
# Export frontend static assets for CDN deployment
|
||||
# Usage:
|
||||
# npm run assets:export - Output tar to stdout (pipe to file or aws s3)
|
||||
# npm run assets:export /path - Extract assets to specified directory
|
||||
# npm run assets:export -- --help - Show usage
|
||||
|
||||
set -e
|
||||
|
||||
ASSETS_PATH="/backend/frontend-build/assets"
|
||||
|
||||
show_help() {
|
||||
cat << 'EOF'
|
||||
Export frontend static assets for CDN deployment.
|
||||
|
||||
USAGE:
|
||||
docker run --rm infisical/infisical npm run --silent assets:export [-- OPTIONS] [PATH]
|
||||
|
||||
OPTIONS:
|
||||
--help, -h Show this help message
|
||||
|
||||
ARGUMENTS:
|
||||
PATH Directory to export assets to. If not provided, outputs
|
||||
a tar archive to stdout.
|
||||
|
||||
NOTE:
|
||||
Use --silent flag to suppress npm output when piping to stdout.
|
||||
|
||||
EXAMPLES:
|
||||
# Export as tar to local file
|
||||
docker run --rm infisical/infisical npm run --silent assets:export > assets.tar
|
||||
|
||||
# Extract to local directory
|
||||
docker run --rm -v $(pwd)/cdn-assets:/output infisical/infisical npm run --silent assets:export /output
|
||||
|
||||
EOF
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Check for help flag
|
||||
case "${1:-}" in
|
||||
--help|-h)
|
||||
show_help
|
||||
;;
|
||||
esac
|
||||
|
||||
# Verify assets exist
|
||||
if [ ! -d "$ASSETS_PATH" ]; then
|
||||
echo "Error: Assets directory not found at $ASSETS_PATH" >&2
|
||||
echo "Make sure the frontend is built and included in the image." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ASSET_COUNT=$(find "$ASSETS_PATH" -type f | wc -l | tr -d ' ')
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
# No path provided - output tar to stdout
|
||||
echo "Exporting $ASSET_COUNT assets as tar archive to stdout..." >&2
|
||||
tar -cf - -C "$(dirname "$ASSETS_PATH")" "$(basename "$ASSETS_PATH")"
|
||||
else
|
||||
# Path provided - extract to directory
|
||||
OUTPUT_PATH="$1"
|
||||
|
||||
if [ ! -d "$OUTPUT_PATH" ]; then
|
||||
echo "Creating output directory: $OUTPUT_PATH" >&2
|
||||
mkdir -p "$OUTPUT_PATH"
|
||||
fi
|
||||
|
||||
echo "Exporting $ASSET_COUNT assets to $OUTPUT_PATH..." >&2
|
||||
cp -r "$ASSETS_PATH"/* "$OUTPUT_PATH/"
|
||||
|
||||
echo "✅ Assets exported successfully!" >&2
|
||||
echo " Path: $OUTPUT_PATH" >&2
|
||||
echo " Files: $ASSET_COUNT assets" >&2
|
||||
fi
|
||||
Vendored
+2
@@ -65,6 +65,7 @@ import { TCertificateAuthorityServiceFactory } from "@app/services/certificate-a
|
||||
import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service";
|
||||
import { TCertificateEstV3ServiceFactory } from "@app/services/certificate-est-v3/certificate-est-v3-service";
|
||||
import { TCertificateProfileServiceFactory } from "@app/services/certificate-profile/certificate-profile-service";
|
||||
import { TCertificateRequestServiceFactory } from "@app/services/certificate-request/certificate-request-service";
|
||||
import { TCertificateTemplateServiceFactory } from "@app/services/certificate-template/certificate-template-service";
|
||||
import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service";
|
||||
import { TCertificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service";
|
||||
@@ -288,6 +289,7 @@ declare module "fastify" {
|
||||
auditLogStream: TAuditLogStreamServiceFactory;
|
||||
certificate: TCertificateServiceFactory;
|
||||
certificateV3: TCertificateV3ServiceFactory;
|
||||
certificateRequest: TCertificateRequestServiceFactory;
|
||||
certificateTemplate: TCertificateTemplateServiceFactory;
|
||||
certificateTemplateV2: TCertificateTemplateV2ServiceFactory;
|
||||
certificateProfile: TCertificateProfileServiceFactory;
|
||||
|
||||
Vendored
+10
@@ -573,6 +573,11 @@ import {
|
||||
TWorkflowIntegrationsInsert,
|
||||
TWorkflowIntegrationsUpdate
|
||||
} from "@app/db/schemas";
|
||||
import {
|
||||
TCertificateRequests,
|
||||
TCertificateRequestsInsert,
|
||||
TCertificateRequestsUpdate
|
||||
} from "@app/db/schemas/certificate-requests";
|
||||
import {
|
||||
TAccessApprovalPoliciesEnvironments,
|
||||
TAccessApprovalPoliciesEnvironmentsInsert,
|
||||
@@ -714,6 +719,11 @@ declare module "knex/types/tables" {
|
||||
TExternalCertificateAuthoritiesUpdate
|
||||
>;
|
||||
[TableName.Certificate]: KnexOriginal.CompositeTableType<TCertificates, TCertificatesInsert, TCertificatesUpdate>;
|
||||
[TableName.CertificateRequests]: KnexOriginal.CompositeTableType<
|
||||
TCertificateRequests,
|
||||
TCertificateRequestsInsert,
|
||||
TCertificateRequestsUpdate
|
||||
>;
|
||||
[TableName.CertificateTemplate]: KnexOriginal.CompositeTableType<
|
||||
TCertificateTemplates,
|
||||
TCertificateTemplatesInsert,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
if (!(await knex.schema.hasTable(TableName.CertificateRequests))) {
|
||||
await knex.schema.createTable(TableName.CertificateRequests, (t) => {
|
||||
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
|
||||
t.timestamps(true, true, true);
|
||||
t.string("status").notNullable();
|
||||
t.string("projectId").notNullable();
|
||||
t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
|
||||
t.uuid("profileId").nullable();
|
||||
t.foreign("profileId").references("id").inTable(TableName.PkiCertificateProfile).onDelete("SET NULL");
|
||||
t.uuid("caId").nullable();
|
||||
t.foreign("caId").references("id").inTable(TableName.CertificateAuthority).onDelete("SET NULL");
|
||||
t.uuid("certificateId").nullable();
|
||||
t.foreign("certificateId").references("id").inTable(TableName.Certificate).onDelete("SET NULL");
|
||||
t.text("csr").nullable();
|
||||
t.string("commonName").nullable();
|
||||
t.text("altNames").nullable();
|
||||
t.specificType("keyUsages", "text[]").nullable();
|
||||
t.specificType("extendedKeyUsages", "text[]").nullable();
|
||||
t.datetime("notBefore").nullable();
|
||||
t.datetime("notAfter").nullable();
|
||||
t.string("keyAlgorithm").nullable();
|
||||
t.string("signatureAlgorithm").nullable();
|
||||
t.text("errorMessage").nullable();
|
||||
t.text("metadata").nullable();
|
||||
|
||||
t.index(["projectId"]);
|
||||
t.index(["status"]);
|
||||
t.index(["profileId"]);
|
||||
t.index(["caId"]);
|
||||
t.index(["certificateId"]);
|
||||
t.index(["createdAt"]);
|
||||
});
|
||||
}
|
||||
|
||||
await createOnUpdateTrigger(knex, TableName.CertificateRequests);
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
await knex.schema.dropTableIfExists(TableName.CertificateRequests);
|
||||
await dropOnUpdateTrigger(knex, TableName.CertificateRequests);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
const hasExternalConfigs = await knex.schema.hasColumn(TableName.PkiCertificateProfile, "externalConfigs");
|
||||
if (!hasExternalConfigs) {
|
||||
await knex.schema.alterTable(TableName.PkiCertificateProfile, (t) => {
|
||||
t.text("externalConfigs").nullable();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
const hasExternalConfigs = await knex.schema.hasColumn(TableName.PkiCertificateProfile, "externalConfigs");
|
||||
if (hasExternalConfigs) {
|
||||
await knex.schema.alterTable(TableName.PkiCertificateProfile, (t) => {
|
||||
t.dropColumn("externalConfigs");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Code generated by automation script, DO NOT EDIT.
|
||||
// Automated by pulling database and generating zod schema
|
||||
// To update. Just run npm run generate:schema
|
||||
// Written by akhilmhdh.
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { TImmutableDBKeys } from "./models";
|
||||
|
||||
export const CertificateRequestsSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
status: z.string(),
|
||||
projectId: z.string(),
|
||||
profileId: z.string().uuid().nullable().optional(),
|
||||
caId: z.string().uuid().nullable().optional(),
|
||||
certificateId: z.string().uuid().nullable().optional(),
|
||||
csr: z.string().nullable().optional(),
|
||||
commonName: z.string().nullable().optional(),
|
||||
altNames: z.string().nullable().optional(),
|
||||
keyUsages: z.string().array().nullable().optional(),
|
||||
extendedKeyUsages: z.string().array().nullable().optional(),
|
||||
notBefore: z.date().nullable().optional(),
|
||||
notAfter: z.date().nullable().optional(),
|
||||
keyAlgorithm: z.string().nullable().optional(),
|
||||
signatureAlgorithm: z.string().nullable().optional(),
|
||||
errorMessage: z.string().nullable().optional(),
|
||||
metadata: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
export type TCertificateRequests = z.infer<typeof CertificateRequestsSchema>;
|
||||
export type TCertificateRequestsInsert = Omit<z.input<typeof CertificateRequestsSchema>, TImmutableDBKeys>;
|
||||
export type TCertificateRequestsUpdate = Partial<Omit<z.input<typeof CertificateRequestsSchema>, TImmutableDBKeys>>;
|
||||
@@ -16,6 +16,7 @@ export * from "./certificate-authority-certs";
|
||||
export * from "./certificate-authority-crl";
|
||||
export * from "./certificate-authority-secret";
|
||||
export * from "./certificate-bodies";
|
||||
export * from "./certificate-requests";
|
||||
export * from "./certificate-secrets";
|
||||
export * from "./certificate-syncs";
|
||||
export * from "./certificate-template-est-configs";
|
||||
|
||||
@@ -21,6 +21,7 @@ export enum TableName {
|
||||
CertificateAuthorityCrl = "certificate_authority_crl",
|
||||
Certificate = "certificates",
|
||||
CertificateBody = "certificate_bodies",
|
||||
CertificateRequests = "certificate_requests",
|
||||
CertificateSecret = "certificate_secrets",
|
||||
CertificateTemplate = "certificate_templates",
|
||||
PkiCertificateTemplateV2 = "pki_certificate_templates_v2",
|
||||
|
||||
@@ -20,7 +20,8 @@ export const PkiCertificateProfilesSchema = z.object({
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
acmeConfigId: z.string().uuid().nullable().optional(),
|
||||
issuerType: z.string().default("ca")
|
||||
issuerType: z.string().default("ca"),
|
||||
externalConfigs: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
export type TPkiCertificateProfiles = z.infer<typeof PkiCertificateProfilesSchema>;
|
||||
|
||||
@@ -388,6 +388,9 @@ export enum EventType {
|
||||
GET_CERTIFICATE_PROFILE_LATEST_ACTIVE_BUNDLE = "get-certificate-profile-latest-active-bundle",
|
||||
UPDATE_CERTIFICATE_RENEWAL_CONFIG = "update-certificate-renewal-config",
|
||||
DISABLE_CERTIFICATE_RENEWAL_CONFIG = "disable-certificate-renewal-config",
|
||||
CREATE_CERTIFICATE_REQUEST = "create-certificate-request",
|
||||
GET_CERTIFICATE_REQUEST = "get-certificate-request",
|
||||
GET_CERTIFICATE_FROM_REQUEST = "get-certificate-from-request",
|
||||
ATTEMPT_CREATE_SLACK_INTEGRATION = "attempt-create-slack-integration",
|
||||
ATTEMPT_REINSTALL_SLACK_INTEGRATION = "attempt-reinstall-slack-integration",
|
||||
GET_PROJECT_SLACK_CONFIG = "get-project-slack-config",
|
||||
@@ -2846,7 +2849,6 @@ interface OrderCertificateFromProfile {
|
||||
type: EventType.ORDER_CERTIFICATE_FROM_PROFILE;
|
||||
metadata: {
|
||||
certificateProfileId: string;
|
||||
orderId: string;
|
||||
profileName: string;
|
||||
};
|
||||
}
|
||||
@@ -4196,6 +4198,31 @@ interface DisableCertificateRenewalConfigEvent {
|
||||
};
|
||||
}
|
||||
|
||||
interface CreateCertificateRequestEvent {
|
||||
type: EventType.CREATE_CERTIFICATE_REQUEST;
|
||||
metadata: {
|
||||
certificateRequestId: string;
|
||||
profileId?: string;
|
||||
caId?: string;
|
||||
commonName?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface GetCertificateRequestEvent {
|
||||
type: EventType.GET_CERTIFICATE_REQUEST;
|
||||
metadata: {
|
||||
certificateRequestId: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface GetCertificateFromRequestEvent {
|
||||
type: EventType.GET_CERTIFICATE_FROM_REQUEST;
|
||||
metadata: {
|
||||
certificateRequestId: string;
|
||||
certificateId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type Event =
|
||||
| CreateSubOrganizationEvent
|
||||
| UpdateSubOrganizationEvent
|
||||
@@ -4575,6 +4602,9 @@ export type Event =
|
||||
| PamResourceDeleteEvent
|
||||
| UpdateCertificateRenewalConfigEvent
|
||||
| DisableCertificateRenewalConfigEvent
|
||||
| CreateCertificateRequestEvent
|
||||
| GetCertificateRequestEvent
|
||||
| GetCertificateFromRequestEvent
|
||||
| AutomatedRenewCertificate
|
||||
| AutomatedRenewCertificateFailed
|
||||
| UserLoginEvent
|
||||
|
||||
@@ -45,7 +45,6 @@ const buildAdminPermissionRules = () => {
|
||||
ProjectPermissionSub.Settings,
|
||||
ProjectPermissionSub.Environments,
|
||||
ProjectPermissionSub.Tags,
|
||||
ProjectPermissionSub.AuditLogs,
|
||||
ProjectPermissionSub.IpAllowList,
|
||||
ProjectPermissionSub.PkiAlerts,
|
||||
ProjectPermissionSub.PkiCollections,
|
||||
@@ -67,6 +66,8 @@ const buildAdminPermissionRules = () => {
|
||||
);
|
||||
});
|
||||
|
||||
can([ProjectPermissionAuditLogsActions.Read], ProjectPermissionSub.AuditLogs);
|
||||
|
||||
can(
|
||||
[
|
||||
ProjectPermissionCertificateAuthorityActions.Read,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import axios, { AxiosError } from "axios";
|
||||
|
||||
import { TPkiAcmeChallenges } from "@app/db/schemas/pki-acme-challenges";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { isPrivateIp } from "@app/lib/ip/ipRange";
|
||||
@@ -18,7 +19,11 @@ import { TPkiAcmeChallengeServiceFactory } from "./pki-acme-types";
|
||||
type TPkiAcmeChallengeServiceFactoryDep = {
|
||||
acmeChallengeDAL: Pick<
|
||||
TPkiAcmeChallengeDALFactory,
|
||||
"transaction" | "findByIdForChallengeValidation" | "markAsValidCascadeById" | "markAsInvalidCascadeById"
|
||||
| "transaction"
|
||||
| "findByIdForChallengeValidation"
|
||||
| "markAsValidCascadeById"
|
||||
| "markAsInvalidCascadeById"
|
||||
| "updateById"
|
||||
>;
|
||||
};
|
||||
|
||||
@@ -26,9 +31,8 @@ export const pkiAcmeChallengeServiceFactory = ({
|
||||
acmeChallengeDAL
|
||||
}: TPkiAcmeChallengeServiceFactoryDep): TPkiAcmeChallengeServiceFactory => {
|
||||
const appCfg = getConfig();
|
||||
|
||||
const validateChallengeResponse = async (challengeId: string): Promise<void> => {
|
||||
const error: Error | undefined = await acmeChallengeDAL.transaction(async (tx) => {
|
||||
const markChallengeAsReady = async (challengeId: string): Promise<TPkiAcmeChallenges> => {
|
||||
return acmeChallengeDAL.transaction(async (tx) => {
|
||||
logger.info({ challengeId }, "Validating ACME challenge response");
|
||||
const challenge = await acmeChallengeDAL.findByIdForChallengeValidation(challengeId, tx);
|
||||
if (!challenge) {
|
||||
@@ -52,81 +56,102 @@ export const pkiAcmeChallengeServiceFactory = ({
|
||||
if (challenge.type !== AcmeChallengeType.HTTP_01) {
|
||||
throw new BadRequestError({ message: "Only HTTP-01 challenges are supported for now" });
|
||||
}
|
||||
let host = challenge.auth.identifierValue;
|
||||
const host = challenge.auth.identifierValue;
|
||||
// check if host is a private ip address
|
||||
if (isPrivateIp(host)) {
|
||||
throw new BadRequestError({ message: "Private IP addresses are not allowed" });
|
||||
}
|
||||
if (appCfg.isAcmeDevelopmentMode && appCfg.ACME_DEVELOPMENT_HTTP01_CHALLENGE_HOST_OVERRIDES[host]) {
|
||||
host = appCfg.ACME_DEVELOPMENT_HTTP01_CHALLENGE_HOST_OVERRIDES[host];
|
||||
logger.warn(
|
||||
{ srcHost: challenge.auth.identifierValue, dstHost: host },
|
||||
"Using ACME development HTTP-01 challenge host override"
|
||||
);
|
||||
}
|
||||
const challengeUrl = new URL(`/.well-known/acme-challenge/${challenge.auth.token}`, `http://${host}`);
|
||||
logger.info({ challengeUrl }, "Performing ACME HTTP-01 challenge validation");
|
||||
try {
|
||||
// TODO: read config from the profile to get the timeout instead
|
||||
const timeoutMs = 10 * 1000; // 10 seconds
|
||||
// Notice: well, we are in a transaction, ideally we should not hold transaction and perform
|
||||
// a long running operation for long time. But assuming we are not performing a tons of
|
||||
// challenge validation at the same time, it should be fine.
|
||||
const challengeResponse = await axios.get<string>(challengeUrl.toString(), {
|
||||
// In case if we override the host in the development mode, still provide the original host in the header
|
||||
// to help the upstream server to validate the request
|
||||
headers: { Host: challenge.auth.identifierValue },
|
||||
timeout: timeoutMs,
|
||||
responseType: "text",
|
||||
validateStatus: () => true
|
||||
});
|
||||
if (challengeResponse.status !== 200) {
|
||||
throw new AcmeIncorrectResponseError({
|
||||
message: `ACME challenge response is not 200: ${challengeResponse.status}`
|
||||
});
|
||||
}
|
||||
const challengeResponseBody: string = challengeResponse.data;
|
||||
const thumbprint = challenge.auth.account.publicKeyThumbprint;
|
||||
const expectedChallengeResponseBody = `${challenge.auth.token}.${thumbprint}`;
|
||||
if (challengeResponseBody.trimEnd() !== expectedChallengeResponseBody) {
|
||||
throw new AcmeIncorrectResponseError({ message: "ACME challenge response is not correct" });
|
||||
}
|
||||
await acmeChallengeDAL.markAsValidCascadeById(challengeId, tx);
|
||||
} catch (exp) {
|
||||
// TODO: we should retry the challenge validation a few times, but let's keep it simple for now
|
||||
await acmeChallengeDAL.markAsInvalidCascadeById(challengeId, tx);
|
||||
// Properly type and inspect the error
|
||||
if (axios.isAxiosError(exp)) {
|
||||
const axiosError = exp as AxiosError;
|
||||
const errorCode = axiosError.code;
|
||||
const errorMessage = axiosError.message;
|
||||
|
||||
if (errorCode === "ECONNREFUSED" || errorMessage.includes("ECONNREFUSED")) {
|
||||
return new AcmeConnectionError({ message: "Connection refused" });
|
||||
}
|
||||
if (errorCode === "ENOTFOUND" || errorMessage.includes("ENOTFOUND")) {
|
||||
return new AcmeDnsFailureError({ message: "Hostname could not be resolved (DNS failure)" });
|
||||
}
|
||||
if (errorCode === "ECONNABORTED" || errorMessage.includes("timeout")) {
|
||||
logger.error(exp, "Connection timed out while validating ACME challenge response");
|
||||
return new AcmeConnectionError({ message: "Connection timed out" });
|
||||
}
|
||||
logger.error(exp, "Unknown error validating ACME challenge response");
|
||||
return new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" });
|
||||
}
|
||||
if (exp instanceof Error) {
|
||||
logger.error(exp, "Error validating ACME challenge response");
|
||||
} else {
|
||||
logger.error(exp, "Unknown error validating ACME challenge response");
|
||||
return new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" });
|
||||
}
|
||||
return exp;
|
||||
}
|
||||
return acmeChallengeDAL.updateById(challengeId, { status: AcmeChallengeStatus.Processing }, tx);
|
||||
});
|
||||
if (error) {
|
||||
throw error;
|
||||
};
|
||||
|
||||
const validateChallengeResponse = async (challengeId: string, retryCount: number): Promise<void> => {
|
||||
logger.info({ challengeId, retryCount }, "Validating ACME challenge response");
|
||||
const challenge = await acmeChallengeDAL.findByIdForChallengeValidation(challengeId);
|
||||
if (!challenge) {
|
||||
throw new NotFoundError({ message: "ACME challenge not found" });
|
||||
}
|
||||
if (challenge.status !== AcmeChallengeStatus.Processing) {
|
||||
throw new BadRequestError({
|
||||
message: `ACME challenge is ${challenge.status} instead of ${AcmeChallengeStatus.Processing}`
|
||||
});
|
||||
}
|
||||
let host = challenge.auth.identifierValue;
|
||||
if (appCfg.isAcmeDevelopmentMode && appCfg.ACME_DEVELOPMENT_HTTP01_CHALLENGE_HOST_OVERRIDES[host]) {
|
||||
host = appCfg.ACME_DEVELOPMENT_HTTP01_CHALLENGE_HOST_OVERRIDES[host];
|
||||
logger.warn(
|
||||
{ srcHost: challenge.auth.identifierValue, dstHost: host },
|
||||
"Using ACME development HTTP-01 challenge host override"
|
||||
);
|
||||
}
|
||||
const challengeUrl = new URL(`/.well-known/acme-challenge/${challenge.auth.token}`, `http://${host}`);
|
||||
logger.info({ challengeUrl }, "Performing ACME HTTP-01 challenge validation");
|
||||
try {
|
||||
// TODO: read config from the profile to get the timeout instead
|
||||
const timeoutMs = 10 * 1000; // 10 seconds
|
||||
// Notice: well, we are in a transaction, ideally we should not hold transaction and perform
|
||||
// a long running operation for long time. But assuming we are not performing a tons of
|
||||
// challenge validation at the same time, it should be fine.
|
||||
const challengeResponse = await axios.get<string>(challengeUrl.toString(), {
|
||||
// In case if we override the host in the development mode, still provide the original host in the header
|
||||
// to help the upstream server to validate the request
|
||||
headers: { Host: challenge.auth.identifierValue },
|
||||
timeout: timeoutMs,
|
||||
responseType: "text",
|
||||
validateStatus: () => true
|
||||
});
|
||||
if (challengeResponse.status !== 200) {
|
||||
throw new AcmeIncorrectResponseError({
|
||||
message: `ACME challenge response is not 200: ${challengeResponse.status}`
|
||||
});
|
||||
}
|
||||
const challengeResponseBody: string = challengeResponse.data;
|
||||
const thumbprint = challenge.auth.account.publicKeyThumbprint;
|
||||
const expectedChallengeResponseBody = `${challenge.auth.token}.${thumbprint}`;
|
||||
if (challengeResponseBody.trimEnd() !== expectedChallengeResponseBody) {
|
||||
throw new AcmeIncorrectResponseError({ message: "ACME challenge response is not correct" });
|
||||
}
|
||||
logger.info({ challengeId }, "ACME challenge response is correct, marking challenge as valid");
|
||||
await acmeChallengeDAL.markAsValidCascadeById(challengeId);
|
||||
} catch (exp) {
|
||||
if (retryCount >= 2) {
|
||||
logger.error(
|
||||
exp,
|
||||
`Last attempt to validate ACME challenge response failed, marking ${challengeId} challenge as invalid`
|
||||
);
|
||||
// This is the last attempt to validate the challenge response, if it fails, we mark the challenge as invalid
|
||||
await acmeChallengeDAL.markAsInvalidCascadeById(challengeId);
|
||||
}
|
||||
// Properly type and inspect the error
|
||||
if (axios.isAxiosError(exp)) {
|
||||
const axiosError = exp as AxiosError;
|
||||
const errorCode = axiosError.code;
|
||||
const errorMessage = axiosError.message;
|
||||
|
||||
if (errorCode === "ECONNREFUSED" || errorMessage.includes("ECONNREFUSED")) {
|
||||
throw new AcmeConnectionError({ message: "Connection refused" });
|
||||
}
|
||||
if (errorCode === "ENOTFOUND" || errorMessage.includes("ENOTFOUND")) {
|
||||
throw new AcmeDnsFailureError({ message: "Hostname could not be resolved (DNS failure)" });
|
||||
}
|
||||
if (errorCode === "ECONNRESET" || errorMessage.includes("ECONNRESET")) {
|
||||
throw new AcmeConnectionError({ message: "Connection reset by peer" });
|
||||
}
|
||||
if (errorCode === "ECONNABORTED" || errorMessage.includes("timeout")) {
|
||||
logger.error(exp, "Connection timed out while validating ACME challenge response");
|
||||
throw new AcmeConnectionError({ message: "Connection timed out" });
|
||||
}
|
||||
logger.error(exp, "Unknown error validating ACME challenge response");
|
||||
throw new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" });
|
||||
}
|
||||
if (exp instanceof Error) {
|
||||
logger.error(exp, "Error validating ACME challenge response");
|
||||
throw exp;
|
||||
}
|
||||
logger.error(exp, "Unknown error validating ACME challenge response");
|
||||
throw new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" });
|
||||
}
|
||||
};
|
||||
|
||||
return { validateChallengeResponse };
|
||||
return { markChallengeAsReady, validateChallengeResponse };
|
||||
};
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
|
||||
|
||||
import { TPkiAcmeChallengeServiceFactory } from "./pki-acme-types";
|
||||
|
||||
type TPkiAcmeQueueServiceFactoryDep = {
|
||||
queueService: TQueueServiceFactory;
|
||||
acmeChallengeService: TPkiAcmeChallengeServiceFactory;
|
||||
};
|
||||
|
||||
export type TPkiAcmeQueueServiceFactory = Awaited<ReturnType<typeof pkiAcmeQueueServiceFactory>>;
|
||||
|
||||
export const pkiAcmeQueueServiceFactory = async ({
|
||||
queueService,
|
||||
acmeChallengeService
|
||||
}: TPkiAcmeQueueServiceFactoryDep) => {
|
||||
const appCfg = getConfig();
|
||||
|
||||
// Initialize the worker to process challenge validation jobs
|
||||
await queueService.startPg<QueueName.PkiAcmeChallengeValidation>(
|
||||
QueueJobs.PkiAcmeChallengeValidation,
|
||||
async ([job]) => {
|
||||
const { challengeId } = job.data;
|
||||
const retryCount = job.retryCount || 0;
|
||||
try {
|
||||
logger.info({ challengeId, retryCount }, "Processing ACME challenge validation job");
|
||||
await acmeChallengeService.validateChallengeResponse(challengeId, retryCount);
|
||||
logger.info({ challengeId, retryCount }, "ACME challenge validation completed successfully");
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.error(
|
||||
error,
|
||||
`Failed to validate ACME challenge ${challengeId} (retryCount ${retryCount}): ${errorMessage}`
|
||||
);
|
||||
// Re-throw to let pg-boss handle retries with exponential backoff
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
batchSize: 1,
|
||||
workerCount: 2,
|
||||
pollingIntervalSeconds: 1
|
||||
}
|
||||
);
|
||||
|
||||
const queueChallengeValidation = async (challengeId: string): Promise<void> => {
|
||||
if (appCfg.isSecondaryInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info({ challengeId }, "Queueing ACME challenge validation");
|
||||
await queueService.queuePg(
|
||||
QueueJobs.PkiAcmeChallengeValidation,
|
||||
{ challengeId },
|
||||
{
|
||||
retryLimit: 3,
|
||||
retryDelay: 30, // Base delay of 30 seconds
|
||||
retryBackoff: true // Exponential backoff: 30s, 60s, 120s
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
queueChallengeValidation
|
||||
};
|
||||
};
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
import { buildUrl, extractAccountIdFromKid, validateDnsIdentifier } from "./pki-acme-fns";
|
||||
import { TPkiAcmeOrderAuthDALFactory } from "./pki-acme-order-auth-dal";
|
||||
import { TPkiAcmeOrderDALFactory } from "./pki-acme-order-dal";
|
||||
import { TPkiAcmeQueueServiceFactory } from "./pki-acme-queue";
|
||||
import {
|
||||
AcmeAuthStatus,
|
||||
AcmeChallengeStatus,
|
||||
@@ -94,7 +95,7 @@ import {
|
||||
type TPkiAcmeServiceFactoryDep = {
|
||||
projectDAL: Pick<TProjectDALFactory, "findOne" | "updateById" | "transaction" | "findById">;
|
||||
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById">;
|
||||
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction">;
|
||||
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction" | "updateById">;
|
||||
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa">;
|
||||
externalCertificateAuthorityDAL: Pick<TExternalCertificateAuthorityDALFactory, "update">;
|
||||
certificateProfileDAL: Pick<TCertificateProfileDALFactory, "findByIdWithOwnerOrgId" | "findByIdWithConfigs">;
|
||||
@@ -126,7 +127,8 @@ type TPkiAcmeServiceFactoryDep = {
|
||||
>;
|
||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||
certificateV3Service: Pick<TCertificateV3ServiceFactory, "signCertificateFromProfile">;
|
||||
acmeChallengeService: TPkiAcmeChallengeServiceFactory;
|
||||
acmeChallengeService: Pick<TPkiAcmeChallengeServiceFactory, "markChallengeAsReady">;
|
||||
pkiAcmeQueueService: Pick<TPkiAcmeQueueServiceFactory, "queueChallengeValidation">;
|
||||
};
|
||||
|
||||
export const pkiAcmeServiceFactory = ({
|
||||
@@ -147,7 +149,8 @@ export const pkiAcmeServiceFactory = ({
|
||||
kmsService,
|
||||
licenseService,
|
||||
certificateV3Service,
|
||||
acmeChallengeService
|
||||
acmeChallengeService,
|
||||
pkiAcmeQueueService
|
||||
}: TPkiAcmeServiceFactoryDep): TPkiAcmeServiceFactory => {
|
||||
const validateAcmeProfile = async (profileId: string): Promise<TCertificateProfileWithConfigs> => {
|
||||
const profile = await certificateProfileDAL.findByIdWithConfigs(profileId);
|
||||
@@ -975,7 +978,8 @@ export const pkiAcmeServiceFactory = ({
|
||||
if (!result) {
|
||||
throw new NotFoundError({ message: "ACME challenge not found" });
|
||||
}
|
||||
await acmeChallengeService.validateChallengeResponse(challengeId);
|
||||
await acmeChallengeService.markChallengeAsReady(challengeId);
|
||||
await pkiAcmeQueueService.queueChallengeValidation(challengeId);
|
||||
const challenge = (await acmeChallengeDAL.findByIdForChallengeValidation(challengeId))!;
|
||||
return {
|
||||
status: 200,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { JWSHeaderParameters } from "jose";
|
||||
import { z } from "zod";
|
||||
|
||||
import { TPkiAcmeChallenges } from "@app/db/schemas/pki-acme-challenges";
|
||||
|
||||
import {
|
||||
AcmeOrderResourceSchema,
|
||||
CreateAcmeAccountBodySchema,
|
||||
@@ -176,5 +178,6 @@ export type TPkiAcmeServiceFactory = {
|
||||
};
|
||||
|
||||
export type TPkiAcmeChallengeServiceFactory = {
|
||||
validateChallengeResponse: (challengeId: string) => Promise<void>;
|
||||
markChallengeAsReady: (challengeId: string) => Promise<TPkiAcmeChallenges>;
|
||||
validateChallengeResponse: (challengeId: string, retryCount: number) => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -64,6 +64,7 @@ export enum QueueName {
|
||||
DynamicSecretLeaseRevocationFailedEmail = "dynamic-secret-lease-revocation-failed-email",
|
||||
CaCrlRotation = "ca-crl-rotation",
|
||||
CaLifecycle = "ca-lifecycle", // parent queue to ca-order-certificate-for-subscriber
|
||||
CertificateIssuance = "certificate-issuance",
|
||||
SecretReplication = "secret-replication",
|
||||
SecretSync = "secret-sync", // parent queue to push integration sync, webhook, and secret replication
|
||||
PkiSync = "pki-sync",
|
||||
@@ -81,7 +82,8 @@ export enum QueueName {
|
||||
UserNotification = "user-notification",
|
||||
HealthAlert = "health-alert",
|
||||
CertificateV3AutoRenewal = "certificate-v3-auto-renewal",
|
||||
PamAccountRotation = "pam-account-rotation"
|
||||
PamAccountRotation = "pam-account-rotation",
|
||||
PkiAcmeChallengeValidation = "pki-acme-challenge-validation"
|
||||
}
|
||||
|
||||
export enum QueueJobs {
|
||||
@@ -127,6 +129,7 @@ export enum QueueJobs {
|
||||
SecretScanningV2DiffScan = "secret-scanning-v2-diff-scan",
|
||||
SecretScanningV2SendNotification = "secret-scanning-v2-notification",
|
||||
CaOrderCertificateForSubscriber = "ca-order-certificate-for-subscriber",
|
||||
CaIssueCertificateFromProfile = "ca-issue-certificate-from-profile",
|
||||
PkiSubscriberDailyAutoRenewal = "pki-subscriber-daily-auto-renewal",
|
||||
TelemetryAggregatedEvents = "telemetry-aggregated-events",
|
||||
DailyReminders = "daily-reminders",
|
||||
@@ -134,7 +137,8 @@ export enum QueueJobs {
|
||||
UserNotification = "user-notification-job",
|
||||
HealthAlert = "health-alert",
|
||||
CertificateV3DailyAutoRenewal = "certificate-v3-daily-auto-renewal",
|
||||
PamAccountRotation = "pam-account-rotation"
|
||||
PamAccountRotation = "pam-account-rotation",
|
||||
PkiAcmeChallengeValidation = "pki-acme-challenge-validation"
|
||||
}
|
||||
|
||||
export type TQueueJobTypes = {
|
||||
@@ -353,6 +357,21 @@ export type TQueueJobTypes = {
|
||||
caType: CaType;
|
||||
};
|
||||
};
|
||||
[QueueName.CertificateIssuance]: {
|
||||
name: QueueJobs.CaIssueCertificateFromProfile;
|
||||
payload: {
|
||||
certificateId: string;
|
||||
profileId: string;
|
||||
caId: string;
|
||||
commonName?: string;
|
||||
altNames?: string[];
|
||||
ttl: string;
|
||||
signatureAlgorithm: string;
|
||||
keyAlgorithm: string;
|
||||
keyUsages?: string[];
|
||||
extendedKeyUsages?: string[];
|
||||
};
|
||||
};
|
||||
[QueueName.DailyReminders]: {
|
||||
name: QueueJobs.DailyReminders;
|
||||
payload: undefined;
|
||||
@@ -385,6 +404,10 @@ export type TQueueJobTypes = {
|
||||
name: QueueJobs.PamAccountRotation;
|
||||
payload: undefined;
|
||||
};
|
||||
[QueueName.PkiAcmeChallengeValidation]: {
|
||||
name: QueueJobs.PkiAcmeChallengeValidation;
|
||||
payload: { challengeId: string };
|
||||
};
|
||||
};
|
||||
|
||||
const SECRET_SCANNING_JOBS = [
|
||||
|
||||
@@ -81,6 +81,7 @@ import { pkiAcmeChallengeDALFactory } from "@app/ee/services/pki-acme/pki-acme-c
|
||||
import { pkiAcmeChallengeServiceFactory } from "@app/ee/services/pki-acme/pki-acme-challenge-service";
|
||||
import { pkiAcmeOrderAuthDALFactory } from "@app/ee/services/pki-acme/pki-acme-order-auth-dal";
|
||||
import { pkiAcmeOrderDALFactory } from "@app/ee/services/pki-acme/pki-acme-order-dal";
|
||||
import { pkiAcmeQueueServiceFactory } from "@app/ee/services/pki-acme/pki-acme-queue";
|
||||
import { pkiAcmeServiceFactory } from "@app/ee/services/pki-acme/pki-acme-service";
|
||||
import { projectTemplateDALFactory } from "@app/ee/services/project-template/project-template-dal";
|
||||
import { projectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-service";
|
||||
@@ -173,6 +174,7 @@ import { certificateAuthorityDALFactory } from "@app/services/certificate-author
|
||||
import { certificateAuthorityQueueFactory } from "@app/services/certificate-authority/certificate-authority-queue";
|
||||
import { certificateAuthoritySecretDALFactory } from "@app/services/certificate-authority/certificate-authority-secret-dal";
|
||||
import { certificateAuthorityServiceFactory } from "@app/services/certificate-authority/certificate-authority-service";
|
||||
import { certificateIssuanceQueueFactory } from "@app/services/certificate-authority/certificate-issuance-queue";
|
||||
import { externalCertificateAuthorityDALFactory } from "@app/services/certificate-authority/external-certificate-authority-dal";
|
||||
import { internalCertificateAuthorityDALFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-dal";
|
||||
import { InternalCertificateAuthorityFns } from "@app/services/certificate-authority/internal/internal-certificate-authority-fns";
|
||||
@@ -180,6 +182,8 @@ import { internalCertificateAuthorityServiceFactory } from "@app/services/certif
|
||||
import { certificateEstV3ServiceFactory } from "@app/services/certificate-est-v3/certificate-est-v3-service";
|
||||
import { certificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal";
|
||||
import { certificateProfileServiceFactory } from "@app/services/certificate-profile/certificate-profile-service";
|
||||
import { certificateRequestDALFactory } from "@app/services/certificate-request/certificate-request-dal";
|
||||
import { certificateRequestServiceFactory } from "@app/services/certificate-request/certificate-request-service";
|
||||
import { certificateSyncDALFactory } from "@app/services/certificate-sync/certificate-sync-dal";
|
||||
import { certificateTemplateDALFactory } from "@app/services/certificate-template/certificate-template-dal";
|
||||
import { certificateTemplateEstConfigDALFactory } from "@app/services/certificate-template/certificate-template-est-config-dal";
|
||||
@@ -1092,6 +1096,7 @@ export const registerRoutes = async (
|
||||
const certificateDAL = certificateDALFactory(db);
|
||||
const certificateBodyDAL = certificateBodyDALFactory(db);
|
||||
const certificateSecretDAL = certificateSecretDALFactory(db);
|
||||
const certificateRequestDAL = certificateRequestDALFactory(db);
|
||||
const certificateSyncDAL = certificateSyncDALFactory(db);
|
||||
|
||||
const pkiAlertDAL = pkiAlertDALFactory(db);
|
||||
@@ -1187,7 +1192,7 @@ export const registerRoutes = async (
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL,
|
||||
certificateAuthorityDAL,
|
||||
certificateAuthorityCertDAL,
|
||||
externalCertificateAuthorityDAL,
|
||||
permissionService,
|
||||
licenseService,
|
||||
kmsService,
|
||||
@@ -2215,6 +2220,31 @@ export const registerRoutes = async (
|
||||
pkiSyncQueue
|
||||
});
|
||||
|
||||
const certificateRequestService = certificateRequestServiceFactory({
|
||||
certificateRequestDAL,
|
||||
certificateDAL,
|
||||
certificateService,
|
||||
permissionService
|
||||
});
|
||||
|
||||
const certificateIssuanceQueue = certificateIssuanceQueueFactory({
|
||||
certificateAuthorityDAL,
|
||||
appConnectionDAL,
|
||||
appConnectionService,
|
||||
externalCertificateAuthorityDAL,
|
||||
certificateDAL,
|
||||
projectDAL,
|
||||
kmsService,
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL,
|
||||
queueService,
|
||||
pkiSubscriberDAL,
|
||||
pkiSyncDAL,
|
||||
pkiSyncQueue,
|
||||
certificateProfileDAL,
|
||||
certificateRequestService
|
||||
});
|
||||
|
||||
const certificateV3Service = certificateV3ServiceFactory({
|
||||
certificateDAL,
|
||||
certificateSecretDAL,
|
||||
@@ -2229,7 +2259,9 @@ export const registerRoutes = async (
|
||||
pkiSyncQueue,
|
||||
kmsService,
|
||||
projectDAL,
|
||||
certificateBodyDAL
|
||||
certificateBodyDAL,
|
||||
certificateIssuanceQueue,
|
||||
certificateRequestService
|
||||
});
|
||||
|
||||
const certificateV3Queue = certificateV3QueueServiceFactory({
|
||||
@@ -2254,6 +2286,12 @@ export const registerRoutes = async (
|
||||
const acmeChallengeService = pkiAcmeChallengeServiceFactory({
|
||||
acmeChallengeDAL
|
||||
});
|
||||
|
||||
const pkiAcmeQueueService = await pkiAcmeQueueServiceFactory({
|
||||
queueService,
|
||||
acmeChallengeService
|
||||
});
|
||||
|
||||
const pkiAcmeService = pkiAcmeServiceFactory({
|
||||
projectDAL,
|
||||
appConnectionDAL,
|
||||
@@ -2272,7 +2310,8 @@ export const registerRoutes = async (
|
||||
kmsService,
|
||||
licenseService,
|
||||
certificateV3Service,
|
||||
acmeChallengeService
|
||||
acmeChallengeService,
|
||||
pkiAcmeQueueService
|
||||
});
|
||||
|
||||
const pkiSubscriberService = pkiSubscriberServiceFactory({
|
||||
@@ -2455,6 +2494,7 @@ export const registerRoutes = async (
|
||||
await pkiSubscriberQueue.startDailyAutoRenewalJob();
|
||||
await pkiAlertV2Queue.init();
|
||||
await certificateV3Queue.init();
|
||||
await certificateIssuanceQueue.initializeCertificateIssuanceQueue();
|
||||
await microsoftTeamsService.start();
|
||||
await dynamicSecretQueueService.init();
|
||||
await eventBusService.init();
|
||||
@@ -2520,6 +2560,7 @@ export const registerRoutes = async (
|
||||
auditLogStream: auditLogStreamService,
|
||||
certificate: certificateService,
|
||||
certificateV3: certificateV3Service,
|
||||
certificateRequest: certificateRequestService,
|
||||
certificateEstV3: certificateEstV3Service,
|
||||
sshCertificateAuthority: sshCertificateAuthorityService,
|
||||
sshCertificateTemplate: sshCertificateTemplateService,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
import { CertStatus } from "@app/services/certificate/certificate-types";
|
||||
import { ExternalConfigUnionSchema } from "@app/services/certificate-profile/certificate-profile-external-config-schemas";
|
||||
import { EnrollmentType, IssuerType } from "@app/services/certificate-profile/certificate-profile-types";
|
||||
|
||||
export const registerCertificateProfilesRouter = async (server: FastifyZodProvider) => {
|
||||
@@ -46,7 +47,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
|
||||
renewBeforeDays: z.number().min(1).max(30).optional()
|
||||
})
|
||||
.optional(),
|
||||
acmeConfig: z.object({}).optional()
|
||||
acmeConfig: z.object({}).optional(),
|
||||
externalConfigs: ExternalConfigUnionSchema
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
@@ -149,7 +151,9 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
|
||||
),
|
||||
response: {
|
||||
200: z.object({
|
||||
certificateProfile: PkiCertificateProfilesSchema
|
||||
certificateProfile: PkiCertificateProfilesSchema.extend({
|
||||
externalConfigs: ExternalConfigUnionSchema
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -204,6 +208,15 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
|
||||
response: {
|
||||
200: z.object({
|
||||
certificateProfiles: PkiCertificateProfilesSchema.extend({
|
||||
certificateAuthority: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
status: z.string(),
|
||||
name: z.string(),
|
||||
isExternal: z.boolean().optional(),
|
||||
externalType: z.string().nullable().optional()
|
||||
})
|
||||
.optional(),
|
||||
metrics: z
|
||||
.object({
|
||||
profileId: z.string(),
|
||||
@@ -234,7 +247,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
|
||||
id: z.string(),
|
||||
directoryUrl: z.string()
|
||||
})
|
||||
.optional()
|
||||
.optional(),
|
||||
externalConfigs: ExternalConfigUnionSchema
|
||||
}).array(),
|
||||
totalCount: z.number()
|
||||
})
|
||||
@@ -280,12 +294,16 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
|
||||
response: {
|
||||
200: z.object({
|
||||
certificateProfile: PkiCertificateProfilesSchema.extend({
|
||||
externalConfigs: ExternalConfigUnionSchema
|
||||
}).extend({
|
||||
certificateAuthority: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
projectId: z.string(),
|
||||
status: z.string(),
|
||||
name: z.string()
|
||||
name: z.string(),
|
||||
isExternal: z.boolean().optional(),
|
||||
externalType: z.string().nullable().optional()
|
||||
})
|
||||
.optional(),
|
||||
certificateTemplate: z
|
||||
@@ -310,7 +328,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
|
||||
autoRenew: z.boolean(),
|
||||
renewBeforeDays: z.number().optional()
|
||||
})
|
||||
.optional()
|
||||
.optional(),
|
||||
externalConfigs: ExternalConfigUnionSchema
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -358,7 +377,9 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
certificateProfile: PkiCertificateProfilesSchema
|
||||
certificateProfile: PkiCertificateProfilesSchema.extend({
|
||||
externalConfigs: ExternalConfigUnionSchema
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -412,7 +433,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
|
||||
autoRenew: z.boolean().default(false),
|
||||
renewBeforeDays: z.number().min(1).max(30).optional()
|
||||
})
|
||||
.optional()
|
||||
.optional(),
|
||||
externalConfigs: ExternalConfigUnionSchema
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
@@ -434,7 +456,9 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
|
||||
),
|
||||
response: {
|
||||
200: z.object({
|
||||
certificateProfile: PkiCertificateProfilesSchema
|
||||
certificateProfile: PkiCertificateProfilesSchema.extend({
|
||||
externalConfigs: ExternalConfigUnionSchema
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -479,7 +503,9 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
certificateProfile: PkiCertificateProfilesSchema
|
||||
certificateProfile: PkiCertificateProfilesSchema.extend({
|
||||
externalConfigs: ExternalConfigUnionSchema
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -5,18 +5,14 @@ import { z } from "zod";
|
||||
import { CertificatesSchema } from "@app/db/schemas";
|
||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import { ApiDocsTags, CERTIFICATES } from "@app/lib/api-docs";
|
||||
import { NotFoundError } from "@app/lib/errors";
|
||||
import { ms } from "@app/lib/ms";
|
||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { addNoCacheHeaders } from "@app/server/lib/caching";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
import {
|
||||
ACMESANType,
|
||||
CertificateOrderStatus,
|
||||
CertKeyAlgorithm,
|
||||
CertSignatureAlgorithm,
|
||||
CrlReason
|
||||
} from "@app/services/certificate/certificate-types";
|
||||
import { CertKeyAlgorithm, CertSignatureAlgorithm, CrlReason } from "@app/services/certificate/certificate-types";
|
||||
import { CaType } from "@app/services/certificate-authority/certificate-authority-enums";
|
||||
import { validateCaDateField } from "@app/services/certificate-authority/certificate-authority-validators";
|
||||
import {
|
||||
CertExtendedKeyUsageType,
|
||||
@@ -26,10 +22,23 @@ import {
|
||||
import { extractCertificateRequestFromCSR } from "@app/services/certificate-common/certificate-csr-utils";
|
||||
import { mapEnumsForValidation } from "@app/services/certificate-common/certificate-utils";
|
||||
import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types";
|
||||
import { CertificateRequestStatus } from "@app/services/certificate-request/certificate-request-types";
|
||||
import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators";
|
||||
import { TCertificateFromProfileResponse } from "@app/services/certificate-v3/certificate-v3-types";
|
||||
|
||||
import { booleanSchema } from "../sanitizedSchemas";
|
||||
|
||||
type CertificateServiceResponse = TCertificateFromProfileResponse | Omit<TCertificateFromProfileResponse, "privateKey">;
|
||||
|
||||
const extractCertificateData = (data: CertificateServiceResponse) => ({
|
||||
certificate: data.certificate,
|
||||
issuingCaCertificate: data.issuingCaCertificate,
|
||||
certificateChain: data.certificateChain,
|
||||
privateKey: "privateKey" in data ? data.privateKey : undefined,
|
||||
serialNumber: data.serialNumber,
|
||||
certificateId: data.certificateId
|
||||
});
|
||||
|
||||
interface CertificateRequestForService {
|
||||
commonName?: string;
|
||||
keyUsages?: CertKeyUsageType[];
|
||||
@@ -47,13 +56,32 @@ interface CertificateRequestForService {
|
||||
keyAlgorithm?: string;
|
||||
}
|
||||
|
||||
const validateTtlAndDateFields = (data: { notBefore?: string; notAfter?: string; ttl?: string }) => {
|
||||
const validateTtlAndDateFields = (data: {
|
||||
attributes?: { notBefore?: string; notAfter?: string; ttl?: string };
|
||||
notBefore?: string;
|
||||
notAfter?: string;
|
||||
ttl?: string;
|
||||
}) => {
|
||||
if (data.attributes) {
|
||||
const hasDateFields = data.attributes.notBefore || data.attributes.notAfter;
|
||||
const hasTtl = data.attributes.ttl;
|
||||
return !(hasDateFields && hasTtl);
|
||||
}
|
||||
const hasDateFields = data.notBefore || data.notAfter;
|
||||
const hasTtl = data.ttl;
|
||||
return !(hasDateFields && hasTtl);
|
||||
};
|
||||
|
||||
const validateDateOrder = (data: { notBefore?: string; notAfter?: string }) => {
|
||||
const validateDateOrder = (data: {
|
||||
attributes?: { notBefore?: string; notAfter?: string };
|
||||
notBefore?: string;
|
||||
notAfter?: string;
|
||||
}) => {
|
||||
if (data.attributes?.notBefore && data.attributes?.notAfter) {
|
||||
const notBefore = new Date(data.attributes.notBefore);
|
||||
const notAfter = new Date(data.attributes.notAfter);
|
||||
return notBefore < notAfter;
|
||||
}
|
||||
if (data.notBefore && data.notAfter) {
|
||||
const notBefore = new Date(data.notBefore);
|
||||
const notAfter = new Date(data.notAfter);
|
||||
@@ -65,13 +93,279 @@ const validateDateOrder = (data: { notBefore?: string; notAfter?: string }) => {
|
||||
export const registerCertificateRouter = async (server: FastifyZodProvider) => {
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/issue-certificate",
|
||||
url: "/",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
hide: false,
|
||||
tags: [ApiDocsTags.PkiCertificates],
|
||||
body: z
|
||||
.object({
|
||||
profileId: z.string().uuid(),
|
||||
csr: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "CSR cannot be empty")
|
||||
.max(4096, "CSR cannot exceed 4096 characters")
|
||||
.optional(),
|
||||
attributes: z
|
||||
.object({
|
||||
commonName: validateTemplateRegexField.optional(),
|
||||
keyUsages: z.nativeEnum(CertKeyUsageType).array().optional(),
|
||||
extendedKeyUsages: z.nativeEnum(CertExtendedKeyUsageType).array().optional(),
|
||||
altNames: z
|
||||
.array(
|
||||
z.object({
|
||||
type: z.nativeEnum(CertSubjectAlternativeNameType),
|
||||
value: z.string().min(1, "SAN value cannot be empty")
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm).optional(),
|
||||
keyAlgorithm: z.nativeEnum(CertKeyAlgorithm).optional(),
|
||||
ttl: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "TTL cannot be empty")
|
||||
.refine((val) => ms(val) > 0, "TTL must be a positive number"),
|
||||
notBefore: validateCaDateField.optional(),
|
||||
notAfter: validateCaDateField.optional()
|
||||
})
|
||||
.optional(),
|
||||
removeRootsFromChain: booleanSchema.default(false).optional()
|
||||
})
|
||||
.refine(validateTtlAndDateFields, {
|
||||
message:
|
||||
"Cannot specify both TTL and notBefore/notAfter. Use either TTL for duration-based validity or notBefore/notAfter for explicit date range."
|
||||
})
|
||||
.refine(validateDateOrder, {
|
||||
message: "notBefore must be earlier than notAfter"
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
certificate: z
|
||||
.object({
|
||||
certificate: z.string().trim(),
|
||||
issuingCaCertificate: z.string().trim(),
|
||||
certificateChain: z.string().trim(),
|
||||
privateKey: z.string().trim().optional(),
|
||||
serialNumber: z.string().trim(),
|
||||
certificateId: z.string()
|
||||
})
|
||||
.nullable(),
|
||||
certificateRequestId: z.string()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const { csr, attributes, ...requestBody } = req.body;
|
||||
const profile = await server.services.certificateProfile.getProfileById({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
profileId: requestBody.profileId
|
||||
});
|
||||
|
||||
let useOrderFlow = false;
|
||||
if (profile?.caId) {
|
||||
const ca = await server.services.certificateAuthority.getCaById({
|
||||
caId: profile.caId,
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId
|
||||
});
|
||||
const caType = (ca?.externalCa?.type as CaType) ?? CaType.INTERNAL;
|
||||
useOrderFlow = caType !== CaType.INTERNAL;
|
||||
}
|
||||
|
||||
if (useOrderFlow) {
|
||||
const certificateOrderObject = {
|
||||
altNames: attributes?.altNames || [],
|
||||
validity: { ttl: attributes?.ttl || "" },
|
||||
commonName: attributes?.commonName,
|
||||
keyUsages: attributes?.keyUsages,
|
||||
extendedKeyUsages: attributes?.extendedKeyUsages,
|
||||
notBefore: attributes?.notBefore ? new Date(attributes.notBefore) : undefined,
|
||||
notAfter: attributes?.notAfter ? new Date(attributes.notAfter) : undefined,
|
||||
signatureAlgorithm: attributes?.signatureAlgorithm,
|
||||
keyAlgorithm: attributes?.keyAlgorithm,
|
||||
csr
|
||||
};
|
||||
|
||||
const data = await server.services.certificateV3.orderCertificateFromProfile({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
profileId: requestBody.profileId,
|
||||
certificateOrder: certificateOrderObject,
|
||||
removeRootsFromChain: requestBody.removeRootsFromChain
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: data.projectId,
|
||||
event: {
|
||||
type: EventType.ORDER_CERTIFICATE_FROM_PROFILE,
|
||||
metadata: {
|
||||
certificateProfileId: requestBody.profileId,
|
||||
profileName: data.profileName
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
certificate: null,
|
||||
certificateRequestId: data.certificateRequestId
|
||||
};
|
||||
}
|
||||
|
||||
if (csr) {
|
||||
const extractedCsrData = extractCertificateRequestFromCSR(csr);
|
||||
|
||||
const data = await server.services.certificateV3.signCertificateFromProfile({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
profileId: requestBody.profileId,
|
||||
csr,
|
||||
validity: { ttl: attributes?.ttl || "" },
|
||||
notBefore: attributes?.notBefore ? new Date(attributes.notBefore) : undefined,
|
||||
notAfter: attributes?.notAfter ? new Date(attributes.notAfter) : undefined,
|
||||
enrollmentType: EnrollmentType.API,
|
||||
removeRootsFromChain: requestBody.removeRootsFromChain
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: data.projectId,
|
||||
event: {
|
||||
type: EventType.SIGN_CERTIFICATE_FROM_PROFILE,
|
||||
metadata: {
|
||||
certificateProfileId: requestBody.profileId,
|
||||
certificateId: data.certificateId,
|
||||
profileName: data.profileName,
|
||||
commonName: extractedCsrData.commonName || ""
|
||||
}
|
||||
}
|
||||
});
|
||||
return {
|
||||
certificate: extractCertificateData(data),
|
||||
certificateRequestId: data.certificateRequestId
|
||||
};
|
||||
}
|
||||
|
||||
const certificateRequestForService: CertificateRequestForService = {
|
||||
commonName: attributes?.commonName,
|
||||
keyUsages: attributes?.keyUsages,
|
||||
extendedKeyUsages: attributes?.extendedKeyUsages,
|
||||
altNames: attributes?.altNames,
|
||||
validity: { ttl: attributes?.ttl || "" },
|
||||
notBefore: attributes?.notBefore ? new Date(attributes.notBefore) : undefined,
|
||||
notAfter: attributes?.notAfter ? new Date(attributes.notAfter) : undefined,
|
||||
signatureAlgorithm: attributes?.signatureAlgorithm,
|
||||
keyAlgorithm: attributes?.keyAlgorithm
|
||||
};
|
||||
|
||||
const mappedCertificateRequest = mapEnumsForValidation(certificateRequestForService);
|
||||
|
||||
const data = await server.services.certificateV3.issueCertificateFromProfile({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
profileId: requestBody.profileId,
|
||||
certificateRequest: mappedCertificateRequest,
|
||||
removeRootsFromChain: requestBody.removeRootsFromChain
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: data.projectId,
|
||||
event: {
|
||||
type: EventType.ISSUE_CERTIFICATE_FROM_PROFILE,
|
||||
metadata: {
|
||||
certificateProfileId: requestBody.profileId,
|
||||
certificateId: data.certificateId,
|
||||
commonName: attributes?.commonName || "",
|
||||
profileName: data.profileName
|
||||
}
|
||||
}
|
||||
});
|
||||
return {
|
||||
certificate: extractCertificateData(data),
|
||||
certificateRequestId: data.certificateRequestId
|
||||
};
|
||||
}
|
||||
});
|
||||
server.route({
|
||||
method: "GET",
|
||||
url: "/certificate-requests/:requestId",
|
||||
config: {
|
||||
rateLimit: readLimit
|
||||
},
|
||||
schema: {
|
||||
hide: false,
|
||||
tags: [ApiDocsTags.PkiCertificates],
|
||||
params: z.object({
|
||||
requestId: z.string().uuid()
|
||||
}),
|
||||
query: z.object({
|
||||
projectId: z.string().uuid()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
status: z.nativeEnum(CertificateRequestStatus),
|
||||
certificate: z.string().nullable(),
|
||||
privateKey: z.string().nullable(),
|
||||
serialNumber: z.string().nullable(),
|
||||
errorMessage: z.string().nullable(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const data = await server.services.certificateRequest.getCertificateFromRequest({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
projectId: (req.query as { projectId: string }).projectId,
|
||||
certificateRequestId: req.params.requestId
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: (req.query as { projectId: string }).projectId,
|
||||
event: {
|
||||
type: EventType.GET_CERTIFICATE_REQUEST,
|
||||
metadata: {
|
||||
certificateRequestId: req.params.requestId
|
||||
}
|
||||
}
|
||||
});
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/issue-certificate",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
hide: true,
|
||||
deprecated: true,
|
||||
tags: [ApiDocsTags.PkiCertificates],
|
||||
description: "This endpoint will be removed in a future version.",
|
||||
body: z
|
||||
.object({
|
||||
profileId: z.string().uuid(),
|
||||
@@ -111,7 +405,8 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => {
|
||||
certificateChain: z.string().trim(),
|
||||
privateKey: z.string().trim().optional(),
|
||||
serialNumber: z.string().trim(),
|
||||
certificateId: z.string()
|
||||
certificateId: z.string(),
|
||||
certificateRequestId: z.string()
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -168,8 +463,10 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
hide: false,
|
||||
hide: true,
|
||||
deprecated: true,
|
||||
tags: [ApiDocsTags.PkiCertificates],
|
||||
description: "This endpoint will be removed in a future version.",
|
||||
body: z
|
||||
.object({
|
||||
profileId: z.string().uuid(),
|
||||
@@ -196,14 +493,13 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => {
|
||||
issuingCaCertificate: z.string().trim(),
|
||||
certificateChain: z.string().trim(),
|
||||
serialNumber: z.string().trim(),
|
||||
certificateId: z.string()
|
||||
certificateId: z.string(),
|
||||
certificateRequestId: z.string()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const certificateRequest = extractCertificateRequestFromCSR(req.body.csr);
|
||||
|
||||
const data = await server.services.certificateV3.signCertificateFromProfile({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
@@ -220,6 +516,8 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => {
|
||||
removeRootsFromChain: req.body.removeRootsFromChain
|
||||
});
|
||||
|
||||
const certificateRequestData = extractCertificateRequestFromCSR(req.body.csr);
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: data.projectId,
|
||||
@@ -229,7 +527,7 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => {
|
||||
certificateProfileId: req.body.profileId,
|
||||
certificateId: data.certificateId,
|
||||
profileName: data.profileName,
|
||||
commonName: certificateRequest.commonName || ""
|
||||
commonName: certificateRequestData.commonName || ""
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -245,23 +543,23 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
hide: false,
|
||||
hide: true,
|
||||
deprecated: true,
|
||||
tags: [ApiDocsTags.PkiCertificates],
|
||||
description: "This endpoint will be removed in a future version.",
|
||||
body: z
|
||||
.object({
|
||||
profileId: z.string().uuid(),
|
||||
subjectAlternativeNames: z
|
||||
.array(
|
||||
z.object({
|
||||
type: z.nativeEnum(ACMESANType),
|
||||
value: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "SAN value cannot be empty")
|
||||
.max(255, "SAN value must be less than 255 characters")
|
||||
})
|
||||
)
|
||||
.min(1, "At least one subject alternative name must be provided"),
|
||||
subjectAlternativeNames: z.array(
|
||||
z.object({
|
||||
type: z.nativeEnum(CertSubjectAlternativeNameType),
|
||||
value: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "SAN value cannot be empty")
|
||||
.max(255, "SAN value must be less than 255 characters")
|
||||
})
|
||||
),
|
||||
ttl: z
|
||||
.string()
|
||||
.trim()
|
||||
@@ -285,59 +583,34 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => {
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
orderId: z.string(),
|
||||
status: z.nativeEnum(CertificateOrderStatus),
|
||||
subjectAlternativeNames: z.array(
|
||||
z.object({
|
||||
type: z.nativeEnum(ACMESANType),
|
||||
value: z.string(),
|
||||
status: z.nativeEnum(CertificateOrderStatus)
|
||||
})
|
||||
),
|
||||
authorizations: z.array(
|
||||
z.object({
|
||||
identifier: z.object({
|
||||
type: z.nativeEnum(ACMESANType),
|
||||
value: z.string()
|
||||
}),
|
||||
status: z.nativeEnum(CertificateOrderStatus),
|
||||
expires: z.string().optional(),
|
||||
challenges: z.array(
|
||||
z.object({
|
||||
type: z.string(),
|
||||
status: z.nativeEnum(CertificateOrderStatus),
|
||||
url: z.string(),
|
||||
token: z.string()
|
||||
})
|
||||
)
|
||||
})
|
||||
),
|
||||
finalize: z.string(),
|
||||
certificate: z.string().optional()
|
||||
certificate: z.string().optional(),
|
||||
certificateRequestId: z.string()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const certificateOrderObject = {
|
||||
altNames: req.body.subjectAlternativeNames,
|
||||
validity: {
|
||||
ttl: req.body.ttl
|
||||
},
|
||||
commonName: req.body.commonName,
|
||||
keyUsages: req.body.keyUsages,
|
||||
extendedKeyUsages: req.body.extendedKeyUsages,
|
||||
notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined,
|
||||
notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined,
|
||||
signatureAlgorithm: req.body.signatureAlgorithm,
|
||||
keyAlgorithm: req.body.keyAlgorithm
|
||||
};
|
||||
|
||||
const data = await server.services.certificateV3.orderCertificateFromProfile({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
profileId: req.body.profileId,
|
||||
certificateOrder: {
|
||||
altNames: req.body.subjectAlternativeNames,
|
||||
validity: {
|
||||
ttl: req.body.ttl
|
||||
},
|
||||
commonName: req.body.commonName,
|
||||
keyUsages: req.body.keyUsages,
|
||||
extendedKeyUsages: req.body.extendedKeyUsages,
|
||||
notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined,
|
||||
notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined,
|
||||
signatureAlgorithm: req.body.signatureAlgorithm,
|
||||
keyAlgorithm: req.body.keyAlgorithm
|
||||
},
|
||||
certificateOrder: certificateOrderObject,
|
||||
removeRootsFromChain: req.body.removeRootsFromChain
|
||||
});
|
||||
|
||||
@@ -348,7 +621,6 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => {
|
||||
type: EventType.ORDER_CERTIFICATE_FROM_PROFILE,
|
||||
metadata: {
|
||||
certificateProfileId: req.body.profileId,
|
||||
orderId: data.orderId,
|
||||
profileName: data.profileName
|
||||
}
|
||||
}
|
||||
@@ -382,12 +654,24 @@ export const registerCertificateRouter = async (server: FastifyZodProvider) => {
|
||||
certificateChain: z.string().trim(),
|
||||
privateKey: z.string().trim().optional(),
|
||||
serialNumber: z.string().trim(),
|
||||
certificateId: z.string()
|
||||
certificateId: z.string(),
|
||||
certificateRequestId: z.string()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const originalCertificate = await server.services.certificate.getCert({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
id: req.params.id
|
||||
});
|
||||
if (!originalCertificate) {
|
||||
throw new NotFoundError({ message: "Original certificate not found" });
|
||||
}
|
||||
|
||||
const data = await server.services.certificateV3.renewCertificate({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
|
||||
@@ -2,16 +2,12 @@ import { z } from "zod";
|
||||
|
||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||
import { ApiDocsTags } from "@app/lib/api-docs";
|
||||
import { NotFoundError } from "@app/lib/errors";
|
||||
import { ms } from "@app/lib/ms";
|
||||
import { writeLimit } from "@app/server/config/rateLimiter";
|
||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||
import { AuthMode } from "@app/services/auth/auth-type";
|
||||
import {
|
||||
ACMESANType,
|
||||
CertificateOrderStatus,
|
||||
CertKeyAlgorithm,
|
||||
CertSignatureAlgorithm
|
||||
} from "@app/services/certificate/certificate-types";
|
||||
import { CertKeyAlgorithm, CertSignatureAlgorithm } from "@app/services/certificate/certificate-types";
|
||||
import { validateCaDateField } from "@app/services/certificate-authority/certificate-authority-validators";
|
||||
import {
|
||||
CertExtendedKeyUsageType,
|
||||
@@ -21,6 +17,7 @@ import {
|
||||
import { extractCertificateRequestFromCSR } from "@app/services/certificate-common/certificate-csr-utils";
|
||||
import { mapEnumsForValidation } from "@app/services/certificate-common/certificate-utils";
|
||||
import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types";
|
||||
import { CertificateRequestStatus } from "@app/services/certificate-request/certificate-request-types";
|
||||
import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators";
|
||||
|
||||
import { booleanSchema } from "../sanitizedSchemas";
|
||||
@@ -65,8 +62,10 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
hide: false,
|
||||
hide: true,
|
||||
deprecated: true,
|
||||
tags: [ApiDocsTags.PkiCertificates],
|
||||
description: "This endpoint will be removed in a future version.",
|
||||
body: z
|
||||
.object({
|
||||
profileId: z.string().uuid(),
|
||||
@@ -106,7 +105,8 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
|
||||
certificateChain: z.string().trim(),
|
||||
privateKey: z.string().trim().optional(),
|
||||
serialNumber: z.string().trim(),
|
||||
certificateId: z.string()
|
||||
certificateId: z.string(),
|
||||
certificateRequestId: z.string()
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -138,6 +138,29 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
|
||||
removeRootsFromChain: req.body.removeRootsFromChain
|
||||
});
|
||||
|
||||
const certificateRequest = await server.services.certificateRequest.createCertificateRequest({
|
||||
status: CertificateRequestStatus.ISSUED,
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
projectId: data.projectId,
|
||||
profileId: req.body.profileId,
|
||||
commonName: req.body.commonName,
|
||||
altNames: req.body.altNames?.map((altName) => `${altName.type}:${altName.value}`).join(","),
|
||||
keyUsages: req.body.keyUsages,
|
||||
extendedKeyUsages: req.body.extendedKeyUsages,
|
||||
notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined,
|
||||
notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined,
|
||||
keyAlgorithm: req.body.keyAlgorithm,
|
||||
signatureAlgorithm: req.body.signatureAlgorithm
|
||||
});
|
||||
|
||||
await server.services.certificateRequest.attachCertificateToRequest({
|
||||
certificateRequestId: certificateRequest.id,
|
||||
certificateId: data.certificateId
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: data.projectId,
|
||||
@@ -152,7 +175,10 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
|
||||
}
|
||||
});
|
||||
|
||||
return data;
|
||||
return {
|
||||
...data,
|
||||
certificateRequestId: certificateRequest.id
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -163,8 +189,10 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
hide: false,
|
||||
hide: true,
|
||||
deprecated: true,
|
||||
tags: [ApiDocsTags.PkiCertificates],
|
||||
description: "This endpoint will be removed in a future version.",
|
||||
body: z
|
||||
.object({
|
||||
profileId: z.string().uuid(),
|
||||
@@ -191,14 +219,13 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
|
||||
issuingCaCertificate: z.string().trim(),
|
||||
certificateChain: z.string().trim(),
|
||||
serialNumber: z.string().trim(),
|
||||
certificateId: z.string()
|
||||
certificateId: z.string(),
|
||||
certificateRequestId: z.string()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const certificateRequest = extractCertificateRequestFromCSR(req.body.csr);
|
||||
|
||||
const data = await server.services.certificateV3.signCertificateFromProfile({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
@@ -215,6 +242,32 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
|
||||
removeRootsFromChain: req.body.removeRootsFromChain
|
||||
});
|
||||
|
||||
const certificateRequestData = extractCertificateRequestFromCSR(req.body.csr);
|
||||
|
||||
const certificateRequest = await server.services.certificateRequest.createCertificateRequest({
|
||||
actor: req.permission.type,
|
||||
status: CertificateRequestStatus.ISSUED,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
projectId: data.projectId,
|
||||
profileId: req.body.profileId,
|
||||
csr: req.body.csr,
|
||||
commonName: certificateRequestData.commonName,
|
||||
altNames: certificateRequestData.subjectAlternativeNames?.map((san) => `${san.type}:${san.value}`).join(","),
|
||||
keyUsages: certificateRequestData.keyUsages,
|
||||
extendedKeyUsages: certificateRequestData.extendedKeyUsages,
|
||||
notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined,
|
||||
notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined,
|
||||
keyAlgorithm: certificateRequestData.keyAlgorithm,
|
||||
signatureAlgorithm: certificateRequestData.signatureAlgorithm
|
||||
});
|
||||
|
||||
await server.services.certificateRequest.attachCertificateToRequest({
|
||||
certificateRequestId: certificateRequest.id,
|
||||
certificateId: data.certificateId
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: data.projectId,
|
||||
@@ -224,12 +277,15 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
|
||||
certificateProfileId: req.body.profileId,
|
||||
certificateId: data.certificateId,
|
||||
profileName: data.profileName,
|
||||
commonName: certificateRequest.commonName || ""
|
||||
commonName: certificateRequestData.commonName || ""
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return data;
|
||||
return {
|
||||
...data,
|
||||
certificateRequestId: certificateRequest.id
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -240,23 +296,23 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
hide: false,
|
||||
hide: true,
|
||||
deprecated: true,
|
||||
tags: [ApiDocsTags.PkiCertificates],
|
||||
description: "This endpoint will be removed in a future version.",
|
||||
body: z
|
||||
.object({
|
||||
profileId: z.string().uuid(),
|
||||
subjectAlternativeNames: z
|
||||
.array(
|
||||
z.object({
|
||||
type: z.nativeEnum(ACMESANType),
|
||||
value: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "SAN value cannot be empty")
|
||||
.max(255, "SAN value must be less than 255 characters")
|
||||
})
|
||||
)
|
||||
.min(1, "At least one subject alternative name must be provided"),
|
||||
subjectAlternativeNames: z.array(
|
||||
z.object({
|
||||
type: z.nativeEnum(CertSubjectAlternativeNameType),
|
||||
value: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "SAN value cannot be empty")
|
||||
.max(255, "SAN value must be less than 255 characters")
|
||||
})
|
||||
),
|
||||
ttl: z
|
||||
.string()
|
||||
.trim()
|
||||
@@ -280,62 +336,55 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
orderId: z.string(),
|
||||
status: z.nativeEnum(CertificateOrderStatus),
|
||||
subjectAlternativeNames: z.array(
|
||||
z.object({
|
||||
type: z.nativeEnum(ACMESANType),
|
||||
value: z.string(),
|
||||
status: z.nativeEnum(CertificateOrderStatus)
|
||||
})
|
||||
),
|
||||
authorizations: z.array(
|
||||
z.object({
|
||||
identifier: z.object({
|
||||
type: z.nativeEnum(ACMESANType),
|
||||
value: z.string()
|
||||
}),
|
||||
status: z.nativeEnum(CertificateOrderStatus),
|
||||
expires: z.string().optional(),
|
||||
challenges: z.array(
|
||||
z.object({
|
||||
type: z.string(),
|
||||
status: z.nativeEnum(CertificateOrderStatus),
|
||||
url: z.string(),
|
||||
token: z.string()
|
||||
})
|
||||
)
|
||||
})
|
||||
),
|
||||
finalize: z.string(),
|
||||
certificate: z.string().optional()
|
||||
certificate: z.string().optional(),
|
||||
certificateRequestId: z.string()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const certificateOrderObject = {
|
||||
altNames: req.body.subjectAlternativeNames,
|
||||
validity: {
|
||||
ttl: req.body.ttl
|
||||
},
|
||||
commonName: req.body.commonName,
|
||||
keyUsages: req.body.keyUsages,
|
||||
extendedKeyUsages: req.body.extendedKeyUsages,
|
||||
notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined,
|
||||
notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined,
|
||||
signatureAlgorithm: req.body.signatureAlgorithm,
|
||||
keyAlgorithm: req.body.keyAlgorithm
|
||||
};
|
||||
|
||||
const data = await server.services.certificateV3.orderCertificateFromProfile({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
profileId: req.body.profileId,
|
||||
certificateOrder: {
|
||||
altNames: req.body.subjectAlternativeNames,
|
||||
validity: {
|
||||
ttl: req.body.ttl
|
||||
},
|
||||
commonName: req.body.commonName,
|
||||
keyUsages: req.body.keyUsages,
|
||||
extendedKeyUsages: req.body.extendedKeyUsages,
|
||||
notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined,
|
||||
notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined,
|
||||
signatureAlgorithm: req.body.signatureAlgorithm,
|
||||
keyAlgorithm: req.body.keyAlgorithm
|
||||
},
|
||||
certificateOrder: certificateOrderObject,
|
||||
removeRootsFromChain: req.body.removeRootsFromChain
|
||||
});
|
||||
|
||||
const certificateRequest = await server.services.certificateRequest.createCertificateRequest({
|
||||
status: CertificateRequestStatus.PENDING,
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
projectId: data.projectId,
|
||||
profileId: req.body.profileId,
|
||||
commonName: req.body.commonName,
|
||||
altNames: req.body.subjectAlternativeNames?.map((san) => `${san.type}:${san.value}`).join(","),
|
||||
keyUsages: req.body.keyUsages,
|
||||
extendedKeyUsages: req.body.extendedKeyUsages,
|
||||
notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined,
|
||||
notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined,
|
||||
signatureAlgorithm: req.body.signatureAlgorithm,
|
||||
keyAlgorithm: req.body.keyAlgorithm
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: data.projectId,
|
||||
@@ -343,13 +392,15 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
|
||||
type: EventType.ORDER_CERTIFICATE_FROM_PROFILE,
|
||||
metadata: {
|
||||
certificateProfileId: req.body.profileId,
|
||||
orderId: data.orderId,
|
||||
profileName: data.profileName
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return data;
|
||||
return {
|
||||
...data,
|
||||
certificateRequestId: certificateRequest.id
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
@@ -377,12 +428,24 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
|
||||
certificateChain: z.string().trim(),
|
||||
privateKey: z.string().trim().optional(),
|
||||
serialNumber: z.string().trim(),
|
||||
certificateId: z.string()
|
||||
certificateId: z.string(),
|
||||
certificateRequestId: z.string()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const originalCertificate = await server.services.certificate.getCert({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
id: req.params.certificateId
|
||||
});
|
||||
if (!originalCertificate) {
|
||||
throw new NotFoundError({ message: "Original certificate not found" });
|
||||
}
|
||||
|
||||
const data = await server.services.certificateV3.renewCertificate({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as x509 from "@peculiar/x509";
|
||||
import acme, { CsrBuffer } from "acme-client";
|
||||
import { Knex } from "knex";
|
||||
import RE2 from "re2";
|
||||
|
||||
import { TableName } from "@app/db/schemas";
|
||||
import { getConfig } from "@app/lib/config/env";
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
CertKeyUsage,
|
||||
CertStatus
|
||||
} from "@app/services/certificate/certificate-types";
|
||||
import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal";
|
||||
import { TPkiSyncDALFactory } from "@app/services/pki-sync/pki-sync-dal";
|
||||
@@ -47,6 +49,60 @@ import { cloudflareDeleteTxtRecord, cloudflareInsertTxtRecord } from "./dns-prov
|
||||
import { dnsMadeEasyDeleteTxtRecord, dnsMadeEasyInsertTxtRecord } from "./dns-providers/dns-made-easy";
|
||||
import { route53DeleteTxtRecord, route53InsertTxtRecord } from "./dns-providers/route54";
|
||||
|
||||
const parseTtlToDays = (ttl: string): number => {
|
||||
const match = ttl.match(new RE2("^(\\d+)([dhm])$"));
|
||||
if (!match) {
|
||||
throw new BadRequestError({ message: `Invalid TTL format: ${ttl}` });
|
||||
}
|
||||
|
||||
const [, value, unit] = match;
|
||||
const num = parseInt(value, 10);
|
||||
|
||||
switch (unit) {
|
||||
case "d":
|
||||
return num;
|
||||
case "h":
|
||||
return Math.ceil(num / 24);
|
||||
case "m":
|
||||
return Math.ceil(num / (24 * 60));
|
||||
default:
|
||||
throw new BadRequestError({ message: `Invalid TTL unit: ${unit}` });
|
||||
}
|
||||
};
|
||||
|
||||
const calculateRenewalThreshold = (
|
||||
profileRenewBeforeDays: number | undefined,
|
||||
certificateTtlInDays: number
|
||||
): number | undefined => {
|
||||
if (profileRenewBeforeDays === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (profileRenewBeforeDays >= certificateTtlInDays) {
|
||||
return Math.max(1, certificateTtlInDays - 1);
|
||||
}
|
||||
|
||||
return profileRenewBeforeDays;
|
||||
};
|
||||
|
||||
const calculateFinalRenewBeforeDays = (
|
||||
profile: { apiConfig?: { autoRenew?: boolean; renewBeforeDays?: number } } | undefined,
|
||||
ttl: string
|
||||
): number | undefined => {
|
||||
if (!profile?.apiConfig?.autoRenew || !profile.apiConfig.renewBeforeDays) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const certificateTtlInDays = parseTtlToDays(ttl);
|
||||
const renewBeforeDays = calculateRenewalThreshold(profile.apiConfig.renewBeforeDays, certificateTtlInDays);
|
||||
|
||||
if (!renewBeforeDays) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return renewBeforeDays;
|
||||
};
|
||||
|
||||
type TAcmeCertificateAuthorityFnsDeps = {
|
||||
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById">;
|
||||
appConnectionService: Pick<TAppConnectionServiceFactory, "validateAppConnectionUsageById">;
|
||||
@@ -55,7 +111,7 @@ type TAcmeCertificateAuthorityFnsDeps = {
|
||||
"create" | "transaction" | "findByIdWithAssociatedCa" | "updateById" | "findWithAssociatedCa" | "findById"
|
||||
>;
|
||||
externalCertificateAuthorityDAL: Pick<TExternalCertificateAuthorityDALFactory, "create" | "update">;
|
||||
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction">;
|
||||
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction" | "updateById">;
|
||||
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "create">;
|
||||
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "create">;
|
||||
kmsService: Pick<
|
||||
@@ -66,13 +122,14 @@ type TAcmeCertificateAuthorityFnsDeps = {
|
||||
pkiSyncDAL: Pick<TPkiSyncDALFactory, "find">;
|
||||
pkiSyncQueue: Pick<TPkiSyncQueueFactory, "queuePkiSyncSyncCertificatesById">;
|
||||
projectDAL: Pick<TProjectDALFactory, "findById" | "findOne" | "updateById" | "transaction">;
|
||||
certificateProfileDAL?: Pick<TCertificateProfileDALFactory, "findById">;
|
||||
};
|
||||
|
||||
type TOrderCertificateDeps = {
|
||||
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById">;
|
||||
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa">;
|
||||
externalCertificateAuthorityDAL: Pick<TExternalCertificateAuthorityDALFactory, "update">;
|
||||
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction">;
|
||||
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction" | "updateById">;
|
||||
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "create">;
|
||||
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "create">;
|
||||
kmsService: Pick<
|
||||
@@ -80,6 +137,7 @@ type TOrderCertificateDeps = {
|
||||
"encryptWithKmsKey" | "generateKmsKey" | "createCipherPairWithDataKey" | "decryptWithKmsKey"
|
||||
>;
|
||||
projectDAL: Pick<TProjectDALFactory, "findById" | "findOne" | "updateById" | "transaction">;
|
||||
certificateProfileDAL?: Pick<TCertificateProfileDALFactory, "findById">;
|
||||
};
|
||||
|
||||
type DBConfigurationColumn = {
|
||||
@@ -93,7 +151,7 @@ type DBConfigurationColumn = {
|
||||
|
||||
export const castDbEntryToAcmeCertificateAuthority = (
|
||||
ca: Awaited<ReturnType<TCertificateAuthorityDALFactory["findByIdWithAssociatedCa"]>>
|
||||
): TAcmeCertificateAuthority & { credentials: unknown } => {
|
||||
): TAcmeCertificateAuthority & { credentials: Buffer | null | undefined } => {
|
||||
if (!ca.externalCa?.id) {
|
||||
throw new BadRequestError({ message: "Malformed ACME certificate authority" });
|
||||
}
|
||||
@@ -141,15 +199,22 @@ const getAcmeChallengeRecord = (
|
||||
export const orderCertificate = async (
|
||||
{
|
||||
caId,
|
||||
profileId,
|
||||
subscriberId,
|
||||
commonName,
|
||||
altNames,
|
||||
csr,
|
||||
csrPrivateKey,
|
||||
keyUsages,
|
||||
extendedKeyUsages
|
||||
extendedKeyUsages,
|
||||
ttl,
|
||||
signatureAlgorithm,
|
||||
keyAlgorithm,
|
||||
isRenewal,
|
||||
originalCertificateId
|
||||
}: {
|
||||
caId: string;
|
||||
profileId?: string;
|
||||
subscriberId?: string;
|
||||
commonName: string;
|
||||
altNames?: string[];
|
||||
@@ -157,6 +222,11 @@ export const orderCertificate = async (
|
||||
csrPrivateKey?: string;
|
||||
keyUsages?: CertKeyUsage[];
|
||||
extendedKeyUsages?: CertExtendedKeyUsage[];
|
||||
ttl?: string;
|
||||
signatureAlgorithm?: string;
|
||||
keyAlgorithm?: string;
|
||||
isRenewal?: boolean;
|
||||
originalCertificateId?: string;
|
||||
},
|
||||
deps: TOrderCertificateDeps,
|
||||
tx?: Knex
|
||||
@@ -169,7 +239,8 @@ export const orderCertificate = async (
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL,
|
||||
kmsService,
|
||||
projectDAL
|
||||
projectDAL,
|
||||
certificateProfileDAL
|
||||
} = deps;
|
||||
|
||||
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId, tx);
|
||||
@@ -199,7 +270,7 @@ export const orderCertificate = async (
|
||||
let accountKey: Buffer | undefined;
|
||||
if (acmeCa.credentials) {
|
||||
const decryptedCredentials = await kmsDecryptor({
|
||||
cipherTextBlob: acmeCa.credentials as Buffer
|
||||
cipherTextBlob: acmeCa.credentials
|
||||
});
|
||||
|
||||
const parsedCredentials = await AcmeCertificateAuthorityCredentialsSchema.parseAsync(
|
||||
@@ -364,6 +435,7 @@ export const orderCertificate = async (
|
||||
{
|
||||
caId: ca.id,
|
||||
pkiSubscriberId: subscriberId,
|
||||
profileId,
|
||||
status: CertStatus.ACTIVE,
|
||||
friendlyName: commonName,
|
||||
commonName,
|
||||
@@ -373,11 +445,18 @@ export const orderCertificate = async (
|
||||
notAfter: certObj.notAfter,
|
||||
keyUsages,
|
||||
extendedKeyUsages,
|
||||
projectId: ca.projectId
|
||||
keyAlgorithm,
|
||||
signatureAlgorithm,
|
||||
projectId: ca.projectId,
|
||||
renewedFromCertificateId: isRenewal && originalCertificateId ? originalCertificateId : null
|
||||
},
|
||||
innerTx
|
||||
);
|
||||
|
||||
if (isRenewal && originalCertificateId) {
|
||||
await certificateDAL.updateById(originalCertificateId, { renewedByCertificateId: cert.id }, innerTx);
|
||||
}
|
||||
|
||||
await certificateBodyDAL.create(
|
||||
{
|
||||
certId: cert.id,
|
||||
@@ -397,6 +476,26 @@ export const orderCertificate = async (
|
||||
);
|
||||
}
|
||||
|
||||
if (profileId && ttl && certificateProfileDAL) {
|
||||
const profile = await certificateProfileDAL.findById(profileId, innerTx);
|
||||
if (profile) {
|
||||
const finalRenewBeforeDays = calculateFinalRenewBeforeDays(
|
||||
profile as { apiConfig?: { autoRenew?: boolean; renewBeforeDays?: number } },
|
||||
ttl
|
||||
);
|
||||
|
||||
if (finalRenewBeforeDays !== undefined) {
|
||||
await certificateDAL.updateById(
|
||||
cert.id,
|
||||
{
|
||||
renewBeforeDays: finalRenewBeforeDays
|
||||
},
|
||||
innerTx
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return cert;
|
||||
});
|
||||
};
|
||||
@@ -413,7 +512,8 @@ export const AcmeCertificateAuthorityFns = ({
|
||||
projectDAL,
|
||||
pkiSubscriberDAL,
|
||||
pkiSyncDAL,
|
||||
pkiSyncQueue
|
||||
pkiSyncQueue,
|
||||
certificateProfileDAL
|
||||
}: TAcmeCertificateAuthorityFnsDeps) => {
|
||||
const createCertificateAuthority = async ({
|
||||
name,
|
||||
@@ -668,10 +768,71 @@ export const AcmeCertificateAuthorityFns = ({
|
||||
await triggerAutoSyncForSubscriber(subscriber.id, { pkiSyncDAL, pkiSyncQueue });
|
||||
};
|
||||
|
||||
const orderCertificateFromProfile = async ({
|
||||
caId,
|
||||
profileId,
|
||||
commonName,
|
||||
altNames = [],
|
||||
csr,
|
||||
csrPrivateKey,
|
||||
keyUsages,
|
||||
extendedKeyUsages,
|
||||
ttl,
|
||||
signatureAlgorithm,
|
||||
keyAlgorithm,
|
||||
isRenewal,
|
||||
originalCertificateId
|
||||
}: {
|
||||
caId: string;
|
||||
profileId?: string;
|
||||
commonName: string;
|
||||
altNames?: string[];
|
||||
csr: CsrBuffer;
|
||||
csrPrivateKey: string;
|
||||
keyUsages?: CertKeyUsage[];
|
||||
extendedKeyUsages?: CertExtendedKeyUsage[];
|
||||
ttl?: string;
|
||||
signatureAlgorithm?: string;
|
||||
keyAlgorithm?: string;
|
||||
isRenewal?: boolean;
|
||||
originalCertificateId?: string;
|
||||
}) => {
|
||||
return orderCertificate(
|
||||
{
|
||||
caId,
|
||||
profileId,
|
||||
subscriberId: undefined,
|
||||
commonName,
|
||||
altNames,
|
||||
csr,
|
||||
csrPrivateKey,
|
||||
keyUsages,
|
||||
extendedKeyUsages,
|
||||
ttl,
|
||||
signatureAlgorithm,
|
||||
keyAlgorithm,
|
||||
isRenewal,
|
||||
originalCertificateId
|
||||
},
|
||||
{
|
||||
appConnectionDAL,
|
||||
certificateAuthorityDAL,
|
||||
externalCertificateAuthorityDAL,
|
||||
certificateDAL,
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL,
|
||||
kmsService,
|
||||
projectDAL,
|
||||
certificateProfileDAL
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
createCertificateAuthority,
|
||||
updateCertificateAuthority,
|
||||
listCertificateAuthorities,
|
||||
orderSubscriberCertificate
|
||||
orderSubscriberCertificate,
|
||||
orderCertificateFromProfile
|
||||
};
|
||||
};
|
||||
|
||||
+441
-4
@@ -21,8 +21,10 @@ import {
|
||||
CertExtendedKeyUsage,
|
||||
CertKeyAlgorithm,
|
||||
CertKeyUsage,
|
||||
CertStatus
|
||||
CertStatus,
|
||||
TAltNameType
|
||||
} from "@app/services/certificate/certificate-types";
|
||||
import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal";
|
||||
import { TPkiSubscriberProperties } from "@app/services/pki-subscriber/pki-subscriber-types";
|
||||
@@ -42,6 +44,60 @@ import {
|
||||
TUpdateAzureAdCsCertificateAuthorityDTO
|
||||
} from "./azure-ad-cs-certificate-authority-types";
|
||||
|
||||
const parseTtlToDays = (ttl: string): number => {
|
||||
const match = ttl.match(new RE2("^(\\d+)([dhm])$"));
|
||||
if (!match) {
|
||||
throw new BadRequestError({ message: `Invalid TTL format: ${ttl}` });
|
||||
}
|
||||
|
||||
const [, value, unit] = match;
|
||||
const num = parseInt(value, 10);
|
||||
|
||||
switch (unit) {
|
||||
case "d":
|
||||
return num;
|
||||
case "h":
|
||||
return Math.ceil(num / 24);
|
||||
case "m":
|
||||
return Math.ceil(num / (24 * 60));
|
||||
default:
|
||||
throw new BadRequestError({ message: `Invalid TTL unit: ${unit}` });
|
||||
}
|
||||
};
|
||||
|
||||
const calculateRenewalThreshold = (
|
||||
profileRenewBeforeDays: number | undefined,
|
||||
certificateTtlInDays: number
|
||||
): number | undefined => {
|
||||
if (profileRenewBeforeDays === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (profileRenewBeforeDays >= certificateTtlInDays) {
|
||||
return Math.max(1, certificateTtlInDays - 1);
|
||||
}
|
||||
|
||||
return profileRenewBeforeDays;
|
||||
};
|
||||
|
||||
const calculateFinalRenewBeforeDays = (
|
||||
profile: { apiConfig?: { autoRenew?: boolean; renewBeforeDays?: number } } | undefined,
|
||||
ttl: string
|
||||
): number | undefined => {
|
||||
const hasAutoRenewEnabled = profile?.apiConfig?.autoRenew === true;
|
||||
if (!hasAutoRenewEnabled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const profileRenewBeforeDays = profile?.apiConfig?.renewBeforeDays;
|
||||
if (profileRenewBeforeDays !== undefined) {
|
||||
const certificateTtlInDays = parseTtlToDays(ttl);
|
||||
return calculateRenewalThreshold(profileRenewBeforeDays, certificateTtlInDays);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
type TAzureAdCsCertificateAuthorityFnsDeps = {
|
||||
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "updateById">;
|
||||
appConnectionService: Pick<TAppConnectionServiceFactory, "validateAppConnectionUsageById">;
|
||||
@@ -50,7 +106,7 @@ type TAzureAdCsCertificateAuthorityFnsDeps = {
|
||||
"create" | "transaction" | "findByIdWithAssociatedCa" | "updateById" | "findWithAssociatedCa" | "findById"
|
||||
>;
|
||||
externalCertificateAuthorityDAL: Pick<TExternalCertificateAuthorityDALFactory, "create" | "update">;
|
||||
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction">;
|
||||
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction" | "updateById">;
|
||||
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "create">;
|
||||
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "create">;
|
||||
kmsService: Pick<
|
||||
@@ -61,6 +117,7 @@ type TAzureAdCsCertificateAuthorityFnsDeps = {
|
||||
pkiSyncDAL: Pick<TPkiSyncDALFactory, "find">;
|
||||
pkiSyncQueue: Pick<TPkiSyncQueueFactory, "queuePkiSyncSyncCertificatesById">;
|
||||
projectDAL: Pick<TProjectDALFactory, "findById" | "findOne" | "updateById" | "transaction">;
|
||||
certificateProfileDAL?: Pick<TCertificateProfileDALFactory, "findById">;
|
||||
};
|
||||
|
||||
type AzureCertificateRequest = {
|
||||
@@ -190,7 +247,7 @@ const buildSubjectDN = (commonName: string, properties?: TPkiSubscriberPropertie
|
||||
|
||||
export const castDbEntryToAzureAdCsCertificateAuthority = (
|
||||
ca: Awaited<ReturnType<TCertificateAuthorityDALFactory["findByIdWithAssociatedCa"]>>
|
||||
): TAzureAdCsCertificateAuthority & { credentials: unknown } => {
|
||||
): TAzureAdCsCertificateAuthority & { credentials: Buffer | null | undefined } => {
|
||||
if (!ca.externalCa?.id) {
|
||||
throw new BadRequestError({ message: "Malformed Active Directory Certificate Service certificate authority" });
|
||||
}
|
||||
@@ -591,7 +648,8 @@ export const AzureAdCsCertificateAuthorityFns = ({
|
||||
projectDAL,
|
||||
pkiSubscriberDAL,
|
||||
pkiSyncDAL,
|
||||
pkiSyncQueue
|
||||
pkiSyncQueue,
|
||||
certificateProfileDAL
|
||||
}: TAzureAdCsCertificateAuthorityFnsDeps) => {
|
||||
const createCertificateAuthority = async ({
|
||||
name,
|
||||
@@ -1024,6 +1082,384 @@ export const AzureAdCsCertificateAuthorityFns = ({
|
||||
};
|
||||
};
|
||||
|
||||
const orderCertificateFromProfile = async ({
|
||||
caId,
|
||||
profileId,
|
||||
commonName,
|
||||
altNames = [],
|
||||
keyUsages = [],
|
||||
extendedKeyUsages = [],
|
||||
template,
|
||||
validity,
|
||||
notBefore,
|
||||
notAfter,
|
||||
signatureAlgorithm,
|
||||
keyAlgorithm = CertKeyAlgorithm.RSA_2048,
|
||||
isRenewal,
|
||||
originalCertificateId
|
||||
}: {
|
||||
caId: string;
|
||||
profileId: string;
|
||||
commonName: string;
|
||||
altNames?: string[];
|
||||
keyUsages?: CertKeyUsage[];
|
||||
extendedKeyUsages?: CertExtendedKeyUsage[];
|
||||
template?: string;
|
||||
validity: { ttl: string };
|
||||
notBefore?: Date;
|
||||
notAfter?: Date;
|
||||
signatureAlgorithm?: string;
|
||||
keyAlgorithm?: CertKeyAlgorithm;
|
||||
isRenewal?: boolean;
|
||||
originalCertificateId?: string;
|
||||
}) => {
|
||||
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId);
|
||||
if (!ca.externalCa || ca.externalCa.type !== CaType.AZURE_AD_CS) {
|
||||
throw new BadRequestError({ message: "CA is not an Active Directory Certificate Service CA" });
|
||||
}
|
||||
|
||||
const azureCa = castDbEntryToAzureAdCsCertificateAuthority(ca);
|
||||
if (azureCa.status !== CaStatus.ACTIVE) {
|
||||
throw new BadRequestError({ message: "CA is disabled" });
|
||||
}
|
||||
|
||||
const certificateManagerKmsId = await getProjectKmsCertificateKeyId({
|
||||
projectId: ca.projectId,
|
||||
projectDAL,
|
||||
kmsService
|
||||
});
|
||||
|
||||
const kmsEncryptor = await kmsService.encryptWithKmsKey({
|
||||
kmsId: certificateManagerKmsId
|
||||
});
|
||||
|
||||
const { username, password, adcsUrl, sslRejectUnauthorized, sslCertificate } =
|
||||
await getAzureADCSConnectionCredentials(
|
||||
azureCa.configuration.azureAdcsConnectionId,
|
||||
appConnectionDAL,
|
||||
kmsService
|
||||
);
|
||||
|
||||
const credentials: {
|
||||
username: string;
|
||||
password: string;
|
||||
sslRejectUnauthorized?: boolean;
|
||||
sslCertificate?: string;
|
||||
} = {
|
||||
username,
|
||||
password,
|
||||
sslRejectUnauthorized,
|
||||
sslCertificate
|
||||
};
|
||||
|
||||
let alg;
|
||||
if (signatureAlgorithm) {
|
||||
switch (signatureAlgorithm.toUpperCase()) {
|
||||
case "RSA-SHA256":
|
||||
case "SHA256WITHRSA":
|
||||
alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_2048);
|
||||
break;
|
||||
case "RSA-SHA384":
|
||||
case "SHA384WITHRSA":
|
||||
alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_3072);
|
||||
break;
|
||||
case "RSA-SHA512":
|
||||
case "SHA512WITHRSA":
|
||||
alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.RSA_4096);
|
||||
break;
|
||||
case "ECDSA-SHA256":
|
||||
case "SHA256WITHECDSA":
|
||||
alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.ECDSA_P256);
|
||||
break;
|
||||
case "ECDSA-SHA384":
|
||||
case "SHA384WITHECDSA":
|
||||
alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.ECDSA_P384);
|
||||
break;
|
||||
case "ECDSA-SHA512":
|
||||
case "SHA512WITHECDSA":
|
||||
alg = keyAlgorithmToAlgCfg(CertKeyAlgorithm.ECDSA_P521);
|
||||
break;
|
||||
default:
|
||||
alg = keyAlgorithmToAlgCfg(keyAlgorithm);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
alg = keyAlgorithmToAlgCfg(keyAlgorithm);
|
||||
}
|
||||
|
||||
const leafKeys = await crypto.nativeCrypto.subtle.generateKey(alg, true, ["sign", "verify"]);
|
||||
const skLeafObj = crypto.nativeCrypto.KeyObject.from(leafKeys.privateKey);
|
||||
const skLeaf = skLeafObj.export({ format: "pem", type: "pkcs8" }) as string;
|
||||
|
||||
const subjectDN = buildSubjectDN(commonName);
|
||||
|
||||
let sanExtension = "";
|
||||
if (altNames && altNames.length > 0) {
|
||||
sanExtension = altNames.join(",");
|
||||
}
|
||||
|
||||
const csrObj = await x509.Pkcs10CertificateRequestGenerator.create({
|
||||
name: subjectDN,
|
||||
keys: leafKeys,
|
||||
signingAlgorithm: alg,
|
||||
...(sanExtension && {
|
||||
extensions: [
|
||||
new x509.SubjectAlternativeNameExtension(
|
||||
altNames.map((name) => ({ type: "dns" as TAltNameType, value: name })),
|
||||
false
|
||||
)
|
||||
]
|
||||
})
|
||||
});
|
||||
|
||||
const csrPem = csrObj.toString("pem");
|
||||
|
||||
let templateValue = template;
|
||||
if (!templateValue) {
|
||||
templateValue = "WebServer";
|
||||
}
|
||||
|
||||
const templateInput = templateValue.trim();
|
||||
if (!templateInput || templateInput.length === 0) {
|
||||
throw new BadRequestError({
|
||||
message: "Certificate template name cannot be empty"
|
||||
});
|
||||
}
|
||||
|
||||
let validityPeriod: string | undefined;
|
||||
if (notBefore && notAfter) {
|
||||
if (notAfter <= notBefore) {
|
||||
throw new BadRequestError({
|
||||
message: "Certificate notAfter date must be after notBefore date"
|
||||
});
|
||||
}
|
||||
|
||||
const diffMs = notAfter.getTime() - notBefore.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
validityPeriod = `${diffDays}d`;
|
||||
} else if (notAfter) {
|
||||
const diffMs = notAfter.getTime() - Date.now();
|
||||
if (diffMs <= 0) {
|
||||
throw new BadRequestError({
|
||||
message: "Certificate notAfter date must be in the future"
|
||||
});
|
||||
}
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
validityPeriod = `${diffDays}d`;
|
||||
} else if (validity.ttl) {
|
||||
validityPeriod = validity.ttl;
|
||||
}
|
||||
|
||||
const certificateRequest: AzureCertificateRequest = {
|
||||
csr: csrPem,
|
||||
template: templateInput,
|
||||
attributes: {
|
||||
subject: subjectDN,
|
||||
...(sanExtension && { subjectAlternativeName: sanExtension }),
|
||||
...(validityPeriod && { validityPeriod })
|
||||
}
|
||||
};
|
||||
|
||||
let submissionResponse;
|
||||
const maxOidRetries = 3;
|
||||
let oidRetryCount = 0;
|
||||
|
||||
while (oidRetryCount <= maxOidRetries) {
|
||||
try {
|
||||
submissionResponse = await submitCertificateRequest(credentials, adcsUrl, certificateRequest);
|
||||
break;
|
||||
} catch (error) {
|
||||
const isOidError =
|
||||
error instanceof BadRequestError &&
|
||||
(error.message.includes("OID resolution error") || error.message.includes("Cannot get OID for name type"));
|
||||
|
||||
if (isOidError && oidRetryCount < maxOidRetries) {
|
||||
oidRetryCount += 1;
|
||||
|
||||
const delay = 3000 * oidRetryCount;
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, delay);
|
||||
});
|
||||
// eslint-disable-next-line no-continue
|
||||
continue;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (!submissionResponse) {
|
||||
throw new BadRequestError({
|
||||
message: "Failed to submit certificate request after multiple attempts due to OID resolution issues"
|
||||
});
|
||||
}
|
||||
|
||||
if (submissionResponse.status === "denied") {
|
||||
throw new BadRequestError({ message: "Certificate request was denied by ADCS" });
|
||||
}
|
||||
|
||||
let certificatePem = "";
|
||||
|
||||
if (submissionResponse.status === "issued" && submissionResponse.certificate) {
|
||||
certificatePem = submissionResponse.certificate;
|
||||
} else {
|
||||
const maxRetries = 5;
|
||||
const initialDelay = 2000;
|
||||
let retryCount = 0;
|
||||
let lastError: Error | null = null;
|
||||
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
while (retryCount < maxRetries) {
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
certificatePem = await retrieveCertificate(credentials, adcsUrl, submissionResponse.certificateId);
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
// eslint-disable-next-line no-plusplus
|
||||
retryCount++;
|
||||
|
||||
if (retryCount < maxRetries) {
|
||||
// Wait with exponential backoff: 2s, 4s, 8s, 16s, 32s
|
||||
const delay = initialDelay * 2 ** (retryCount - 1);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, delay);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (retryCount === maxRetries) {
|
||||
throw new BadRequestError({
|
||||
message: `Certificate request submitted with ID ${submissionResponse.certificateId} but failed to retrieve after ${maxRetries} attempts. The certificate may still be pending approval or processing. Last error: ${lastError?.message || "Unknown error"}.`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!certificatePem) {
|
||||
throw new BadRequestError({
|
||||
message: "Failed to obtain certificate from ADCS. The certificate may still be pending processing."
|
||||
});
|
||||
}
|
||||
|
||||
let cleanedCertificatePem = certificatePem.trim();
|
||||
|
||||
if (!cleanedCertificatePem.includes("-----BEGIN CERTIFICATE-----")) {
|
||||
throw new BadRequestError({
|
||||
message: "Invalid certificate format received from ADCS. Expected PEM format."
|
||||
});
|
||||
}
|
||||
|
||||
cleanedCertificatePem = cleanedCertificatePem
|
||||
.replace(new RE2("\\r\\n", "g"), "\n")
|
||||
.replace(new RE2("\\r", "g"), "\n")
|
||||
.trim();
|
||||
|
||||
if (!cleanedCertificatePem.includes("-----END CERTIFICATE-----")) {
|
||||
throw new BadRequestError({
|
||||
message: "Invalid certificate format received from ADCS. Missing end marker."
|
||||
});
|
||||
}
|
||||
|
||||
let certObj: x509.X509Certificate;
|
||||
try {
|
||||
certObj = new x509.X509Certificate(cleanedCertificatePem);
|
||||
} catch (error) {
|
||||
throw new BadRequestError({
|
||||
message: `Failed to parse certificate from ADCS: ${error instanceof Error ? error.message : "Unknown error"}. Certificate data may be corrupted.`
|
||||
});
|
||||
}
|
||||
|
||||
const { cipherTextBlob: encryptedCertificate } = await kmsEncryptor({
|
||||
plainText: Buffer.from(new Uint8Array(certObj.rawData))
|
||||
});
|
||||
|
||||
const certificateChainPem = submissionResponse.certificateChain || "";
|
||||
|
||||
const { cipherTextBlob: encryptedCertificateChain } = await kmsEncryptor({
|
||||
plainText: Buffer.from(certificateChainPem)
|
||||
});
|
||||
|
||||
const { cipherTextBlob: encryptedPrivateKey } = await kmsEncryptor({
|
||||
plainText: Buffer.from(skLeaf)
|
||||
});
|
||||
|
||||
let certificateId: string;
|
||||
|
||||
await certificateDAL.transaction(async (tx) => {
|
||||
const cert = await certificateDAL.create(
|
||||
{
|
||||
caId: ca.id,
|
||||
profileId,
|
||||
status: CertStatus.ACTIVE,
|
||||
friendlyName: commonName,
|
||||
commonName,
|
||||
altNames: altNames.join(","),
|
||||
serialNumber: certObj.serialNumber,
|
||||
notBefore: certObj.notBefore,
|
||||
notAfter: certObj.notAfter,
|
||||
keyUsages,
|
||||
extendedKeyUsages,
|
||||
keyAlgorithm,
|
||||
signatureAlgorithm,
|
||||
projectId: ca.projectId,
|
||||
renewedFromCertificateId: isRenewal && originalCertificateId ? originalCertificateId : null
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
certificateId = cert.id;
|
||||
|
||||
if (isRenewal && originalCertificateId) {
|
||||
await certificateDAL.updateById(originalCertificateId, { renewedByCertificateId: cert.id }, tx);
|
||||
}
|
||||
|
||||
await certificateBodyDAL.create(
|
||||
{
|
||||
certId: cert.id,
|
||||
encryptedCertificate,
|
||||
encryptedCertificateChain
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
await certificateSecretDAL.create(
|
||||
{
|
||||
certId: cert.id,
|
||||
encryptedPrivateKey
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
if (profileId && validity?.ttl && certificateProfileDAL) {
|
||||
const profile = await certificateProfileDAL.findById(profileId, tx);
|
||||
if (profile) {
|
||||
const finalRenewBeforeDays = calculateFinalRenewBeforeDays(undefined, validity.ttl);
|
||||
|
||||
if (finalRenewBeforeDays !== undefined) {
|
||||
await certificateDAL.updateById(
|
||||
cert.id,
|
||||
{
|
||||
renewBeforeDays: finalRenewBeforeDays
|
||||
},
|
||||
tx
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
certificate: cleanedCertificatePem,
|
||||
certificateChain: certificateChainPem,
|
||||
privateKey: skLeaf,
|
||||
serialNumber: certObj.serialNumber,
|
||||
certificateId: certificateId!,
|
||||
ca: azureCa
|
||||
};
|
||||
};
|
||||
|
||||
const getTemplates = async ({ caId, projectId }: { caId: string; projectId: string }) => {
|
||||
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId);
|
||||
if (!ca || ca.projectId !== projectId) {
|
||||
@@ -1163,6 +1599,7 @@ export const AzureAdCsCertificateAuthorityFns = ({
|
||||
updateCertificateAuthority,
|
||||
listCertificateAuthorities,
|
||||
orderSubscriberCertificate,
|
||||
orderCertificateFromProfile,
|
||||
getTemplates
|
||||
};
|
||||
};
|
||||
|
||||
+7
@@ -11,6 +11,13 @@ export const AzureAdCsCertificateAuthorityConfigurationSchema = z.object({
|
||||
azureAdcsConnectionId: z.string().uuid().trim().describe("Azure ADCS Connection ID")
|
||||
});
|
||||
|
||||
export const AzureAdCsCertificateAuthorityCredentialsSchema = z.object({
|
||||
username: z.string(),
|
||||
password: z.string(),
|
||||
sslRejectUnauthorized: z.boolean().optional(),
|
||||
sslCertificate: z.string().optional()
|
||||
});
|
||||
|
||||
export const AzureAdCsCertificateAuthoritySchema = BaseCertificateAuthoritySchema.extend({
|
||||
type: z.literal(CaType.AZURE_AD_CS),
|
||||
configuration: AzureAdCsCertificateAuthorityConfigurationSchema
|
||||
|
||||
@@ -14,6 +14,7 @@ import { TAppConnectionServiceFactory } from "../app-connection/app-connection-s
|
||||
import { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal";
|
||||
import { TCertificateDALFactory } from "../certificate/certificate-dal";
|
||||
import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal";
|
||||
import { TCertificateProfileDALFactory } from "../certificate-profile/certificate-profile-dal";
|
||||
import { TKmsServiceFactory } from "../kms/kms-service";
|
||||
import { TPkiSubscriberDALFactory } from "../pki-subscriber/pki-subscriber-dal";
|
||||
import { TPkiSyncDALFactory } from "../pki-sync/pki-sync-dal";
|
||||
@@ -66,7 +67,7 @@ type TCertificateAuthorityServiceFactoryDep = {
|
||||
internalCertificateAuthorityService: TInternalCertificateAuthorityServiceFactory;
|
||||
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug" | "findOne" | "updateById" | "findById" | "transaction">;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction">;
|
||||
certificateDAL: Pick<TCertificateDALFactory, "create" | "transaction" | "updateById">;
|
||||
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "create">;
|
||||
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "create">;
|
||||
kmsService: Pick<
|
||||
@@ -76,6 +77,7 @@ type TCertificateAuthorityServiceFactoryDep = {
|
||||
pkiSubscriberDAL: Pick<TPkiSubscriberDALFactory, "findById">;
|
||||
pkiSyncDAL: Pick<TPkiSyncDALFactory, "find">;
|
||||
pkiSyncQueue: Pick<TPkiSyncQueueFactory, "queuePkiSyncSyncCertificatesById">;
|
||||
certificateProfileDAL?: Pick<TCertificateProfileDALFactory, "findById">;
|
||||
};
|
||||
|
||||
export type TCertificateAuthorityServiceFactory = ReturnType<typeof certificateAuthorityServiceFactory>;
|
||||
@@ -94,7 +96,8 @@ export const certificateAuthorityServiceFactory = ({
|
||||
kmsService,
|
||||
pkiSubscriberDAL,
|
||||
pkiSyncDAL,
|
||||
pkiSyncQueue
|
||||
pkiSyncQueue,
|
||||
certificateProfileDAL
|
||||
}: TCertificateAuthorityServiceFactoryDep) => {
|
||||
const acmeFns = AcmeCertificateAuthorityFns({
|
||||
appConnectionDAL,
|
||||
@@ -108,7 +111,8 @@ export const certificateAuthorityServiceFactory = ({
|
||||
pkiSubscriberDAL,
|
||||
projectDAL,
|
||||
pkiSyncDAL,
|
||||
pkiSyncQueue
|
||||
pkiSyncQueue,
|
||||
certificateProfileDAL
|
||||
});
|
||||
|
||||
const azureAdCsFns = AzureAdCsCertificateAuthorityFns({
|
||||
@@ -123,7 +127,8 @@ export const certificateAuthorityServiceFactory = ({
|
||||
pkiSubscriberDAL,
|
||||
projectDAL,
|
||||
pkiSyncDAL,
|
||||
pkiSyncQueue
|
||||
pkiSyncQueue,
|
||||
certificateProfileDAL
|
||||
});
|
||||
|
||||
const createCertificateAuthority = async (
|
||||
@@ -690,6 +695,41 @@ export const certificateAuthorityServiceFactory = ({
|
||||
});
|
||||
};
|
||||
|
||||
const getCaById = async ({
|
||||
caId,
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
}: {
|
||||
caId: string;
|
||||
actor: OrgServiceActor["type"];
|
||||
actorId: string;
|
||||
actorAuthMethod: OrgServiceActor["authMethod"];
|
||||
actorOrgId?: string;
|
||||
}) => {
|
||||
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId);
|
||||
if (!ca) {
|
||||
throw new NotFoundError({ message: "CA not found" });
|
||||
}
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
projectId: ca.projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.CertificateManager
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Read,
|
||||
ProjectPermissionSub.CertificateAuthorities
|
||||
);
|
||||
|
||||
return ca;
|
||||
};
|
||||
|
||||
return {
|
||||
createCertificateAuthority,
|
||||
findCertificateAuthorityById,
|
||||
@@ -698,6 +738,7 @@ export const certificateAuthorityServiceFactory = ({
|
||||
updateCertificateAuthority,
|
||||
deleteCertificateAuthority,
|
||||
getAzureAdcsTemplates,
|
||||
getCaById,
|
||||
deprecatedUpdateCertificateAuthority,
|
||||
deprecatedDeleteCertificateAuthority
|
||||
};
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
import acme from "acme-client";
|
||||
|
||||
import { crypto } from "@app/lib/crypto/cryptography";
|
||||
import { NotFoundError } from "@app/lib/errors";
|
||||
import { logger } from "@app/lib/logger";
|
||||
import { QueueJobs, TQueueServiceFactory } from "@app/queue";
|
||||
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
|
||||
import { CertExtendedKeyUsage, CertKeyUsage } from "@app/services/certificate/certificate-types";
|
||||
import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal";
|
||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
||||
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||
|
||||
import { TAppConnectionDALFactory } from "../app-connection/app-connection-dal";
|
||||
import { TAppConnectionServiceFactory } from "../app-connection/app-connection-service";
|
||||
import { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal";
|
||||
import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal";
|
||||
import { CertKeyAlgorithm } from "../certificate-common/certificate-constants";
|
||||
import { TCertificateRequestServiceFactory } from "../certificate-request/certificate-request-service";
|
||||
import { CertificateRequestStatus } from "../certificate-request/certificate-request-types";
|
||||
import { TPkiSubscriberDALFactory } from "../pki-subscriber/pki-subscriber-dal";
|
||||
import { TPkiSyncDALFactory } from "../pki-sync/pki-sync-dal";
|
||||
import { TPkiSyncQueueFactory } from "../pki-sync/pki-sync-queue";
|
||||
import { AcmeCertificateAuthorityFns } from "./acme/acme-certificate-authority-fns";
|
||||
import { AzureAdCsCertificateAuthorityFns } from "./azure-ad-cs/azure-ad-cs-certificate-authority-fns";
|
||||
import { TCertificateAuthorityDALFactory } from "./certificate-authority-dal";
|
||||
import { CaType } from "./certificate-authority-enums";
|
||||
import { keyAlgorithmToAlgCfg } from "./certificate-authority-fns";
|
||||
import { TExternalCertificateAuthorityDALFactory } from "./external-certificate-authority-dal";
|
||||
|
||||
export type TIssueCertificateFromProfileJobData = {
|
||||
certificateId: string;
|
||||
profileId: string;
|
||||
caId: string;
|
||||
commonName?: string;
|
||||
altNames?: string[];
|
||||
ttl: string;
|
||||
signatureAlgorithm: string;
|
||||
keyAlgorithm: string;
|
||||
keyUsages?: string[];
|
||||
extendedKeyUsages?: string[];
|
||||
isRenewal?: boolean;
|
||||
originalCertificateId?: string;
|
||||
certificateRequestId?: string;
|
||||
csr?: string;
|
||||
};
|
||||
|
||||
type TCertificateIssuanceQueueFactoryDep = {
|
||||
certificateAuthorityDAL: TCertificateAuthorityDALFactory;
|
||||
appConnectionDAL: Pick<TAppConnectionDALFactory, "findById" | "update" | "updateById">;
|
||||
appConnectionService: Pick<TAppConnectionServiceFactory, "validateAppConnectionUsageById">;
|
||||
externalCertificateAuthorityDAL: Pick<TExternalCertificateAuthorityDALFactory, "create" | "update">;
|
||||
certificateDAL: TCertificateDALFactory;
|
||||
projectDAL: Pick<TProjectDALFactory, "findProjectBySlug" | "findOne" | "updateById" | "findById" | "transaction">;
|
||||
kmsService: Pick<
|
||||
TKmsServiceFactory,
|
||||
"generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey" | "createCipherPairWithDataKey"
|
||||
>;
|
||||
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "create">;
|
||||
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "create">;
|
||||
queueService: TQueueServiceFactory;
|
||||
pkiSubscriberDAL: Pick<TPkiSubscriberDALFactory, "findById" | "updateById">;
|
||||
pkiSyncDAL: Pick<TPkiSyncDALFactory, "find">;
|
||||
pkiSyncQueue: Pick<TPkiSyncQueueFactory, "queuePkiSyncSyncCertificatesById">;
|
||||
certificateProfileDAL?: Pick<TCertificateProfileDALFactory, "findById">;
|
||||
certificateRequestService?: Pick<
|
||||
TCertificateRequestServiceFactory,
|
||||
"attachCertificateToRequest" | "updateCertificateRequestStatus"
|
||||
>;
|
||||
};
|
||||
|
||||
export type TCertificateIssuanceQueueFactory = ReturnType<typeof certificateIssuanceQueueFactory>;
|
||||
|
||||
export const certificateIssuanceQueueFactory = ({
|
||||
certificateAuthorityDAL,
|
||||
appConnectionDAL,
|
||||
appConnectionService,
|
||||
externalCertificateAuthorityDAL,
|
||||
certificateDAL,
|
||||
projectDAL,
|
||||
kmsService,
|
||||
queueService,
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL,
|
||||
pkiSubscriberDAL,
|
||||
pkiSyncDAL,
|
||||
pkiSyncQueue,
|
||||
certificateProfileDAL,
|
||||
certificateRequestService
|
||||
}: TCertificateIssuanceQueueFactoryDep) => {
|
||||
const acmeFns = AcmeCertificateAuthorityFns({
|
||||
appConnectionDAL,
|
||||
appConnectionService,
|
||||
certificateAuthorityDAL,
|
||||
externalCertificateAuthorityDAL,
|
||||
certificateDAL,
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL,
|
||||
kmsService,
|
||||
pkiSubscriberDAL,
|
||||
projectDAL,
|
||||
pkiSyncDAL,
|
||||
pkiSyncQueue,
|
||||
certificateProfileDAL
|
||||
});
|
||||
|
||||
const azureAdCsFns = AzureAdCsCertificateAuthorityFns({
|
||||
appConnectionDAL,
|
||||
appConnectionService,
|
||||
certificateAuthorityDAL,
|
||||
externalCertificateAuthorityDAL,
|
||||
certificateDAL,
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL,
|
||||
kmsService,
|
||||
pkiSubscriberDAL,
|
||||
projectDAL,
|
||||
pkiSyncDAL,
|
||||
pkiSyncQueue,
|
||||
certificateProfileDAL
|
||||
});
|
||||
|
||||
/**
|
||||
* Queue a certificate issuance job using pgBoss
|
||||
*/
|
||||
const queueCertificateIssuance = async ({
|
||||
certificateId,
|
||||
profileId,
|
||||
caId,
|
||||
commonName,
|
||||
altNames,
|
||||
ttl,
|
||||
signatureAlgorithm,
|
||||
keyAlgorithm,
|
||||
keyUsages,
|
||||
extendedKeyUsages,
|
||||
isRenewal,
|
||||
originalCertificateId,
|
||||
certificateRequestId,
|
||||
csr
|
||||
}: TIssueCertificateFromProfileJobData) => {
|
||||
const jobData: TIssueCertificateFromProfileJobData = {
|
||||
certificateId,
|
||||
profileId,
|
||||
caId,
|
||||
commonName,
|
||||
altNames,
|
||||
ttl,
|
||||
signatureAlgorithm,
|
||||
keyAlgorithm,
|
||||
keyUsages,
|
||||
extendedKeyUsages,
|
||||
isRenewal,
|
||||
originalCertificateId,
|
||||
certificateRequestId,
|
||||
csr
|
||||
};
|
||||
|
||||
await queueService.queuePg(QueueJobs.CaIssueCertificateFromProfile, jobData, {
|
||||
retryLimit: 3,
|
||||
retryDelay: 5,
|
||||
retryBackoff: true
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Process certificate issuance jobs
|
||||
*/
|
||||
const processCertificateIssuanceJobs = async (data: TIssueCertificateFromProfileJobData) => {
|
||||
const {
|
||||
certificateId,
|
||||
profileId,
|
||||
caId,
|
||||
commonName,
|
||||
altNames,
|
||||
ttl,
|
||||
signatureAlgorithm,
|
||||
keyAlgorithm,
|
||||
keyUsages,
|
||||
extendedKeyUsages,
|
||||
isRenewal,
|
||||
originalCertificateId,
|
||||
certificateRequestId,
|
||||
csr
|
||||
} = data;
|
||||
|
||||
try {
|
||||
logger.info(`Processing certificate issuance job for [certificateId=${certificateId}] [caId=${caId}]`);
|
||||
|
||||
if (!caId) {
|
||||
throw new NotFoundError({
|
||||
message: `Certificate authority ID is required for external CA certificate issuance`
|
||||
});
|
||||
}
|
||||
|
||||
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId);
|
||||
|
||||
if (ca.externalCa?.type === CaType.ACME) {
|
||||
let certificateCsr: string;
|
||||
let skLeaf: string = "";
|
||||
|
||||
if (csr) {
|
||||
certificateCsr = csr;
|
||||
} else {
|
||||
const keyAlg = keyAlgorithmToAlgCfg(keyAlgorithm as CertKeyAlgorithm);
|
||||
const leafKeys = await crypto.nativeCrypto.subtle.generateKey(keyAlg, true, ["sign", "verify"]);
|
||||
const skLeafObj = crypto.nativeCrypto.KeyObject.from(leafKeys.privateKey);
|
||||
skLeaf = skLeafObj.export({ format: "pem", type: "pkcs8" }) as string;
|
||||
|
||||
const [, generatedCsr] = await acme.crypto.createCsr(
|
||||
{
|
||||
altNames: altNames ? [...altNames] : [],
|
||||
commonName: commonName || ""
|
||||
},
|
||||
skLeaf
|
||||
);
|
||||
certificateCsr = generatedCsr.toString();
|
||||
}
|
||||
|
||||
const acmeResult = await acmeFns.orderCertificateFromProfile({
|
||||
caId,
|
||||
profileId,
|
||||
commonName: commonName || "",
|
||||
altNames: altNames || [],
|
||||
csr: Buffer.from(certificateCsr),
|
||||
csrPrivateKey: skLeaf,
|
||||
keyUsages: keyUsages as CertKeyUsage[],
|
||||
extendedKeyUsages: extendedKeyUsages as CertExtendedKeyUsage[],
|
||||
ttl,
|
||||
signatureAlgorithm,
|
||||
keyAlgorithm,
|
||||
isRenewal,
|
||||
originalCertificateId
|
||||
});
|
||||
|
||||
if (certificateRequestId && certificateRequestService && acmeResult?.id) {
|
||||
try {
|
||||
await certificateRequestService.attachCertificateToRequest({
|
||||
certificateRequestId,
|
||||
certificateId: acmeResult.id
|
||||
});
|
||||
logger.info(`Certificate attached to request [certificateRequestId=${certificateRequestId}]`);
|
||||
} catch (attachError) {
|
||||
logger.error(
|
||||
attachError,
|
||||
`Failed to attach certificate to request [certificateRequestId=${certificateRequestId}]`
|
||||
);
|
||||
try {
|
||||
await certificateRequestService.updateCertificateRequestStatus({
|
||||
certificateRequestId,
|
||||
status: CertificateRequestStatus.FAILED,
|
||||
errorMessage: `Failed to attach certificate: ${attachError instanceof Error ? attachError.message : String(attachError)}`
|
||||
});
|
||||
} catch (statusUpdateError) {
|
||||
logger.error(
|
||||
statusUpdateError,
|
||||
`Failed to update certificate request status [certificateRequestId=${certificateRequestId}]`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (ca.externalCa?.type === CaType.AZURE_AD_CS) {
|
||||
let template: string | undefined;
|
||||
if (certificateProfileDAL) {
|
||||
try {
|
||||
const profile = await certificateProfileDAL.findById(profileId);
|
||||
if (
|
||||
profile?.externalConfigs &&
|
||||
typeof profile.externalConfigs === "object" &&
|
||||
profile.externalConfigs !== null
|
||||
) {
|
||||
const configs = profile.externalConfigs;
|
||||
if (typeof configs.template === "string") {
|
||||
template = configs.template;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Failed to fetch profile ${profileId} for template extraction: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const azureParams = {
|
||||
caId,
|
||||
profileId,
|
||||
commonName: commonName || "",
|
||||
altNames: altNames || [],
|
||||
keyUsages: keyUsages as CertKeyUsage[],
|
||||
extendedKeyUsages: extendedKeyUsages as CertExtendedKeyUsage[],
|
||||
validity: { ttl },
|
||||
signatureAlgorithm,
|
||||
keyAlgorithm: keyAlgorithm as CertKeyAlgorithm,
|
||||
isRenewal,
|
||||
originalCertificateId,
|
||||
template,
|
||||
...(csr && { csr })
|
||||
};
|
||||
|
||||
const azureResult = await azureAdCsFns.orderCertificateFromProfile(azureParams);
|
||||
|
||||
if (certificateRequestId && certificateRequestService && azureResult?.certificateId) {
|
||||
try {
|
||||
await certificateRequestService.attachCertificateToRequest({
|
||||
certificateRequestId,
|
||||
certificateId: azureResult.certificateId
|
||||
});
|
||||
logger.info(`Certificate attached to request [certificateRequestId=${certificateRequestId}]`);
|
||||
} catch (attachError) {
|
||||
logger.error(
|
||||
attachError,
|
||||
`Failed to attach certificate to request [certificateRequestId=${certificateRequestId}]`
|
||||
);
|
||||
try {
|
||||
await certificateRequestService.updateCertificateRequestStatus({
|
||||
certificateRequestId,
|
||||
status: CertificateRequestStatus.FAILED,
|
||||
errorMessage: `Failed to attach certificate: ${attachError instanceof Error ? attachError.message : String(attachError)}`
|
||||
});
|
||||
} catch (statusUpdateError) {
|
||||
logger.error(
|
||||
statusUpdateError,
|
||||
`Failed to update certificate request status [certificateRequestId=${certificateRequestId}]`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Successfully processed certificate issuance job with [certificateId=${certificateId}] [caId=${caId}]`
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
logger.error(error, `Certificate issuance job failed for [certificateId=${certificateId}] [caId=${caId}]`);
|
||||
|
||||
if (certificateRequestId && certificateRequestService) {
|
||||
try {
|
||||
await certificateRequestService.updateCertificateRequestStatus({
|
||||
certificateRequestId,
|
||||
status: CertificateRequestStatus.FAILED,
|
||||
errorMessage: `Certificate issuance failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
});
|
||||
logger.info(`Updated certificate request ${certificateRequestId} status to failed due to issuance error`);
|
||||
} catch (statusUpdateError) {
|
||||
logger.error(
|
||||
statusUpdateError,
|
||||
`Failed to update certificate request status [certificateRequestId=${certificateRequestId}]`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const initializeCertificateIssuanceQueue = async () => {
|
||||
await queueService.startPg(
|
||||
QueueJobs.CaIssueCertificateFromProfile,
|
||||
async ([job]) => {
|
||||
const data = job.data as TIssueCertificateFromProfileJobData;
|
||||
await processCertificateIssuanceJobs(data);
|
||||
},
|
||||
{
|
||||
workerCount: 2,
|
||||
batchSize: 1,
|
||||
pollingIntervalSeconds: 1
|
||||
}
|
||||
);
|
||||
|
||||
logger.info("Certificate issuance queue worker initialized successfully");
|
||||
};
|
||||
|
||||
return {
|
||||
queueCertificateIssuance,
|
||||
initializeCertificateIssuanceQueue,
|
||||
processCertificateIssuanceJobs
|
||||
};
|
||||
};
|
||||
+30
-17
@@ -67,6 +67,7 @@ import {
|
||||
TGetCaDTO,
|
||||
TImportCertToCaDTO,
|
||||
TIssueCertFromCaDTO,
|
||||
TIssueCertFromCaResponse,
|
||||
TRenewCaCertDTO,
|
||||
TSignCertFromCaDTO,
|
||||
TSignIntermediateDTO,
|
||||
@@ -1198,7 +1199,7 @@ export const internalCertificateAuthorityServiceFactory = ({
|
||||
isFromProfile,
|
||||
internal = false,
|
||||
tx
|
||||
}: TIssueCertFromCaDTO) => {
|
||||
}: TIssueCertFromCaDTO): Promise<TIssueCertFromCaResponse> => {
|
||||
let ca: TCertificateAuthorityWithAssociatedCa | undefined;
|
||||
let certificateTemplate: TCertificateTemplates | undefined;
|
||||
let collectionId = pkiCollectionId;
|
||||
@@ -1532,10 +1533,11 @@ export const internalCertificateAuthorityServiceFactory = ({
|
||||
return cert;
|
||||
};
|
||||
|
||||
let cert;
|
||||
if (tx) {
|
||||
await executeIssueCertOperations(tx);
|
||||
cert = await executeIssueCertOperations(tx);
|
||||
} else {
|
||||
await certificateDAL.transaction(executeIssueCertOperations);
|
||||
cert = await certificateDAL.transaction(executeIssueCertOperations);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -1544,6 +1546,8 @@ export const internalCertificateAuthorityServiceFactory = ({
|
||||
issuingCaCertificate,
|
||||
privateKey: skLeaf,
|
||||
serialNumber,
|
||||
certificateId: cert.id,
|
||||
commonName,
|
||||
ca: expandInternalCa(ca)
|
||||
};
|
||||
};
|
||||
@@ -1571,15 +1575,16 @@ export const internalCertificateAuthorityServiceFactory = ({
|
||||
keyUsages,
|
||||
extendedKeyUsages,
|
||||
signatureAlgorithm,
|
||||
keyAlgorithm
|
||||
keyAlgorithm,
|
||||
tx
|
||||
} = dto;
|
||||
|
||||
let collectionId = pkiCollectionId;
|
||||
|
||||
if (caId) {
|
||||
ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId);
|
||||
ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(caId, tx);
|
||||
} else if (certificateTemplateId) {
|
||||
certificateTemplate = await certificateTemplateDAL.getById(certificateTemplateId);
|
||||
certificateTemplate = await certificateTemplateDAL.getById(certificateTemplateId, tx);
|
||||
if (!certificateTemplate) {
|
||||
throw new NotFoundError({
|
||||
message: `Certificate template with ID '${certificateTemplateId}' not found`
|
||||
@@ -1587,7 +1592,7 @@ export const internalCertificateAuthorityServiceFactory = ({
|
||||
}
|
||||
|
||||
collectionId = certificateTemplate.pkiCollectionId as string;
|
||||
ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(certificateTemplate.caId);
|
||||
ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(certificateTemplate.caId, tx);
|
||||
}
|
||||
|
||||
if (!ca) {
|
||||
@@ -1636,7 +1641,7 @@ export const internalCertificateAuthorityServiceFactory = ({
|
||||
|
||||
// check PKI collection
|
||||
if (pkiCollectionId) {
|
||||
const pkiCollection = await pkiCollectionDAL.findById(pkiCollectionId);
|
||||
const pkiCollection = await pkiCollectionDAL.findById(pkiCollectionId, tx);
|
||||
if (!pkiCollection) throw new NotFoundError({ message: `PKI collection with ID '${pkiCollectionId}' not found` });
|
||||
if (pkiCollection.projectId !== ca.projectId) throw new BadRequestError({ message: "Invalid PKI collection" });
|
||||
}
|
||||
@@ -1905,8 +1910,8 @@ export const internalCertificateAuthorityServiceFactory = ({
|
||||
plainText: Buffer.from(certificateChainPem)
|
||||
});
|
||||
|
||||
await certificateDAL.transaction(async (tx) => {
|
||||
const cert = await certificateDAL.create(
|
||||
const createSignedCert = async (transaction: Knex) => {
|
||||
const newCert = await certificateDAL.create(
|
||||
{
|
||||
caId: (ca as TCertificateAuthorities).id,
|
||||
caCertId: caCert.id,
|
||||
@@ -1924,36 +1929,44 @@ export const internalCertificateAuthorityServiceFactory = ({
|
||||
keyAlgorithm: keyAlgorithm || ca!.internalCa!.keyAlgorithm,
|
||||
signatureAlgorithm: signatureAlgorithm || ca!.internalCa!.keyAlgorithm
|
||||
},
|
||||
tx
|
||||
transaction
|
||||
);
|
||||
|
||||
await certificateBodyDAL.create(
|
||||
{
|
||||
certId: cert.id,
|
||||
certId: newCert.id,
|
||||
encryptedCertificate,
|
||||
encryptedCertificateChain
|
||||
},
|
||||
tx
|
||||
transaction
|
||||
);
|
||||
|
||||
if (collectionId) {
|
||||
await pkiCollectionItemDAL.create(
|
||||
{
|
||||
pkiCollectionId: collectionId,
|
||||
certId: cert.id
|
||||
certId: newCert.id
|
||||
},
|
||||
tx
|
||||
transaction
|
||||
);
|
||||
}
|
||||
|
||||
return cert;
|
||||
});
|
||||
return newCert;
|
||||
};
|
||||
|
||||
let cert;
|
||||
if (tx) {
|
||||
cert = await createSignedCert(tx);
|
||||
} else {
|
||||
cert = await certificateDAL.transaction(createSignedCert);
|
||||
}
|
||||
|
||||
return {
|
||||
certificate: leafCert,
|
||||
certificateChain: certificateChainPem,
|
||||
issuingCaCertificate,
|
||||
serialNumber,
|
||||
certificateId: cert.id,
|
||||
ca: expandInternalCa(ca),
|
||||
commonName: cn
|
||||
};
|
||||
|
||||
+19
@@ -160,6 +160,7 @@ export type TSignCertFromCaDTO =
|
||||
keyAlgorithm?: string;
|
||||
isFromProfile?: boolean;
|
||||
profileId?: string;
|
||||
tx?: Knex;
|
||||
}
|
||||
| ({
|
||||
isInternal: false;
|
||||
@@ -179,6 +180,7 @@ export type TSignCertFromCaDTO =
|
||||
keyAlgorithm?: string;
|
||||
isFromProfile?: boolean;
|
||||
profileId?: string;
|
||||
tx?: Knex;
|
||||
} & Omit<TProjectPermission, "projectId">);
|
||||
|
||||
export type TGetCaCertificateTemplatesDTO = {
|
||||
@@ -248,3 +250,20 @@ export type TIssueCertWithTemplateDTO = {
|
||||
keyUsages?: CertKeyUsage[];
|
||||
extendedKeyUsages?: CertExtendedKeyUsage[];
|
||||
};
|
||||
|
||||
type TCaReference = {
|
||||
id: string;
|
||||
projectId: string;
|
||||
dn: string;
|
||||
};
|
||||
|
||||
export type TIssueCertFromCaResponse = {
|
||||
certificate: string;
|
||||
certificateChain: string;
|
||||
issuingCaCertificate: string;
|
||||
privateKey: string;
|
||||
serialNumber: string;
|
||||
certificateId: string;
|
||||
ca: TCaReference;
|
||||
commonName: string;
|
||||
};
|
||||
|
||||
@@ -22,10 +22,19 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
|
||||
|
||||
const create = async (data: TCertificateProfileInsert, tx?: Knex): Promise<TCertificateProfile> => {
|
||||
try {
|
||||
const [certificateProfile] = (await (tx || db)(TableName.PkiCertificateProfile).insert(data).returning("*")) as [
|
||||
TCertificateProfile
|
||||
];
|
||||
return certificateProfile;
|
||||
const dataToInsert = {
|
||||
...data,
|
||||
externalConfigs: data.externalConfigs ? JSON.stringify(data.externalConfigs) : null
|
||||
};
|
||||
|
||||
const [insertedProfile] = await (tx || db)(TableName.PkiCertificateProfile).insert(dataToInsert).returning("*");
|
||||
|
||||
return {
|
||||
...insertedProfile,
|
||||
externalConfigs: insertedProfile.externalConfigs
|
||||
? (JSON.parse(insertedProfile.externalConfigs) as Record<string, unknown>)
|
||||
: null
|
||||
} as TCertificateProfile;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Create certificate profile" });
|
||||
}
|
||||
@@ -33,11 +42,25 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
|
||||
|
||||
const updateById = async (id: string, data: TCertificateProfileUpdate, tx?: Knex): Promise<TCertificateProfile> => {
|
||||
try {
|
||||
const [certificateProfile] = (await (tx || db)(TableName.PkiCertificateProfile)
|
||||
const dataToUpdate: Partial<Record<string, unknown>> = {
|
||||
...data
|
||||
};
|
||||
|
||||
if (data.externalConfigs !== undefined) {
|
||||
dataToUpdate.externalConfigs = data.externalConfigs ? JSON.stringify(data.externalConfigs) : null;
|
||||
}
|
||||
|
||||
const [updatedProfile] = await (tx || db)(TableName.PkiCertificateProfile)
|
||||
.where({ id })
|
||||
.update(data)
|
||||
.returning("*")) as [TCertificateProfile];
|
||||
return certificateProfile;
|
||||
.update(dataToUpdate)
|
||||
.returning("*");
|
||||
|
||||
return {
|
||||
...updatedProfile,
|
||||
externalConfigs: updatedProfile.externalConfigs
|
||||
? (JSON.parse(updatedProfile.externalConfigs) as Record<string, unknown>)
|
||||
: null
|
||||
} as TCertificateProfile;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Update certificate profile" });
|
||||
}
|
||||
@@ -57,10 +80,16 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
|
||||
|
||||
const findById = async (id: string, tx?: Knex): Promise<TCertificateProfile | undefined> => {
|
||||
try {
|
||||
const certificateProfile = (await (tx || db)(TableName.PkiCertificateProfile).where({ id }).first()) as
|
||||
| TCertificateProfile
|
||||
| undefined;
|
||||
return certificateProfile;
|
||||
const certificateProfile = await (tx || db)(TableName.PkiCertificateProfile).where({ id }).first();
|
||||
|
||||
if (!certificateProfile) return undefined;
|
||||
|
||||
return {
|
||||
...certificateProfile,
|
||||
externalConfigs: certificateProfile.externalConfigs
|
||||
? (JSON.parse(certificateProfile.externalConfigs) as Record<string, unknown>)
|
||||
: null
|
||||
} as TCertificateProfile;
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Find certificate profile by id" });
|
||||
}
|
||||
@@ -203,6 +232,9 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
|
||||
estConfigId: result.estConfigId,
|
||||
apiConfigId: result.apiConfigId,
|
||||
acmeConfigId: result.acmeConfigId,
|
||||
externalConfigs: result.externalConfigs
|
||||
? (JSON.parse(result.externalConfigs) as Record<string, unknown>)
|
||||
: null,
|
||||
createdAt: result.createdAt,
|
||||
updatedAt: result.updatedAt,
|
||||
estConfig,
|
||||
@@ -277,6 +309,16 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
|
||||
}
|
||||
|
||||
const query = baseQuery
|
||||
.leftJoin(
|
||||
TableName.CertificateAuthority,
|
||||
`${TableName.PkiCertificateProfile}.caId`,
|
||||
`${TableName.CertificateAuthority}.id`
|
||||
)
|
||||
.leftJoin(
|
||||
TableName.ExternalCertificateAuthority,
|
||||
`${TableName.CertificateAuthority}.id`,
|
||||
`${TableName.ExternalCertificateAuthority}.caId`
|
||||
)
|
||||
.leftJoin(
|
||||
TableName.PkiEstEnrollmentConfig,
|
||||
`${TableName.PkiCertificateProfile}.estConfigId`,
|
||||
@@ -294,6 +336,11 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
|
||||
)
|
||||
.select(selectAllTableCols(TableName.PkiCertificateProfile))
|
||||
.select(
|
||||
db.ref("id").withSchema(TableName.CertificateAuthority).as("caId"),
|
||||
db.ref("name").withSchema(TableName.CertificateAuthority).as("caName"),
|
||||
db.ref("status").withSchema(TableName.CertificateAuthority).as("caStatus"),
|
||||
db.ref("id").withSchema(TableName.ExternalCertificateAuthority).as("externalCaId"),
|
||||
db.ref("type").withSchema(TableName.ExternalCertificateAuthority).as("externalCaType"),
|
||||
db.ref("id").withSchema(TableName.PkiEstEnrollmentConfig).as("estId"),
|
||||
db
|
||||
.ref("disableBootstrapCaValidation")
|
||||
@@ -337,6 +384,16 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const certificateAuthority = result.caId
|
||||
? {
|
||||
id: result.caId as string,
|
||||
name: result.caName as string,
|
||||
status: result.caStatus as string,
|
||||
isExternal: !!result.externalCaId,
|
||||
externalType: result.externalCaType as string | undefined
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const baseProfile = {
|
||||
id: result.id,
|
||||
projectId: result.projectId,
|
||||
@@ -349,11 +406,15 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
|
||||
estConfigId: result.estConfigId,
|
||||
apiConfigId: result.apiConfigId,
|
||||
acmeConfigId: result.acmeConfigId,
|
||||
externalConfigs: result.externalConfigs
|
||||
? (JSON.parse(result.externalConfigs as string) as Record<string, unknown>)
|
||||
: null,
|
||||
createdAt: result.createdAt,
|
||||
updatedAt: result.updatedAt,
|
||||
estConfig,
|
||||
apiConfig,
|
||||
acmeConfig
|
||||
acmeConfig,
|
||||
certificateAuthority
|
||||
};
|
||||
|
||||
return baseProfile as TCertificateProfileWithConfigs;
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { CaType } from "@app/services/certificate-authority/certificate-authority-enums";
|
||||
|
||||
/**
|
||||
* External configuration schema for Azure AD CS Certificate Authority
|
||||
*/
|
||||
export const AzureAdCsExternalConfigSchema = z.object({
|
||||
template: z
|
||||
.string()
|
||||
.min(1, "Template name is required for Azure AD CS")
|
||||
.describe("Certificate template name for Azure AD CS")
|
||||
});
|
||||
|
||||
/**
|
||||
* External configuration schema for ACME Certificate Authority
|
||||
*/
|
||||
export const AcmeExternalConfigSchema = z.object({});
|
||||
|
||||
/**
|
||||
* Map of CA types to their corresponding external configuration schemas
|
||||
*/
|
||||
export const ExternalConfigSchemaMap = {
|
||||
[CaType.AZURE_AD_CS]: AzureAdCsExternalConfigSchema,
|
||||
[CaType.ACME]: AcmeExternalConfigSchema,
|
||||
[CaType.INTERNAL]: z.object({}).optional() // Internal CAs don't use external configs
|
||||
} as const;
|
||||
|
||||
export const createExternalConfigSchema = (caType?: CaType | null) => {
|
||||
if (!caType || caType === CaType.INTERNAL) {
|
||||
return z.object({}).nullable().optional();
|
||||
}
|
||||
|
||||
const schema = ExternalConfigSchemaMap[caType];
|
||||
if (!schema) {
|
||||
return z.object({}).nullable().optional();
|
||||
}
|
||||
|
||||
return schema.nullable().optional();
|
||||
};
|
||||
|
||||
/**
|
||||
* Union type of all possible external configuration schemas
|
||||
*/
|
||||
export const ExternalConfigUnionSchema = z
|
||||
.union([AzureAdCsExternalConfigSchema, AcmeExternalConfigSchema, z.object({})])
|
||||
.nullable()
|
||||
.optional();
|
||||
|
||||
export type TExternalConfig = z.infer<typeof ExternalConfigUnionSchema>;
|
||||
@@ -12,8 +12,8 @@ import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/
|
||||
import { ActorType, AuthMethod } from "../auth/auth-type";
|
||||
import type { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal";
|
||||
import type { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal";
|
||||
import type { TCertificateAuthorityCertDALFactory } from "../certificate-authority/certificate-authority-cert-dal";
|
||||
import type { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal";
|
||||
import type { TExternalCertificateAuthorityDALFactory } from "../certificate-authority/external-certificate-authority-dal";
|
||||
import type { TCertificateTemplateV2DALFactory } from "../certificate-template-v2/certificate-template-v2-dal";
|
||||
import { TAcmeEnrollmentConfigDALFactory } from "../enrollment-config/acme-enrollment-config-dal";
|
||||
import type { TApiEnrollmentConfigDALFactory } from "../enrollment-config/api-enrollment-config-dal";
|
||||
@@ -100,6 +100,7 @@ describe("CertificateProfileService", () => {
|
||||
certificateTemplateId: "template-123",
|
||||
apiConfigId: "api-config-123",
|
||||
estConfigId: null,
|
||||
externalConfigs: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
};
|
||||
@@ -229,17 +230,10 @@ describe("CertificateProfileService", () => {
|
||||
delete: vi.fn()
|
||||
} as unknown as TCertificateAuthorityDALFactory;
|
||||
|
||||
const mockCertificateAuthorityCertDAL = {
|
||||
create: vi.fn(),
|
||||
const mockExternalCertificateAuthorityDAL = {
|
||||
findById: vi.fn(),
|
||||
updateById: vi.fn(),
|
||||
deleteById: vi.fn(),
|
||||
transaction: vi.fn(),
|
||||
find: vi.fn(),
|
||||
findOne: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn()
|
||||
} as unknown as TCertificateAuthorityCertDALFactory;
|
||||
findOne: vi.fn()
|
||||
} as unknown as Pick<TExternalCertificateAuthorityDALFactory, "findById" | "findOne">;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(ForbiddenError, "from").mockReturnValue({
|
||||
@@ -261,7 +255,7 @@ describe("CertificateProfileService", () => {
|
||||
certificateBodyDAL: mockCertificateBodyDAL,
|
||||
certificateSecretDAL: mockCertificateSecretDAL,
|
||||
certificateAuthorityDAL: mockCertificateAuthorityDAL,
|
||||
certificateAuthorityCertDAL: mockCertificateAuthorityCertDAL,
|
||||
externalCertificateAuthorityDAL: mockExternalCertificateAuthorityDAL,
|
||||
permissionService: mockPermissionService,
|
||||
licenseService: mockLicenseService,
|
||||
kmsService: mockKmsService,
|
||||
|
||||
@@ -19,8 +19,9 @@ import { ActorAuthMethod, ActorType } from "../auth/auth-type";
|
||||
import { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal";
|
||||
import { getCertificateCredentials, isCertChainValid } from "../certificate/certificate-fns";
|
||||
import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal";
|
||||
import { TCertificateAuthorityCertDALFactory } from "../certificate-authority/certificate-authority-cert-dal";
|
||||
import { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal";
|
||||
import { CaType } from "../certificate-authority/certificate-authority-enums";
|
||||
import { TExternalCertificateAuthorityDALFactory } from "../certificate-authority/external-certificate-authority-dal";
|
||||
import { TCertificateTemplateV2DALFactory } from "../certificate-template-v2/certificate-template-v2-dal";
|
||||
import { TAcmeEnrollmentConfigDALFactory } from "../enrollment-config/acme-enrollment-config-dal";
|
||||
import { TApiEnrollmentConfigDALFactory } from "../enrollment-config/api-enrollment-config-dal";
|
||||
@@ -68,6 +69,55 @@ const validateIssuerTypeConstraints = (
|
||||
}
|
||||
};
|
||||
|
||||
const validateTemplateByExternalCaType = (
|
||||
externalCaType: CaType | undefined,
|
||||
externalConfigs: Record<string, unknown> | null | undefined
|
||||
) => {
|
||||
if (!externalCaType) return;
|
||||
|
||||
switch (externalCaType) {
|
||||
case CaType.AZURE_AD_CS:
|
||||
if (!externalConfigs?.template || typeof externalConfigs.template !== "string") {
|
||||
throw new ForbiddenRequestError({
|
||||
message: "Azure ADCS Certificate Authority requires a template to be specified in external configs"
|
||||
});
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const validateExternalConfigs = async (
|
||||
externalConfigs: Record<string, unknown> | null | undefined,
|
||||
caId: string | null,
|
||||
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findById">,
|
||||
externalCertificateAuthorityDAL: Pick<TExternalCertificateAuthorityDALFactory, "findOne">
|
||||
) => {
|
||||
if (!externalConfigs) return;
|
||||
|
||||
if (!caId) {
|
||||
throw new ForbiddenRequestError({
|
||||
message: "External configs can only be specified when a Certificate Authority is selected"
|
||||
});
|
||||
}
|
||||
|
||||
const ca = await certificateAuthorityDAL.findById(caId);
|
||||
if (!ca) {
|
||||
throw new NotFoundError({ message: "Certificate Authority not found" });
|
||||
}
|
||||
|
||||
const externalCa = await externalCertificateAuthorityDAL.findOne({ caId });
|
||||
|
||||
if (!externalCa) {
|
||||
throw new ForbiddenRequestError({
|
||||
message: "External configs can only be specified for external Certificate Authorities"
|
||||
});
|
||||
}
|
||||
|
||||
validateTemplateByExternalCaType(externalCa.type as CaType, externalConfigs);
|
||||
};
|
||||
|
||||
const generateAndEncryptAcmeEabSecret = async (
|
||||
projectId: string,
|
||||
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "encryptWithKmsKey">,
|
||||
@@ -180,7 +230,7 @@ type TCertificateProfileServiceFactoryDep = {
|
||||
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "findOne">;
|
||||
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "findOne">;
|
||||
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findById">;
|
||||
certificateAuthorityCertDAL: Pick<TCertificateAuthorityCertDALFactory, "findById">;
|
||||
externalCertificateAuthorityDAL: Pick<TExternalCertificateAuthorityDALFactory, "findById" | "findOne">;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey">;
|
||||
@@ -190,10 +240,22 @@ type TCertificateProfileServiceFactoryDep = {
|
||||
export type TCertificateProfileServiceFactory = ReturnType<typeof certificateProfileServiceFactory>;
|
||||
|
||||
const convertDalToService = (dalResult: Record<string, unknown>): TCertificateProfile => {
|
||||
let parsedExternalConfigs: Record<string, unknown> | null = null;
|
||||
if (dalResult.externalConfigs && typeof dalResult.externalConfigs === "string") {
|
||||
try {
|
||||
parsedExternalConfigs = JSON.parse(dalResult.externalConfigs) as Record<string, unknown>;
|
||||
} catch {
|
||||
parsedExternalConfigs = null;
|
||||
}
|
||||
} else if (dalResult.externalConfigs && typeof dalResult.externalConfigs === "object") {
|
||||
parsedExternalConfigs = dalResult.externalConfigs as Record<string, unknown>;
|
||||
}
|
||||
|
||||
return {
|
||||
...dalResult,
|
||||
enrollmentType: dalResult.enrollmentType as EnrollmentType,
|
||||
issuerType: dalResult.issuerType as IssuerType
|
||||
issuerType: dalResult.issuerType as IssuerType,
|
||||
externalConfigs: parsedExternalConfigs
|
||||
} as TCertificateProfile;
|
||||
};
|
||||
|
||||
@@ -205,6 +267,8 @@ export const certificateProfileServiceFactory = ({
|
||||
acmeEnrollmentConfigDAL,
|
||||
certificateBodyDAL,
|
||||
certificateSecretDAL,
|
||||
certificateAuthorityDAL,
|
||||
externalCertificateAuthorityDAL,
|
||||
permissionService,
|
||||
licenseService,
|
||||
kmsService,
|
||||
@@ -274,6 +338,14 @@ export const certificateProfileServiceFactory = ({
|
||||
|
||||
validateIssuerTypeConstraints(data.issuerType, data.enrollmentType, data.caId ?? null);
|
||||
|
||||
// Validate external configs
|
||||
await validateExternalConfigs(
|
||||
data.externalConfigs,
|
||||
data.caId ?? null,
|
||||
certificateAuthorityDAL,
|
||||
externalCertificateAuthorityDAL
|
||||
);
|
||||
|
||||
// Validate enrollment configuration requirements
|
||||
if (data.enrollmentType === EnrollmentType.EST && !data.estConfig) {
|
||||
throw new ForbiddenRequestError({
|
||||
@@ -342,7 +414,8 @@ export const certificateProfileServiceFactory = ({
|
||||
projectId,
|
||||
estConfigId,
|
||||
apiConfigId,
|
||||
acmeConfigId
|
||||
acmeConfigId,
|
||||
externalConfigs: data.externalConfigs
|
||||
},
|
||||
tx
|
||||
);
|
||||
@@ -418,6 +491,16 @@ export const certificateProfileServiceFactory = ({
|
||||
|
||||
validateIssuerTypeConstraints(finalIssuerType, finalEnrollmentType, finalCaId ?? null, existingProfile.caId);
|
||||
|
||||
// Validate external configs only if they are provided in the update
|
||||
if (data.externalConfigs !== undefined) {
|
||||
await validateExternalConfigs(
|
||||
data.externalConfigs,
|
||||
finalCaId ?? null,
|
||||
certificateAuthorityDAL,
|
||||
externalCertificateAuthorityDAL
|
||||
);
|
||||
}
|
||||
|
||||
const updatedData =
|
||||
finalIssuerType === IssuerType.SELF_SIGNED && existingProfile.caId ? { ...data, caId: null } : data;
|
||||
|
||||
@@ -566,9 +649,24 @@ export const certificateProfileServiceFactory = ({
|
||||
}
|
||||
}
|
||||
|
||||
// Parse externalConfigs from JSON string to object if it exists
|
||||
let parsedExternalConfigs: Record<string, unknown> | null = null;
|
||||
if (profile.externalConfigs && typeof profile.externalConfigs === "string") {
|
||||
try {
|
||||
parsedExternalConfigs = JSON.parse(profile.externalConfigs) as Record<string, unknown>;
|
||||
} catch {
|
||||
// If parsing fails, leave as null
|
||||
parsedExternalConfigs = null;
|
||||
}
|
||||
} else if (profile.externalConfigs && typeof profile.externalConfigs === "object") {
|
||||
// Already an object, use as-is
|
||||
parsedExternalConfigs = profile.externalConfigs;
|
||||
}
|
||||
|
||||
return {
|
||||
...profile,
|
||||
enrollmentType: profile.enrollmentType as EnrollmentType
|
||||
enrollmentType: profile.enrollmentType as EnrollmentType,
|
||||
externalConfigs: parsedExternalConfigs
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -15,19 +15,28 @@ export enum IssuerType {
|
||||
SELF_SIGNED = "self-signed"
|
||||
}
|
||||
|
||||
export type TCertificateProfile = Omit<TPkiCertificateProfiles, "enrollmentType" | "issuerType"> & {
|
||||
export type TCertificateProfile = Omit<TPkiCertificateProfiles, "enrollmentType" | "issuerType" | "externalConfigs"> & {
|
||||
enrollmentType: EnrollmentType;
|
||||
issuerType: IssuerType;
|
||||
externalConfigs?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type TCertificateProfileInsert = Omit<TPkiCertificateProfilesInsert, "enrollmentType" | "issuerType"> & {
|
||||
export type TCertificateProfileInsert = Omit<
|
||||
TPkiCertificateProfilesInsert,
|
||||
"enrollmentType" | "issuerType" | "externalConfigs"
|
||||
> & {
|
||||
enrollmentType: EnrollmentType;
|
||||
issuerType: IssuerType;
|
||||
externalConfigs?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type TCertificateProfileUpdate = Omit<TPkiCertificateProfilesUpdate, "enrollmentType" | "issuerType"> & {
|
||||
export type TCertificateProfileUpdate = Omit<
|
||||
TPkiCertificateProfilesUpdate,
|
||||
"enrollmentType" | "issuerType" | "externalConfigs"
|
||||
> & {
|
||||
enrollmentType?: EnrollmentType;
|
||||
issuerType?: IssuerType;
|
||||
externalConfigs?: Record<string, unknown> | null;
|
||||
estConfig?: {
|
||||
disableBootstrapCaValidation?: boolean;
|
||||
passphrase?: string;
|
||||
@@ -50,6 +59,8 @@ export type TCertificateProfileWithConfigs = TCertificateProfile & {
|
||||
projectId: string;
|
||||
status: string;
|
||||
name: string;
|
||||
isExternal?: boolean;
|
||||
externalType?: string;
|
||||
};
|
||||
certificateTemplate?: {
|
||||
id: string;
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TDbClient } from "@app/db";
|
||||
import { TableName, TCertificateRequests, TCertificates } from "@app/db/schemas";
|
||||
import { DatabaseError } from "@app/lib/errors";
|
||||
import { ormify, selectAllTableCols } from "@app/lib/knex";
|
||||
|
||||
type TCertificateRequestWithCertificate = TCertificateRequests & {
|
||||
certificate: TCertificates | null;
|
||||
};
|
||||
|
||||
export type TCertificateRequestDALFactory = ReturnType<typeof certificateRequestDALFactory>;
|
||||
|
||||
export const certificateRequestDALFactory = (db: TDbClient) => {
|
||||
const certificateRequestOrm = ormify(db, TableName.CertificateRequests);
|
||||
|
||||
const findByIdWithCertificate = async (id: string): Promise<TCertificateRequestWithCertificate | null> => {
|
||||
try {
|
||||
const certificateRequest = await certificateRequestOrm.findById(id);
|
||||
if (!certificateRequest) return null;
|
||||
|
||||
if (!certificateRequest.certificateId) {
|
||||
return {
|
||||
...certificateRequest,
|
||||
certificate: null
|
||||
};
|
||||
}
|
||||
|
||||
const certificate = await db(TableName.Certificate)
|
||||
.where("id", certificateRequest.certificateId)
|
||||
.select(selectAllTableCols(TableName.Certificate))
|
||||
.first();
|
||||
|
||||
return {
|
||||
...certificateRequest,
|
||||
certificate: certificate || null
|
||||
};
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Find certificate request by ID with certificate" });
|
||||
}
|
||||
};
|
||||
|
||||
const findPendingByProjectId = async (projectId: string): Promise<TCertificateRequests[]> => {
|
||||
try {
|
||||
return (await db(TableName.CertificateRequests)
|
||||
.where({ projectId, status: "pending" })
|
||||
.orderBy("createdAt", "desc")) as TCertificateRequests[];
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Find pending certificate requests by project ID" });
|
||||
}
|
||||
};
|
||||
|
||||
const updateStatus = async (
|
||||
id: string,
|
||||
status: string,
|
||||
errorMessage?: string,
|
||||
tx?: Knex
|
||||
): Promise<TCertificateRequests> => {
|
||||
try {
|
||||
const updateData: Partial<TCertificateRequests> = { status };
|
||||
if (errorMessage !== undefined) {
|
||||
updateData.errorMessage = errorMessage;
|
||||
}
|
||||
return await certificateRequestOrm.updateById(id, updateData, tx);
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Update certificate request status" });
|
||||
}
|
||||
};
|
||||
|
||||
const attachCertificate = async (id: string, certificateId: string, tx?: Knex): Promise<TCertificateRequests> => {
|
||||
try {
|
||||
return await certificateRequestOrm.updateById(
|
||||
id,
|
||||
{
|
||||
certificateId,
|
||||
status: "issued"
|
||||
},
|
||||
tx
|
||||
);
|
||||
} catch (error) {
|
||||
throw new DatabaseError({ error, name: "Attach certificate to request" });
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
...certificateRequestOrm,
|
||||
findByIdWithCertificate,
|
||||
findPendingByProjectId,
|
||||
updateStatus,
|
||||
attachCertificate
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,563 @@
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-call */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-argument */
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
|
||||
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
|
||||
import { createMongoAbility, ForbiddenError } from "@casl/ability";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ActionProjectType } from "@app/db/schemas";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
|
||||
import {
|
||||
ProjectPermissionCertificateActions,
|
||||
ProjectPermissionSet,
|
||||
ProjectPermissionSub
|
||||
} from "@app/ee/services/permission/project-permission";
|
||||
import { NotFoundError } from "@app/lib/errors";
|
||||
import { ActorType, AuthMethod } from "@app/services/auth/auth-type";
|
||||
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
|
||||
import { TCertificateServiceFactory } from "@app/services/certificate/certificate-service";
|
||||
|
||||
import { TCertificateRequestDALFactory } from "./certificate-request-dal";
|
||||
import { certificateRequestServiceFactory, TCertificateRequestServiceFactory } from "./certificate-request-service";
|
||||
import { CertificateRequestStatus } from "./certificate-request-types";
|
||||
|
||||
describe("CertificateRequestService", () => {
|
||||
let service: TCertificateRequestServiceFactory;
|
||||
|
||||
const mockCertificateRequestDAL: Pick<
|
||||
TCertificateRequestDALFactory,
|
||||
"create" | "findById" | "findByIdWithCertificate" | "updateStatus" | "attachCertificate"
|
||||
> = {
|
||||
create: vi.fn() as any,
|
||||
findById: vi.fn() as any,
|
||||
findByIdWithCertificate: vi.fn() as any,
|
||||
updateStatus: vi.fn() as any,
|
||||
attachCertificate: vi.fn() as any
|
||||
};
|
||||
|
||||
const mockCertificateDAL: Pick<TCertificateDALFactory, "findById"> = {
|
||||
findById: vi.fn() as any
|
||||
};
|
||||
|
||||
const mockCertificateService: Pick<TCertificateServiceFactory, "getCertBody" | "getCertPrivateKey"> = {
|
||||
getCertBody: vi.fn() as any,
|
||||
getCertPrivateKey: vi.fn() as any
|
||||
};
|
||||
|
||||
const mockPermissionService: Pick<TPermissionServiceFactory, "getProjectPermission"> = {
|
||||
getProjectPermission: vi.fn() as any
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = certificateRequestServiceFactory({
|
||||
certificateRequestDAL: mockCertificateRequestDAL as TCertificateRequestDALFactory,
|
||||
certificateDAL: mockCertificateDAL,
|
||||
certificateService: mockCertificateService,
|
||||
permissionService: mockPermissionService
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("createCertificateRequest", () => {
|
||||
const mockCreateData = {
|
||||
actor: ActorType.USER,
|
||||
actorId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
actorAuthMethod: AuthMethod.EMAIL,
|
||||
actorOrgId: "550e8400-e29b-41d4-a716-446655440002",
|
||||
projectId: "550e8400-e29b-41d4-a716-446655440003",
|
||||
profileId: "550e8400-e29b-41d4-a716-446655440004",
|
||||
commonName: "test.example.com",
|
||||
status: CertificateRequestStatus.PENDING
|
||||
};
|
||||
|
||||
it("should create certificate request successfully", async () => {
|
||||
const mockPermission = {
|
||||
permission: createMongoAbility<ProjectPermissionSet>([
|
||||
{
|
||||
action: ProjectPermissionCertificateActions.Create,
|
||||
subject: ProjectPermissionSub.Certificates
|
||||
}
|
||||
])
|
||||
};
|
||||
const mockCreatedRequest = {
|
||||
id: "550e8400-e29b-41d4-a716-446655440005",
|
||||
status: CertificateRequestStatus.PENDING,
|
||||
projectId: "550e8400-e29b-41d4-a716-446655440003",
|
||||
profileId: "550e8400-e29b-41d4-a716-446655440004",
|
||||
commonName: "test.example.com"
|
||||
};
|
||||
|
||||
(mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission);
|
||||
(mockCertificateRequestDAL.create as any).mockResolvedValue(mockCreatedRequest);
|
||||
|
||||
const result = await service.createCertificateRequest(mockCreateData);
|
||||
|
||||
expect(mockPermissionService.getProjectPermission).toHaveBeenCalledWith({
|
||||
actor: ActorType.USER,
|
||||
actorId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
projectId: "550e8400-e29b-41d4-a716-446655440003",
|
||||
actorAuthMethod: AuthMethod.EMAIL,
|
||||
actorOrgId: "550e8400-e29b-41d4-a716-446655440002",
|
||||
actionProjectType: ActionProjectType.CertificateManager
|
||||
});
|
||||
expect(mockCertificateRequestDAL.create).toHaveBeenCalledWith(
|
||||
{
|
||||
status: CertificateRequestStatus.PENDING,
|
||||
projectId: "550e8400-e29b-41d4-a716-446655440003",
|
||||
profileId: "550e8400-e29b-41d4-a716-446655440004",
|
||||
commonName: "test.example.com"
|
||||
},
|
||||
undefined
|
||||
);
|
||||
expect(result).toEqual(mockCreatedRequest);
|
||||
});
|
||||
|
||||
it("should throw ForbiddenError when user lacks permission", async () => {
|
||||
const mockPermission = {
|
||||
permission: ForbiddenError.from(createMongoAbility([]))
|
||||
};
|
||||
|
||||
(mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission);
|
||||
|
||||
await expect(service.createCertificateRequest(mockCreateData)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCertificateRequest", () => {
|
||||
const mockGetData = {
|
||||
actor: ActorType.USER,
|
||||
actorId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
actorAuthMethod: AuthMethod.EMAIL,
|
||||
actorOrgId: "550e8400-e29b-41d4-a716-446655440002",
|
||||
projectId: "550e8400-e29b-41d4-a716-446655440003",
|
||||
certificateRequestId: "550e8400-e29b-41d4-a716-446655440005"
|
||||
};
|
||||
|
||||
it("should get certificate request successfully", async () => {
|
||||
const mockPermission = {
|
||||
permission: createMongoAbility<ProjectPermissionSet>([
|
||||
{
|
||||
action: ProjectPermissionCertificateActions.Read,
|
||||
subject: ProjectPermissionSub.Certificates
|
||||
}
|
||||
])
|
||||
};
|
||||
const mockRequest = {
|
||||
id: "550e8400-e29b-41d4-a716-446655440005",
|
||||
projectId: "550e8400-e29b-41d4-a716-446655440003",
|
||||
status: CertificateRequestStatus.PENDING
|
||||
};
|
||||
|
||||
(mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission);
|
||||
(mockCertificateRequestDAL.findById as any).mockResolvedValue(mockRequest);
|
||||
|
||||
const result = await service.getCertificateRequest(mockGetData);
|
||||
|
||||
expect(mockPermissionService.getProjectPermission).toHaveBeenCalledWith({
|
||||
actor: ActorType.USER,
|
||||
actorId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
projectId: "550e8400-e29b-41d4-a716-446655440003",
|
||||
actorAuthMethod: AuthMethod.EMAIL,
|
||||
actorOrgId: "550e8400-e29b-41d4-a716-446655440002",
|
||||
actionProjectType: ActionProjectType.CertificateManager
|
||||
});
|
||||
expect(mockCertificateRequestDAL.findById).toHaveBeenCalledWith("550e8400-e29b-41d4-a716-446655440005");
|
||||
expect(result).toEqual(mockRequest);
|
||||
});
|
||||
|
||||
it("should throw NotFoundError when certificate request does not exist", async () => {
|
||||
const mockPermission = {
|
||||
permission: createMongoAbility<ProjectPermissionSet>([
|
||||
{
|
||||
action: ProjectPermissionCertificateActions.Read,
|
||||
subject: ProjectPermissionSub.Certificates
|
||||
}
|
||||
])
|
||||
};
|
||||
|
||||
(mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission);
|
||||
(mockCertificateRequestDAL.findById as any).mockResolvedValue(null);
|
||||
|
||||
await expect(service.getCertificateRequest(mockGetData)).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it("should throw BadRequestError when certificate request belongs to different project", async () => {
|
||||
const mockPermission = {
|
||||
permission: createMongoAbility<ProjectPermissionSet>([
|
||||
{
|
||||
action: ProjectPermissionCertificateActions.Read,
|
||||
subject: ProjectPermissionSub.Certificates
|
||||
}
|
||||
])
|
||||
};
|
||||
const mockRequest = {
|
||||
id: "550e8400-e29b-41d4-a716-446655440005",
|
||||
projectId: "550e8400-e29b-41d4-a716-446655440099",
|
||||
status: CertificateRequestStatus.PENDING
|
||||
};
|
||||
|
||||
(mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission);
|
||||
(mockCertificateRequestDAL.findById as any).mockResolvedValue(mockRequest);
|
||||
|
||||
await expect(service.getCertificateRequest(mockGetData)).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCertificateFromRequest", () => {
|
||||
const mockGetData = {
|
||||
actor: ActorType.USER,
|
||||
actorId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
actorAuthMethod: AuthMethod.EMAIL,
|
||||
actorOrgId: "550e8400-e29b-41d4-a716-446655440002",
|
||||
projectId: "550e8400-e29b-41d4-a716-446655440003",
|
||||
certificateRequestId: "550e8400-e29b-41d4-a716-446655440005"
|
||||
};
|
||||
|
||||
it("should get certificate from request successfully when certificate is attached", async () => {
|
||||
const mockPermission = {
|
||||
permission: createMongoAbility<ProjectPermissionSet>([
|
||||
{
|
||||
action: ProjectPermissionCertificateActions.Read,
|
||||
subject: ProjectPermissionSub.Certificates
|
||||
}
|
||||
])
|
||||
};
|
||||
const mockCertificate = {
|
||||
id: "550e8400-e29b-41d4-a716-446655440006",
|
||||
serialNumber: "123456",
|
||||
commonName: "test.example.com"
|
||||
};
|
||||
const mockRequestWithCert = {
|
||||
id: "550e8400-e29b-41d4-a716-446655440005",
|
||||
projectId: "550e8400-e29b-41d4-a716-446655440003",
|
||||
status: CertificateRequestStatus.ISSUED,
|
||||
certificate: mockCertificate,
|
||||
errorMessage: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
};
|
||||
const mockCertBody = {
|
||||
certificate: "-----BEGIN CERTIFICATE-----\nMOCK_CERT_PEM\n-----END CERTIFICATE-----"
|
||||
};
|
||||
const mockPrivateKey = {
|
||||
certPrivateKey: "-----BEGIN PRIVATE KEY-----\nMOCK_KEY_PEM\n-----END PRIVATE KEY-----"
|
||||
};
|
||||
|
||||
(mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission);
|
||||
(mockCertificateRequestDAL.findByIdWithCertificate as any).mockResolvedValue(mockRequestWithCert);
|
||||
(mockCertificateService.getCertBody as any).mockResolvedValue(mockCertBody);
|
||||
(mockCertificateService.getCertPrivateKey as any).mockResolvedValue(mockPrivateKey);
|
||||
|
||||
const result = await service.getCertificateFromRequest(mockGetData);
|
||||
|
||||
expect(mockCertificateRequestDAL.findByIdWithCertificate).toHaveBeenCalledWith(
|
||||
"550e8400-e29b-41d4-a716-446655440005"
|
||||
);
|
||||
expect(mockCertificateService.getCertBody).toHaveBeenCalledWith({
|
||||
id: "550e8400-e29b-41d4-a716-446655440006",
|
||||
actor: ActorType.USER,
|
||||
actorId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
actorAuthMethod: AuthMethod.EMAIL,
|
||||
actorOrgId: "550e8400-e29b-41d4-a716-446655440002"
|
||||
});
|
||||
expect(mockCertificateService.getCertPrivateKey).toHaveBeenCalledWith({
|
||||
id: "550e8400-e29b-41d4-a716-446655440006",
|
||||
actor: ActorType.USER,
|
||||
actorId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
actorAuthMethod: AuthMethod.EMAIL,
|
||||
actorOrgId: "550e8400-e29b-41d4-a716-446655440002"
|
||||
});
|
||||
expect(result).toEqual({
|
||||
status: CertificateRequestStatus.ISSUED,
|
||||
certificate: "-----BEGIN CERTIFICATE-----\nMOCK_CERT_PEM\n-----END CERTIFICATE-----",
|
||||
privateKey: "-----BEGIN PRIVATE KEY-----\nMOCK_KEY_PEM\n-----END PRIVATE KEY-----",
|
||||
serialNumber: "123456",
|
||||
errorMessage: null,
|
||||
createdAt: mockRequestWithCert.createdAt,
|
||||
updatedAt: mockRequestWithCert.updatedAt
|
||||
});
|
||||
});
|
||||
|
||||
it("should get certificate from request successfully when no certificate is attached", async () => {
|
||||
const mockPermission = {
|
||||
permission: createMongoAbility<ProjectPermissionSet>([
|
||||
{
|
||||
action: ProjectPermissionCertificateActions.Read,
|
||||
subject: ProjectPermissionSub.Certificates
|
||||
}
|
||||
])
|
||||
};
|
||||
const mockRequestWithoutCert = {
|
||||
id: "550e8400-e29b-41d4-a716-446655440007",
|
||||
projectId: "550e8400-e29b-41d4-a716-446655440003",
|
||||
status: CertificateRequestStatus.PENDING,
|
||||
certificate: null,
|
||||
errorMessage: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
};
|
||||
|
||||
(mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission);
|
||||
(mockCertificateRequestDAL.findByIdWithCertificate as any).mockResolvedValue(mockRequestWithoutCert);
|
||||
|
||||
const result = await service.getCertificateFromRequest(mockGetData);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: CertificateRequestStatus.PENDING,
|
||||
certificate: null,
|
||||
privateKey: null,
|
||||
serialNumber: null,
|
||||
errorMessage: null,
|
||||
createdAt: mockRequestWithoutCert.createdAt,
|
||||
updatedAt: mockRequestWithoutCert.updatedAt
|
||||
});
|
||||
});
|
||||
|
||||
it("should get certificate from request successfully when private key access is denied", async () => {
|
||||
const mockPermission = {
|
||||
permission: createMongoAbility<ProjectPermissionSet>([
|
||||
{
|
||||
action: ProjectPermissionCertificateActions.Read,
|
||||
subject: ProjectPermissionSub.Certificates
|
||||
}
|
||||
])
|
||||
};
|
||||
const mockCertificate = {
|
||||
id: "550e8400-e29b-41d4-a716-446655440008",
|
||||
serialNumber: "123456",
|
||||
commonName: "test.example.com"
|
||||
};
|
||||
const mockRequestWithCert = {
|
||||
id: "550e8400-e29b-41d4-a716-446655440005",
|
||||
projectId: "550e8400-e29b-41d4-a716-446655440003",
|
||||
status: CertificateRequestStatus.ISSUED,
|
||||
certificate: mockCertificate,
|
||||
errorMessage: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
};
|
||||
const mockCertBody = {
|
||||
certificate: "-----BEGIN CERTIFICATE-----\nMOCK_CERT_PEM\n-----END CERTIFICATE-----"
|
||||
};
|
||||
|
||||
(mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission);
|
||||
(mockCertificateRequestDAL.findByIdWithCertificate as any).mockResolvedValue(mockRequestWithCert);
|
||||
(mockCertificateService.getCertBody as any).mockResolvedValue(mockCertBody);
|
||||
(mockCertificateService.getCertPrivateKey as any).mockRejectedValue(new Error("Private key access denied"));
|
||||
|
||||
const result = await service.getCertificateFromRequest(mockGetData);
|
||||
|
||||
expect(mockCertificateRequestDAL.findByIdWithCertificate).toHaveBeenCalledWith(
|
||||
"550e8400-e29b-41d4-a716-446655440005"
|
||||
);
|
||||
expect(mockCertificateService.getCertBody).toHaveBeenCalledWith({
|
||||
id: "550e8400-e29b-41d4-a716-446655440008",
|
||||
actor: ActorType.USER,
|
||||
actorId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
actorAuthMethod: AuthMethod.EMAIL,
|
||||
actorOrgId: "550e8400-e29b-41d4-a716-446655440002"
|
||||
});
|
||||
expect(mockCertificateService.getCertPrivateKey).toHaveBeenCalledWith({
|
||||
id: "550e8400-e29b-41d4-a716-446655440008",
|
||||
actor: ActorType.USER,
|
||||
actorId: "550e8400-e29b-41d4-a716-446655440001",
|
||||
actorAuthMethod: AuthMethod.EMAIL,
|
||||
actorOrgId: "550e8400-e29b-41d4-a716-446655440002"
|
||||
});
|
||||
expect(result).toEqual({
|
||||
status: CertificateRequestStatus.ISSUED,
|
||||
certificate: "-----BEGIN CERTIFICATE-----\nMOCK_CERT_PEM\n-----END CERTIFICATE-----",
|
||||
privateKey: null,
|
||||
serialNumber: "123456",
|
||||
errorMessage: null,
|
||||
createdAt: mockRequestWithCert.createdAt,
|
||||
updatedAt: mockRequestWithCert.updatedAt
|
||||
});
|
||||
});
|
||||
|
||||
it("should get certificate from request with error message when failed", async () => {
|
||||
const mockPermission = {
|
||||
permission: createMongoAbility<ProjectPermissionSet>([
|
||||
{
|
||||
action: ProjectPermissionCertificateActions.Read,
|
||||
subject: ProjectPermissionSub.Certificates
|
||||
}
|
||||
])
|
||||
};
|
||||
const mockFailedRequest = {
|
||||
id: "550e8400-e29b-41d4-a716-446655440010",
|
||||
projectId: "550e8400-e29b-41d4-a716-446655440003",
|
||||
status: CertificateRequestStatus.FAILED,
|
||||
certificate: null,
|
||||
errorMessage: "Certificate issuance failed",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date()
|
||||
};
|
||||
|
||||
(mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission);
|
||||
(mockCertificateRequestDAL.findByIdWithCertificate as any).mockResolvedValue(mockFailedRequest);
|
||||
|
||||
const result = await service.getCertificateFromRequest(mockGetData);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: CertificateRequestStatus.FAILED,
|
||||
certificate: null,
|
||||
privateKey: null,
|
||||
serialNumber: null,
|
||||
errorMessage: "Certificate issuance failed",
|
||||
createdAt: mockFailedRequest.createdAt,
|
||||
updatedAt: mockFailedRequest.updatedAt
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw NotFoundError when certificate request does not exist", async () => {
|
||||
const mockPermission = {
|
||||
permission: createMongoAbility<ProjectPermissionSet>([
|
||||
{
|
||||
action: ProjectPermissionCertificateActions.Read,
|
||||
subject: ProjectPermissionSub.Certificates
|
||||
}
|
||||
])
|
||||
};
|
||||
|
||||
(mockPermissionService.getProjectPermission as any).mockResolvedValue(mockPermission);
|
||||
(mockCertificateRequestDAL.findByIdWithCertificate as any).mockResolvedValue(null);
|
||||
|
||||
await expect(service.getCertificateFromRequest(mockGetData)).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateCertificateRequestStatus", () => {
|
||||
it("should update certificate request status successfully", async () => {
|
||||
const mockRequest = {
|
||||
id: "550e8400-e29b-41d4-a716-446655440011",
|
||||
status: CertificateRequestStatus.PENDING
|
||||
};
|
||||
const mockUpdatedRequest = {
|
||||
id: "550e8400-e29b-41d4-a716-446655440011",
|
||||
status: CertificateRequestStatus.ISSUED
|
||||
};
|
||||
|
||||
(mockCertificateRequestDAL.findById as any).mockResolvedValue(mockRequest);
|
||||
(mockCertificateRequestDAL.updateStatus as any).mockResolvedValue(mockUpdatedRequest);
|
||||
|
||||
const result = await service.updateCertificateRequestStatus({
|
||||
certificateRequestId: "550e8400-e29b-41d4-a716-446655440011",
|
||||
status: CertificateRequestStatus.ISSUED
|
||||
});
|
||||
|
||||
expect(mockCertificateRequestDAL.findById).toHaveBeenCalledWith("550e8400-e29b-41d4-a716-446655440011");
|
||||
expect(mockCertificateRequestDAL.updateStatus).toHaveBeenCalledWith(
|
||||
"550e8400-e29b-41d4-a716-446655440011",
|
||||
CertificateRequestStatus.ISSUED,
|
||||
undefined
|
||||
);
|
||||
expect(result).toEqual(mockUpdatedRequest);
|
||||
});
|
||||
|
||||
it("should update certificate request status with error message", async () => {
|
||||
const mockRequest = {
|
||||
id: "550e8400-e29b-41d4-a716-446655440012",
|
||||
status: CertificateRequestStatus.PENDING
|
||||
};
|
||||
const mockUpdatedRequest = {
|
||||
id: "550e8400-e29b-41d4-a716-446655440012",
|
||||
status: CertificateRequestStatus.FAILED
|
||||
};
|
||||
|
||||
(mockCertificateRequestDAL.findById as any).mockResolvedValue(mockRequest);
|
||||
(mockCertificateRequestDAL.updateStatus as any).mockResolvedValue(mockUpdatedRequest);
|
||||
|
||||
const result = await service.updateCertificateRequestStatus({
|
||||
certificateRequestId: "550e8400-e29b-41d4-a716-446655440012",
|
||||
status: CertificateRequestStatus.FAILED,
|
||||
errorMessage: "Certificate issuance failed"
|
||||
});
|
||||
|
||||
expect(mockCertificateRequestDAL.updateStatus).toHaveBeenCalledWith(
|
||||
"550e8400-e29b-41d4-a716-446655440012",
|
||||
CertificateRequestStatus.FAILED,
|
||||
"Certificate issuance failed"
|
||||
);
|
||||
expect(result).toEqual(mockUpdatedRequest);
|
||||
});
|
||||
|
||||
it("should throw NotFoundError when certificate request does not exist", async () => {
|
||||
(mockCertificateRequestDAL.findById as any).mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.updateCertificateRequestStatus({
|
||||
certificateRequestId: "550e8400-e29b-41d4-a716-446655440013",
|
||||
status: CertificateRequestStatus.ISSUED
|
||||
})
|
||||
).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("attachCertificateToRequest", () => {
|
||||
it("should attach certificate to request successfully", async () => {
|
||||
const mockRequest = {
|
||||
id: "550e8400-e29b-41d4-a716-446655440014",
|
||||
status: CertificateRequestStatus.PENDING
|
||||
};
|
||||
const mockCertificate = {
|
||||
id: "550e8400-e29b-41d4-a716-446655440015"
|
||||
};
|
||||
const mockUpdatedRequest = {
|
||||
id: "550e8400-e29b-41d4-a716-446655440014",
|
||||
status: CertificateRequestStatus.ISSUED,
|
||||
certificateId: "550e8400-e29b-41d4-a716-446655440015"
|
||||
};
|
||||
|
||||
(mockCertificateRequestDAL.findById as any).mockResolvedValue(mockRequest);
|
||||
(mockCertificateDAL.findById as any).mockResolvedValue(mockCertificate);
|
||||
(mockCertificateRequestDAL.attachCertificate as any).mockResolvedValue(mockUpdatedRequest);
|
||||
|
||||
const result = await service.attachCertificateToRequest({
|
||||
certificateRequestId: "550e8400-e29b-41d4-a716-446655440014",
|
||||
certificateId: "550e8400-e29b-41d4-a716-446655440015"
|
||||
});
|
||||
|
||||
expect(mockCertificateRequestDAL.findById).toHaveBeenCalledWith("550e8400-e29b-41d4-a716-446655440014");
|
||||
expect(mockCertificateDAL.findById).toHaveBeenCalledWith("550e8400-e29b-41d4-a716-446655440015");
|
||||
expect(mockCertificateRequestDAL.attachCertificate).toHaveBeenCalledWith(
|
||||
"550e8400-e29b-41d4-a716-446655440014",
|
||||
"550e8400-e29b-41d4-a716-446655440015"
|
||||
);
|
||||
expect(result).toEqual(mockUpdatedRequest);
|
||||
});
|
||||
|
||||
it("should throw NotFoundError when certificate request does not exist", async () => {
|
||||
(mockCertificateRequestDAL.findById as any).mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.attachCertificateToRequest({
|
||||
certificateRequestId: "550e8400-e29b-41d4-a716-446655440016",
|
||||
certificateId: "550e8400-e29b-41d4-a716-446655440017"
|
||||
})
|
||||
).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
|
||||
it("should throw NotFoundError when certificate does not exist", async () => {
|
||||
const mockRequest = {
|
||||
id: "550e8400-e29b-41d4-a716-446655440018",
|
||||
status: CertificateRequestStatus.PENDING
|
||||
};
|
||||
|
||||
(mockCertificateRequestDAL.findById as any).mockResolvedValue(mockRequest);
|
||||
(mockCertificateDAL.findById as any).mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.attachCertificateToRequest({
|
||||
certificateRequestId: "550e8400-e29b-41d4-a716-446655440018",
|
||||
certificateId: "550e8400-e29b-41d4-a716-446655440019"
|
||||
})
|
||||
).rejects.toThrow(NotFoundError);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
import { ForbiddenError } from "@casl/ability";
|
||||
import { Knex } from "knex";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ActionProjectType } from "@app/db/schemas";
|
||||
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
|
||||
import {
|
||||
ProjectPermissionCertificateActions,
|
||||
ProjectPermissionSub
|
||||
} from "@app/ee/services/permission/project-permission";
|
||||
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
|
||||
import { TCertificateServiceFactory } from "@app/services/certificate/certificate-service";
|
||||
|
||||
import { ActorType } from "../auth/auth-type";
|
||||
import { TCertificateRequestDALFactory } from "./certificate-request-dal";
|
||||
import {
|
||||
CertificateRequestStatus,
|
||||
TAttachCertificateToRequestDTO,
|
||||
TCreateCertificateRequestDTO,
|
||||
TGetCertificateFromRequestDTO,
|
||||
TGetCertificateRequestDTO,
|
||||
TUpdateCertificateRequestStatusDTO
|
||||
} from "./certificate-request-types";
|
||||
|
||||
type TCertificateRequestServiceFactoryDep = {
|
||||
certificateRequestDAL: TCertificateRequestDALFactory;
|
||||
certificateDAL: Pick<TCertificateDALFactory, "findById">;
|
||||
certificateService: Pick<TCertificateServiceFactory, "getCertBody" | "getCertPrivateKey">;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||
};
|
||||
|
||||
export type TCertificateRequestServiceFactory = ReturnType<typeof certificateRequestServiceFactory>;
|
||||
|
||||
const certificateRequestDataSchema = z
|
||||
.object({
|
||||
profileId: z.string().uuid().optional(),
|
||||
caId: z.string().uuid().optional(),
|
||||
csr: z.string().min(1).optional(),
|
||||
commonName: z.string().max(255).optional(),
|
||||
altNames: z.string().max(1000).optional(),
|
||||
keyUsages: z.array(z.string()).max(20).optional(),
|
||||
extendedKeyUsages: z.array(z.string()).max(20).optional(),
|
||||
notBefore: z.date().optional(),
|
||||
notAfter: z.date().optional(),
|
||||
keyAlgorithm: z.string().max(100).optional(),
|
||||
signatureAlgorithm: z.string().max(100).optional(),
|
||||
metadata: z.string().max(2000).optional(),
|
||||
certificateId: z.string().optional()
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
// Must have either profileId or caId
|
||||
return data.profileId || data.caId;
|
||||
},
|
||||
{
|
||||
message: "Either profileId or caId must be provided"
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
// If notAfter is provided, it must be after notBefore
|
||||
if (data.notBefore && data.notAfter) {
|
||||
return data.notAfter > data.notBefore;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "notAfter must be after notBefore"
|
||||
}
|
||||
);
|
||||
|
||||
const validateCertificateRequestData = (data: unknown) => {
|
||||
try {
|
||||
return certificateRequestDataSchema.parse(data);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
throw new BadRequestError({
|
||||
message: `Invalid certificate request data: ${error.errors.map((e) => e.message).join(", ")}`
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const certificateRequestServiceFactory = ({
|
||||
certificateRequestDAL,
|
||||
certificateDAL,
|
||||
certificateService,
|
||||
permissionService
|
||||
}: TCertificateRequestServiceFactoryDep) => {
|
||||
const createCertificateRequest = async ({
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
projectId,
|
||||
tx,
|
||||
status,
|
||||
...requestData
|
||||
}: TCreateCertificateRequestDTO & { tx?: Knex }) => {
|
||||
if (actor !== ActorType.ACME_ACCOUNT) {
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.CertificateManager
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionCertificateActions.Create,
|
||||
ProjectPermissionSub.Certificates
|
||||
);
|
||||
}
|
||||
|
||||
// Validate input data before creating the request
|
||||
const validatedData = validateCertificateRequestData(requestData);
|
||||
|
||||
const certificateRequest = await certificateRequestDAL.create(
|
||||
{
|
||||
status,
|
||||
projectId,
|
||||
...validatedData
|
||||
},
|
||||
tx
|
||||
);
|
||||
|
||||
return certificateRequest;
|
||||
};
|
||||
|
||||
const getCertificateRequest = async ({
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
projectId,
|
||||
certificateRequestId
|
||||
}: TGetCertificateRequestDTO) => {
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.CertificateManager
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionCertificateActions.Read,
|
||||
ProjectPermissionSub.Certificates
|
||||
);
|
||||
|
||||
const certificateRequest = await certificateRequestDAL.findById(certificateRequestId);
|
||||
if (!certificateRequest) {
|
||||
throw new NotFoundError({ message: "Certificate request not found" });
|
||||
}
|
||||
|
||||
if (certificateRequest.projectId !== projectId) {
|
||||
throw new NotFoundError({ message: "Certificate request not found" });
|
||||
}
|
||||
|
||||
return certificateRequest;
|
||||
};
|
||||
|
||||
const getCertificateFromRequest = async ({
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
projectId,
|
||||
certificateRequestId
|
||||
}: TGetCertificateFromRequestDTO) => {
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor,
|
||||
actorId,
|
||||
projectId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
actionProjectType: ActionProjectType.CertificateManager
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionCertificateActions.Read,
|
||||
ProjectPermissionSub.Certificates
|
||||
);
|
||||
|
||||
const certificateRequest = await certificateRequestDAL.findByIdWithCertificate(certificateRequestId);
|
||||
if (!certificateRequest) {
|
||||
throw new NotFoundError({ message: "Certificate request not found" });
|
||||
}
|
||||
|
||||
if (certificateRequest.projectId !== projectId) {
|
||||
throw new NotFoundError({ message: "Certificate request not found" });
|
||||
}
|
||||
|
||||
// If no certificate is attached, return basic info
|
||||
if (!certificateRequest.certificate) {
|
||||
return {
|
||||
status: certificateRequest.status as CertificateRequestStatus,
|
||||
certificate: null,
|
||||
privateKey: null,
|
||||
serialNumber: null,
|
||||
errorMessage: certificateRequest.errorMessage || null,
|
||||
createdAt: certificateRequest.createdAt,
|
||||
updatedAt: certificateRequest.updatedAt
|
||||
};
|
||||
}
|
||||
|
||||
// Get certificate body (PEM data)
|
||||
const certBody = await certificateService.getCertBody({
|
||||
id: certificateRequest.certificate.id,
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
});
|
||||
|
||||
// Try to get private key (may fail if user doesn't have permission)
|
||||
let privateKey: string | null = null;
|
||||
try {
|
||||
const certPrivateKey = await certificateService.getCertPrivateKey({
|
||||
id: certificateRequest.certificate.id,
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId
|
||||
});
|
||||
privateKey = certPrivateKey.certPrivateKey;
|
||||
} catch (error) {
|
||||
// Private key access denied - continue without it
|
||||
privateKey = null;
|
||||
}
|
||||
|
||||
return {
|
||||
status: certificateRequest.status as CertificateRequestStatus,
|
||||
certificate: certBody.certificate,
|
||||
privateKey,
|
||||
serialNumber: certificateRequest.certificate.serialNumber,
|
||||
errorMessage: certificateRequest.errorMessage || null,
|
||||
createdAt: certificateRequest.createdAt,
|
||||
updatedAt: certificateRequest.updatedAt
|
||||
};
|
||||
};
|
||||
|
||||
const updateCertificateRequestStatus = async ({
|
||||
certificateRequestId,
|
||||
status,
|
||||
errorMessage
|
||||
}: TUpdateCertificateRequestStatusDTO) => {
|
||||
const certificateRequest = await certificateRequestDAL.findById(certificateRequestId);
|
||||
if (!certificateRequest) {
|
||||
throw new NotFoundError({ message: "Certificate request not found" });
|
||||
}
|
||||
|
||||
return certificateRequestDAL.updateStatus(certificateRequestId, status, errorMessage);
|
||||
};
|
||||
|
||||
const attachCertificateToRequest = async ({
|
||||
certificateRequestId,
|
||||
certificateId
|
||||
}: TAttachCertificateToRequestDTO) => {
|
||||
const certificateRequest = await certificateRequestDAL.findById(certificateRequestId);
|
||||
if (!certificateRequest) {
|
||||
throw new NotFoundError({ message: "Certificate request not found" });
|
||||
}
|
||||
|
||||
const certificate = await certificateDAL.findById(certificateId);
|
||||
if (!certificate) {
|
||||
throw new NotFoundError({ message: "Certificate not found" });
|
||||
}
|
||||
|
||||
return certificateRequestDAL.attachCertificate(certificateRequestId, certificateId);
|
||||
};
|
||||
|
||||
return {
|
||||
createCertificateRequest,
|
||||
getCertificateRequest,
|
||||
getCertificateFromRequest,
|
||||
updateCertificateRequestStatus,
|
||||
attachCertificateToRequest
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { TProjectPermission } from "@app/lib/types";
|
||||
|
||||
export enum CertificateRequestStatus {
|
||||
PENDING = "pending",
|
||||
ISSUED = "issued",
|
||||
FAILED = "failed"
|
||||
}
|
||||
|
||||
export type TCreateCertificateRequestDTO = TProjectPermission & {
|
||||
profileId?: string;
|
||||
caId?: string;
|
||||
csr?: string;
|
||||
commonName?: string;
|
||||
altNames?: string;
|
||||
keyUsages?: string[];
|
||||
extendedKeyUsages?: string[];
|
||||
notBefore?: Date;
|
||||
notAfter?: Date;
|
||||
keyAlgorithm?: string;
|
||||
signatureAlgorithm?: string;
|
||||
metadata?: string;
|
||||
status: CertificateRequestStatus;
|
||||
certificateId?: string;
|
||||
};
|
||||
|
||||
export type TGetCertificateRequestDTO = TProjectPermission & {
|
||||
certificateRequestId: string;
|
||||
};
|
||||
|
||||
export type TGetCertificateFromRequestDTO = TProjectPermission & {
|
||||
certificateRequestId: string;
|
||||
};
|
||||
|
||||
export type TUpdateCertificateRequestStatusDTO = {
|
||||
certificateRequestId: string;
|
||||
status: CertificateRequestStatus;
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
export type TAttachCertificateToRequestDTO = {
|
||||
certificateRequestId: string;
|
||||
certificateId: string;
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import RE2 from "re2";
|
||||
|
||||
import { BadRequestError } from "@app/lib/errors";
|
||||
|
||||
export const parseTtlToDays = (ttl: string): number => {
|
||||
const match = ttl.match(new RE2("^(\\d+)([dhm])$"));
|
||||
if (!match) {
|
||||
throw new BadRequestError({ message: `Invalid TTL format: ${ttl}` });
|
||||
}
|
||||
|
||||
const [, value, unit] = match;
|
||||
const num = parseInt(value, 10);
|
||||
|
||||
switch (unit) {
|
||||
case "d":
|
||||
return num;
|
||||
case "h":
|
||||
return Math.ceil(num / 24);
|
||||
case "m":
|
||||
return Math.ceil(num / (24 * 60));
|
||||
default:
|
||||
throw new BadRequestError({ message: `Invalid TTL unit: ${unit}` });
|
||||
}
|
||||
};
|
||||
|
||||
export const calculateRenewalThreshold = (
|
||||
profileRenewBeforeDays: number | undefined,
|
||||
certificateTtlInDays: number
|
||||
): number | undefined => {
|
||||
if (profileRenewBeforeDays === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (profileRenewBeforeDays >= certificateTtlInDays) {
|
||||
// If renewBeforeDays >= TTL, renew 1 day before expiry
|
||||
return Math.max(1, certificateTtlInDays - 1);
|
||||
}
|
||||
|
||||
return profileRenewBeforeDays;
|
||||
};
|
||||
@@ -11,7 +11,7 @@ import { TPkiAcmeAccountDALFactory } from "@app/ee/services/pki-acme/pki-acme-ac
|
||||
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
|
||||
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
|
||||
import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal";
|
||||
import { ACMESANType, CertificateOrderStatus, CertStatus } from "@app/services/certificate/certificate-types";
|
||||
import { CertStatus } from "@app/services/certificate/certificate-types";
|
||||
import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal";
|
||||
import { CaStatus } from "@app/services/certificate-authority/certificate-authority-enums";
|
||||
import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service";
|
||||
@@ -19,7 +19,8 @@ import {
|
||||
CertExtendedKeyUsageType,
|
||||
CertIncludeType,
|
||||
CertKeyUsageType,
|
||||
CertSubjectAttributeType
|
||||
CertSubjectAttributeType,
|
||||
CertSubjectAlternativeNameType
|
||||
} from "@app/services/certificate-common/certificate-constants";
|
||||
import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal";
|
||||
import { EnrollmentType, IssuerType } from "@app/services/certificate-profile/certificate-profile-types";
|
||||
@@ -42,7 +43,7 @@ describe("CertificateV3Service", () => {
|
||||
|
||||
const mockCertificateDAL: Pick<
|
||||
TCertificateDALFactory,
|
||||
"findOne" | "findById" | "updateById" | "transaction" | "create"
|
||||
"findOne" | "findById" | "updateById" | "transaction" | "create" | "find"
|
||||
> = {
|
||||
findOne: vi.fn(),
|
||||
findById: vi.fn(),
|
||||
@@ -57,7 +58,8 @@ describe("CertificateV3Service", () => {
|
||||
transaction: vi.fn().mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
|
||||
const mockTx = {};
|
||||
return callback(mockTx);
|
||||
})
|
||||
}),
|
||||
find: vi.fn().mockResolvedValue([])
|
||||
};
|
||||
|
||||
const mockCertificateSecretDAL: Pick<TCertificateSecretDALFactory, "findOne" | "create"> = {
|
||||
@@ -65,12 +67,24 @@ describe("CertificateV3Service", () => {
|
||||
create: vi.fn()
|
||||
};
|
||||
|
||||
const mockCertificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa"> = {
|
||||
findByIdWithAssociatedCa: vi.fn()
|
||||
const mockCertificateAuthorityDAL: Pick<
|
||||
TCertificateAuthorityDALFactory,
|
||||
"findByIdWithAssociatedCa" | "create" | "updateById" | "findById" | "transaction" | "findWithAssociatedCa"
|
||||
> = {
|
||||
findByIdWithAssociatedCa: vi.fn(),
|
||||
create: vi.fn().mockResolvedValue({ id: "ca-123" }),
|
||||
updateById: vi.fn().mockResolvedValue({ id: "ca-123" }),
|
||||
findById: vi.fn().mockResolvedValue({ id: "ca-123" }),
|
||||
findWithAssociatedCa: vi.fn().mockResolvedValue([]),
|
||||
transaction: vi.fn().mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
|
||||
const mockTx = {};
|
||||
return callback(mockTx);
|
||||
})
|
||||
};
|
||||
|
||||
const mockCertificateProfileDAL: Pick<TCertificateProfileDALFactory, "findByIdWithConfigs"> = {
|
||||
findByIdWithConfigs: vi.fn()
|
||||
const mockCertificateProfileDAL: Pick<TCertificateProfileDALFactory, "findByIdWithConfigs" | "findById"> = {
|
||||
findByIdWithConfigs: vi.fn(),
|
||||
findById: vi.fn()
|
||||
};
|
||||
|
||||
const mockCertificateTemplateV2Service: Pick<
|
||||
@@ -168,7 +182,11 @@ describe("CertificateV3Service", () => {
|
||||
kmsService: {
|
||||
generateKmsKey: vi.fn().mockResolvedValue("kms-key-123"),
|
||||
encryptWithKmsKey: vi.fn().mockResolvedValue(vi.fn().mockResolvedValue(Buffer.from("encrypted"))),
|
||||
decryptWithKmsKey: vi.fn().mockResolvedValue(vi.fn().mockResolvedValue(Buffer.from("decrypted")))
|
||||
decryptWithKmsKey: vi.fn().mockResolvedValue(vi.fn().mockResolvedValue(Buffer.from("decrypted"))),
|
||||
createCipherPairWithDataKey: vi.fn().mockResolvedValue({
|
||||
cipherTextBlob: Buffer.from("encrypted"),
|
||||
plainTextKey: Buffer.from("plainkey")
|
||||
})
|
||||
},
|
||||
projectDAL: {
|
||||
findOne: vi.fn().mockResolvedValue({ id: "project-123" }),
|
||||
@@ -178,7 +196,13 @@ describe("CertificateV3Service", () => {
|
||||
const mockTx = {};
|
||||
return callback(mockTx);
|
||||
})
|
||||
} as any
|
||||
} as any,
|
||||
certificateIssuanceQueue: {
|
||||
queueCertificateIssuance: vi.fn().mockResolvedValue(undefined)
|
||||
},
|
||||
certificateRequestService: {
|
||||
createCertificateRequest: vi.fn().mockResolvedValue({ id: "cert-req-123" })
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -267,6 +291,8 @@ describe("CertificateV3Service", () => {
|
||||
issuingCaCertificate: "issuing-ca",
|
||||
privateKey: "key",
|
||||
serialNumber: "123456",
|
||||
certificateId: "cert-1",
|
||||
commonName: "test.example.com",
|
||||
ca: {
|
||||
id: "ca-123",
|
||||
projectId: "project-123",
|
||||
@@ -326,8 +352,13 @@ describe("CertificateV3Service", () => {
|
||||
vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate);
|
||||
vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue(mockCertificateResult as any);
|
||||
vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(mockCertRecord);
|
||||
vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockCertRecord);
|
||||
vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCertRecord);
|
||||
|
||||
vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
|
||||
return callback(undefined as any);
|
||||
});
|
||||
|
||||
const result = await service.issueCertificateFromProfile({
|
||||
profileId,
|
||||
certificateRequest: mockCertificateRequest,
|
||||
@@ -461,6 +492,8 @@ describe("CertificateV3Service", () => {
|
||||
issuingCaCertificate: "issuing-ca",
|
||||
privateKey: "key",
|
||||
serialNumber: "123456",
|
||||
certificateId: "cert-1",
|
||||
commonName: "test.example.com",
|
||||
ca: {
|
||||
id: "ca-123",
|
||||
projectId: "project-123",
|
||||
@@ -503,8 +536,34 @@ describe("CertificateV3Service", () => {
|
||||
vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate);
|
||||
vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue(mockCertificateResultWithCa as any);
|
||||
vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(mockCertRecord);
|
||||
vi.mocked(mockCertificateDAL.findById).mockResolvedValue({
|
||||
id: "cert-1",
|
||||
serialNumber: "123456",
|
||||
status: "ACTIVE",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
projectId: "project-1",
|
||||
commonName: "test.example.com",
|
||||
friendlyName: "Test Algorithm Cert",
|
||||
notBefore: new Date(),
|
||||
notAfter: new Date(),
|
||||
caId: "ca-1",
|
||||
certificateTemplateId: "template-1",
|
||||
revokedAt: null,
|
||||
altNames: null,
|
||||
caCertId: null,
|
||||
keyUsages: null,
|
||||
extendedKeyUsages: null,
|
||||
revocationReason: null,
|
||||
pkiSubscriberId: null,
|
||||
profileId: null
|
||||
});
|
||||
vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCertRecord);
|
||||
|
||||
vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
|
||||
return callback(undefined as any);
|
||||
});
|
||||
|
||||
await service.issueCertificateFromProfile({
|
||||
profileId,
|
||||
certificateRequest: camelCaseRequest,
|
||||
@@ -729,8 +788,13 @@ describe("CertificateV3Service", () => {
|
||||
});
|
||||
vi.mocked(mockInternalCaService.signCertFromCa).mockResolvedValue(mockSignResult as any);
|
||||
vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(mockCertRecord);
|
||||
vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockCertRecord);
|
||||
vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCertRecord);
|
||||
|
||||
vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
|
||||
return callback(undefined as any);
|
||||
});
|
||||
|
||||
const result = await service.signCertificateFromProfile({
|
||||
profileId,
|
||||
csr: mockCSR,
|
||||
@@ -790,7 +854,7 @@ describe("CertificateV3Service", () => {
|
||||
|
||||
describe("orderCertificateFromProfile", () => {
|
||||
const mockCertificateOrder = {
|
||||
altNames: [{ type: ACMESANType.DNS, value: "example.com" }],
|
||||
altNames: [{ type: CertSubjectAlternativeNameType.DNS_NAME, value: "example.com" }],
|
||||
validity: { ttl: "30d" },
|
||||
commonName: "example.com",
|
||||
keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE],
|
||||
@@ -799,168 +863,6 @@ describe("CertificateV3Service", () => {
|
||||
keyAlgorithm: "RSA_2048"
|
||||
};
|
||||
|
||||
it("should create order successfully for API enrollment profile", async () => {
|
||||
const profileId = "profile-123";
|
||||
const mockProfile = {
|
||||
id: profileId,
|
||||
projectId: "project-123",
|
||||
enrollmentType: EnrollmentType.API,
|
||||
issuerType: IssuerType.CA,
|
||||
caId: "ca-123",
|
||||
certificateTemplateId: "template-123",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
slug: "test-profile-order",
|
||||
description: "Test order profile",
|
||||
estConfigId: null,
|
||||
apiConfigId: null
|
||||
};
|
||||
|
||||
const mockCA = {
|
||||
id: "ca-123",
|
||||
projectId: "project-123",
|
||||
externalCa: undefined,
|
||||
internalCa: {
|
||||
id: "internal-ca-123",
|
||||
parentCaId: null,
|
||||
type: "ROOT",
|
||||
friendlyName: "Test CA",
|
||||
organization: "Test Org",
|
||||
ou: "Test OU",
|
||||
country: "US",
|
||||
province: "CA",
|
||||
locality: "SF",
|
||||
commonName: "Test CA",
|
||||
dn: "CN=Test CA",
|
||||
serialNumber: "123",
|
||||
maxPathLength: null,
|
||||
keyAlgorithm: "RSA_2048",
|
||||
notBefore: undefined,
|
||||
notAfter: undefined,
|
||||
activeCaCertId: "cert-123",
|
||||
caId: "ca-123"
|
||||
},
|
||||
name: "Test CA",
|
||||
status: "ACTIVE",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
enableDirectIssuance: true
|
||||
};
|
||||
|
||||
const mockTemplate = {
|
||||
id: "template-123",
|
||||
name: "Test Order Template",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
projectId: "project-123",
|
||||
description: "Test template for ordering certificates",
|
||||
signatureAlgorithm: { defaultAlgorithm: "RSA-SHA256" },
|
||||
keyAlgorithm: { defaultKeyType: "RSA_2048" },
|
||||
attributes: [
|
||||
{
|
||||
type: CertSubjectAttributeType.COMMON_NAME,
|
||||
include: CertIncludeType.OPTIONAL,
|
||||
value: ["example.com"]
|
||||
}
|
||||
],
|
||||
subject: undefined,
|
||||
sans: undefined,
|
||||
keyUsages: undefined,
|
||||
extendedKeyUsages: undefined,
|
||||
algorithms: undefined,
|
||||
validity: undefined
|
||||
};
|
||||
|
||||
const mockCertificateResult = {
|
||||
certificate: "cert",
|
||||
certificateChain: "chain",
|
||||
issuingCaCertificate: "issuing-ca",
|
||||
privateKey: "key",
|
||||
serialNumber: "123456",
|
||||
ca: {
|
||||
id: "ca-123",
|
||||
projectId: "project-123",
|
||||
name: "Test CA",
|
||||
status: "ACTIVE",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
enableDirectIssuance: true,
|
||||
externalCa: undefined,
|
||||
internalCa: {
|
||||
id: "internal-ca-123",
|
||||
parentCaId: null,
|
||||
type: "ROOT",
|
||||
friendlyName: "Test CA",
|
||||
organization: "Test Org",
|
||||
ou: "Test OU",
|
||||
country: "US",
|
||||
province: "CA",
|
||||
locality: "SF",
|
||||
commonName: "Test CA",
|
||||
dn: "CN=Test CA",
|
||||
serialNumber: "123",
|
||||
maxPathLength: null,
|
||||
keyAlgorithm: "RSA_2048",
|
||||
notBefore: null,
|
||||
notAfter: null,
|
||||
activeCaCertId: "cert-123",
|
||||
caId: "ca-123"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const mockCertRecord = {
|
||||
id: "cert-123",
|
||||
serialNumber: "123456",
|
||||
status: "ACTIVE",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
projectId: "project-123",
|
||||
commonName: "example.com",
|
||||
friendlyName: "Test Order Cert",
|
||||
notBefore: new Date(),
|
||||
notAfter: new Date(),
|
||||
caId: "ca-123",
|
||||
certificateTemplateId: "template-123",
|
||||
revokedAt: null,
|
||||
altNames: JSON.stringify([{ type: "DNS", value: "example.com" }]),
|
||||
caCertId: null,
|
||||
keyUsages: ["DIGITAL_SIGNATURE"],
|
||||
extendedKeyUsages: ["SERVER_AUTH"],
|
||||
revocationReason: null,
|
||||
pkiSubscriberId: null,
|
||||
profileId: null
|
||||
};
|
||||
|
||||
vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile);
|
||||
vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({
|
||||
isValid: true,
|
||||
errors: [],
|
||||
warnings: []
|
||||
});
|
||||
vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA);
|
||||
vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate);
|
||||
vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue(mockCertificateResult as any);
|
||||
vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(mockCertRecord);
|
||||
vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCertRecord);
|
||||
|
||||
const result = await service.orderCertificateFromProfile({
|
||||
profileId,
|
||||
certificateOrder: mockCertificateOrder,
|
||||
...mockActor
|
||||
});
|
||||
|
||||
expect(result).toHaveProperty("orderId");
|
||||
expect(result).toHaveProperty("status", "valid");
|
||||
expect(result).toHaveProperty("certificate");
|
||||
expect(result.subjectAlternativeNames).toHaveLength(1);
|
||||
expect(result.subjectAlternativeNames[0]).toEqual({
|
||||
type: ACMESANType.DNS,
|
||||
value: "example.com",
|
||||
status: CertificateOrderStatus.VALID
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw ForbiddenRequestError when profile is not configured for API enrollment", async () => {
|
||||
const profileId = "profile-123";
|
||||
const mockProfile = {
|
||||
@@ -1097,6 +999,8 @@ describe("CertificateV3Service", () => {
|
||||
issuingCaCertificate: "ca-cert",
|
||||
privateKey: "key",
|
||||
serialNumber: "123456",
|
||||
certificateId: "cert-1",
|
||||
commonName: "test.example.com",
|
||||
ca: rsaCa as any
|
||||
});
|
||||
vi.mocked(mockCertificateDAL.findOne).mockResolvedValue({
|
||||
@@ -1143,6 +1047,31 @@ describe("CertificateV3Service", () => {
|
||||
pkiSubscriberId: null,
|
||||
profileId: null
|
||||
});
|
||||
vi.mocked(mockCertificateDAL.findById).mockResolvedValue({
|
||||
id: "cert-1",
|
||||
serialNumber: "123456",
|
||||
status: "ACTIVE",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
projectId: "project-1",
|
||||
commonName: "test.example.com",
|
||||
friendlyName: "Test Algorithm Cert",
|
||||
notBefore: new Date(),
|
||||
notAfter: new Date(),
|
||||
caId: "ca-1",
|
||||
certificateTemplateId: "template-1",
|
||||
revokedAt: null,
|
||||
altNames: null,
|
||||
caCertId: null,
|
||||
keyUsages: null,
|
||||
extendedKeyUsages: null,
|
||||
revocationReason: null,
|
||||
pkiSubscriberId: null,
|
||||
profileId: null
|
||||
});
|
||||
vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
|
||||
return callback(undefined as any);
|
||||
});
|
||||
|
||||
// Should not throw - RSA CA is compatible with RSA signature algorithms
|
||||
await expect(
|
||||
@@ -1229,6 +1158,8 @@ describe("CertificateV3Service", () => {
|
||||
issuingCaCertificate: "ca-cert",
|
||||
privateKey: "key",
|
||||
serialNumber: "123456",
|
||||
certificateId: "cert-1",
|
||||
commonName: "test.example.com",
|
||||
ca: ecCa as any
|
||||
});
|
||||
vi.mocked(mockCertificateDAL.findOne).mockResolvedValue({
|
||||
@@ -1275,6 +1206,31 @@ describe("CertificateV3Service", () => {
|
||||
pkiSubscriberId: null,
|
||||
profileId: null
|
||||
});
|
||||
vi.mocked(mockCertificateDAL.findById).mockResolvedValue({
|
||||
id: "cert-1",
|
||||
serialNumber: "123456",
|
||||
status: "ACTIVE",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
projectId: "project-1",
|
||||
commonName: "test.example.com",
|
||||
friendlyName: "Test Algorithm Cert",
|
||||
notBefore: new Date(),
|
||||
notAfter: new Date(),
|
||||
caId: "ca-1",
|
||||
certificateTemplateId: "template-1",
|
||||
revokedAt: null,
|
||||
altNames: null,
|
||||
caCertId: null,
|
||||
keyUsages: null,
|
||||
extendedKeyUsages: null,
|
||||
revocationReason: null,
|
||||
pkiSubscriberId: null,
|
||||
profileId: null
|
||||
});
|
||||
vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
|
||||
return callback(undefined as any);
|
||||
});
|
||||
|
||||
// Should not throw - EC CA is compatible with ECDSA signature algorithms
|
||||
await expect(
|
||||
@@ -1361,6 +1317,8 @@ describe("CertificateV3Service", () => {
|
||||
issuingCaCertificate: "ca-cert",
|
||||
privateKey: "key",
|
||||
serialNumber: "123456",
|
||||
certificateId: "cert-1",
|
||||
commonName: "test.example.com",
|
||||
ca: rsa8192Ca as any
|
||||
});
|
||||
vi.mocked(mockCertificateDAL.findOne).mockResolvedValue({
|
||||
@@ -1407,6 +1365,31 @@ describe("CertificateV3Service", () => {
|
||||
pkiSubscriberId: null,
|
||||
profileId: null
|
||||
});
|
||||
vi.mocked(mockCertificateDAL.findById).mockResolvedValue({
|
||||
id: "cert-1",
|
||||
serialNumber: "123456",
|
||||
status: "ACTIVE",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
projectId: "project-1",
|
||||
commonName: "test.example.com",
|
||||
friendlyName: "Test Algorithm Cert",
|
||||
notBefore: new Date(),
|
||||
notAfter: new Date(),
|
||||
caId: "ca-1",
|
||||
certificateTemplateId: "template-1",
|
||||
revokedAt: null,
|
||||
altNames: null,
|
||||
caCertId: null,
|
||||
keyUsages: null,
|
||||
extendedKeyUsages: null,
|
||||
revocationReason: null,
|
||||
pkiSubscriberId: null,
|
||||
profileId: null
|
||||
});
|
||||
vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
|
||||
return callback(undefined as any);
|
||||
});
|
||||
|
||||
// Should not throw - dynamic check supports new RSA key sizes
|
||||
await expect(
|
||||
@@ -1493,6 +1476,8 @@ describe("CertificateV3Service", () => {
|
||||
issuingCaCertificate: "ca-cert",
|
||||
privateKey: "key",
|
||||
serialNumber: "123456",
|
||||
certificateId: "cert-1",
|
||||
commonName: "test.example.com",
|
||||
ca: newEcCa as any
|
||||
});
|
||||
vi.mocked(mockCertificateDAL.findOne).mockResolvedValue({
|
||||
@@ -1539,6 +1524,31 @@ describe("CertificateV3Service", () => {
|
||||
pkiSubscriberId: null,
|
||||
profileId: null
|
||||
});
|
||||
vi.mocked(mockCertificateDAL.findById).mockResolvedValue({
|
||||
id: "cert-1",
|
||||
serialNumber: "123456",
|
||||
status: "ACTIVE",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
projectId: "project-1",
|
||||
commonName: "test.example.com",
|
||||
friendlyName: "Test Algorithm Cert",
|
||||
notBefore: new Date(),
|
||||
notAfter: new Date(),
|
||||
caId: "ca-1",
|
||||
certificateTemplateId: "template-1",
|
||||
revokedAt: null,
|
||||
altNames: null,
|
||||
caCertId: null,
|
||||
keyUsages: null,
|
||||
extendedKeyUsages: null,
|
||||
revocationReason: null,
|
||||
pkiSubscriberId: null,
|
||||
profileId: null
|
||||
});
|
||||
vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
|
||||
return callback(undefined as any);
|
||||
});
|
||||
|
||||
// Should not throw - dynamic check supports new EC curves
|
||||
await expect(
|
||||
@@ -1672,8 +1682,9 @@ describe("CertificateV3Service", () => {
|
||||
});
|
||||
|
||||
it("should successfully renew eligible certificate", async () => {
|
||||
// Mock the initial findById call
|
||||
vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockOriginalCert);
|
||||
vi.mocked(mockCertificateDAL.findById)
|
||||
.mockResolvedValueOnce(mockOriginalCert)
|
||||
.mockResolvedValueOnce({ ...mockOriginalCert, id: "cert-456", serialNumber: "789012" });
|
||||
vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue({ id: "secret-123", certId: "cert-123" } as any);
|
||||
vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile);
|
||||
vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA);
|
||||
@@ -1689,6 +1700,8 @@ describe("CertificateV3Service", () => {
|
||||
issuingCaCertificate: "issuing-ca",
|
||||
privateKey: "private-key",
|
||||
serialNumber: "789012",
|
||||
certificateId: "cert-456",
|
||||
commonName: "test.example.com",
|
||||
ca: mockCA
|
||||
});
|
||||
|
||||
@@ -2006,6 +2019,8 @@ describe("CertificateV3Service", () => {
|
||||
issuingCaCertificate: "issuing-ca",
|
||||
privateKey: "private-key",
|
||||
serialNumber: "789012",
|
||||
certificateId: "cert-456",
|
||||
commonName: "test.example.com",
|
||||
ca: mockCA
|
||||
});
|
||||
|
||||
|
||||
@@ -19,11 +19,8 @@ import { TCertificateBodyDALFactory } from "@app/services/certificate/certificat
|
||||
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
|
||||
import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal";
|
||||
import {
|
||||
CertExtendedKeyUsage,
|
||||
CertificateOrderStatus,
|
||||
CertKeyAlgorithm,
|
||||
CertKeyType,
|
||||
CertKeyUsage,
|
||||
CertSignatureAlgorithm,
|
||||
CertStatus
|
||||
} from "@app/services/certificate/certificate-types";
|
||||
@@ -59,15 +56,16 @@ import {
|
||||
bufferToString,
|
||||
buildCertificateSubjectFromTemplate,
|
||||
buildSubjectAlternativeNamesFromTemplate,
|
||||
convertExtendedKeyUsageArrayFromLegacy,
|
||||
convertExtendedKeyUsageArrayToLegacy,
|
||||
convertKeyUsageArrayFromLegacy,
|
||||
convertKeyUsageArrayToLegacy,
|
||||
mapEnumsForValidation,
|
||||
normalizeDateForApi,
|
||||
removeRootCaFromChain
|
||||
} from "../certificate-common/certificate-utils";
|
||||
import { TCertificateRequestServiceFactory } from "../certificate-request/certificate-request-service";
|
||||
import { CertificateRequestStatus } from "../certificate-request/certificate-request-types";
|
||||
import { TCertificateSyncDALFactory } from "../certificate-sync/certificate-sync-dal";
|
||||
import { TCertificateRequest } from "../certificate-template-v2/certificate-template-v2-types";
|
||||
import { TPkiSyncDALFactory } from "../pki-sync/pki-sync-dal";
|
||||
import { TPkiSyncQueueFactory } from "../pki-sync/pki-sync-queue";
|
||||
import { addRenewedCertificateToSyncs, triggerAutoSyncForCertificate } from "../pki-sync/pki-sync-utils";
|
||||
@@ -85,11 +83,17 @@ import {
|
||||
} from "./certificate-v3-types";
|
||||
|
||||
type TCertificateV3ServiceFactoryDep = {
|
||||
certificateDAL: Pick<TCertificateDALFactory, "findOne" | "findById" | "updateById" | "transaction" | "create">;
|
||||
certificateDAL: Pick<
|
||||
TCertificateDALFactory,
|
||||
"findOne" | "findById" | "updateById" | "transaction" | "create" | "find"
|
||||
>;
|
||||
certificateBodyDAL: Pick<TCertificateBodyDALFactory, "create">;
|
||||
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "findOne" | "create">;
|
||||
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa">;
|
||||
certificateProfileDAL: Pick<TCertificateProfileDALFactory, "findByIdWithConfigs">;
|
||||
certificateAuthorityDAL: Pick<
|
||||
TCertificateAuthorityDALFactory,
|
||||
"findByIdWithAssociatedCa" | "create" | "transaction" | "updateById" | "findWithAssociatedCa" | "findById"
|
||||
>;
|
||||
certificateProfileDAL: Pick<TCertificateProfileDALFactory, "findByIdWithConfigs" | "findById">;
|
||||
acmeAccountDAL: Pick<TPkiAcmeAccountDALFactory, "findById">;
|
||||
certificateTemplateV2Service: Pick<
|
||||
TCertificateTemplateV2ServiceFactory,
|
||||
@@ -103,8 +107,16 @@ type TCertificateV3ServiceFactoryDep = {
|
||||
>;
|
||||
pkiSyncDAL: Pick<TPkiSyncDALFactory, "find">;
|
||||
pkiSyncQueue: Pick<TPkiSyncQueueFactory, "queuePkiSyncSyncCertificatesById">;
|
||||
kmsService: Pick<TKmsServiceFactory, "generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey">;
|
||||
kmsService: Pick<
|
||||
TKmsServiceFactory,
|
||||
"generateKmsKey" | "encryptWithKmsKey" | "decryptWithKmsKey" | "createCipherPairWithDataKey"
|
||||
>;
|
||||
projectDAL: TProjectDALFactory;
|
||||
certificateIssuanceQueue: Pick<
|
||||
import("../certificate-authority/certificate-issuance-queue").TCertificateIssuanceQueueFactory,
|
||||
"queueCertificateIssuance"
|
||||
>;
|
||||
certificateRequestService: Pick<TCertificateRequestServiceFactory, "createCertificateRequest">;
|
||||
};
|
||||
|
||||
export type TCertificateV3ServiceFactory = ReturnType<typeof certificateV3ServiceFactory>;
|
||||
@@ -294,16 +306,62 @@ const extractCertificateFromBuffer = (certData: Buffer | { rawData: Buffer } | s
|
||||
return bufferToString(certData as unknown as Buffer);
|
||||
};
|
||||
|
||||
const parseKeyUsages = (keyUsages: unknown): CertKeyUsage[] => {
|
||||
const parseKeyUsages = (keyUsages: unknown): CertKeyUsageType[] => {
|
||||
if (!keyUsages) return [];
|
||||
if (Array.isArray(keyUsages)) return keyUsages as CertKeyUsage[];
|
||||
return (keyUsages as string).split(",").map((usage) => usage.trim() as CertKeyUsage);
|
||||
|
||||
const validKeyUsages = Object.values(CertKeyUsageType);
|
||||
|
||||
if (Array.isArray(keyUsages)) {
|
||||
return keyUsages.filter(
|
||||
(usage): usage is CertKeyUsageType =>
|
||||
typeof usage === "string" && validKeyUsages.includes(usage as CertKeyUsageType)
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof keyUsages === "string") {
|
||||
return keyUsages
|
||||
.split(",")
|
||||
.map((usage) => usage.trim())
|
||||
.filter((usage): usage is CertKeyUsageType => validKeyUsages.includes(usage as CertKeyUsageType));
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const parseExtendedKeyUsages = (extendedKeyUsages: unknown): CertExtendedKeyUsage[] => {
|
||||
const parseExtendedKeyUsages = (extendedKeyUsages: unknown): CertExtendedKeyUsageType[] => {
|
||||
if (!extendedKeyUsages) return [];
|
||||
if (Array.isArray(extendedKeyUsages)) return extendedKeyUsages as CertExtendedKeyUsage[];
|
||||
return (extendedKeyUsages as string).split(",").map((usage) => usage.trim() as CertExtendedKeyUsage);
|
||||
|
||||
const validExtendedKeyUsages = Object.values(CertExtendedKeyUsageType);
|
||||
|
||||
if (Array.isArray(extendedKeyUsages)) {
|
||||
return extendedKeyUsages.filter(
|
||||
(usage): usage is CertExtendedKeyUsageType =>
|
||||
typeof usage === "string" && validExtendedKeyUsages.includes(usage as CertExtendedKeyUsageType)
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof extendedKeyUsages === "string") {
|
||||
return extendedKeyUsages
|
||||
.split(",")
|
||||
.map((usage) => usage.trim())
|
||||
.filter((usage): usage is CertExtendedKeyUsageType =>
|
||||
validExtendedKeyUsages.includes(usage as CertExtendedKeyUsageType)
|
||||
);
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const convertEnumsToStringArray = <T extends string>(enumArray: T[]): string[] => {
|
||||
return enumArray.map((item) => item as string);
|
||||
};
|
||||
|
||||
const combineKeyUsageFlags = (keyUsages: string[]): number => {
|
||||
return keyUsages.reduce((acc: number, usage) => {
|
||||
const flag = x509.KeyUsageFlags[usage as keyof typeof x509.KeyUsageFlags];
|
||||
// eslint-disable-next-line no-bitwise
|
||||
return typeof flag === "number" ? acc | flag : acc;
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const isValidRenewalTiming = (renewBeforeDays: number, certificateExpiryDate: Date): boolean => {
|
||||
@@ -443,11 +501,7 @@ const generateSelfSignedCertificate = async ({
|
||||
...(certificateRequest.keyUsages?.length
|
||||
? [
|
||||
new x509.KeyUsagesExtension(
|
||||
(convertKeyUsageArrayToLegacy(certificateRequest.keyUsages) || []).reduce(
|
||||
// eslint-disable-next-line no-bitwise
|
||||
(acc: number, usage) => acc | x509.KeyUsageFlags[usage],
|
||||
0
|
||||
),
|
||||
combineKeyUsageFlags(convertKeyUsageArrayToLegacy(certificateRequest.keyUsages) || []),
|
||||
false
|
||||
)
|
||||
]
|
||||
@@ -761,6 +815,37 @@ const processSelfSignedCertificate = async ({
|
||||
};
|
||||
};
|
||||
|
||||
const detectSanType = (value: string): { type: CertSubjectAlternativeNameType; value: string } => {
|
||||
const isIpv4 = new RE2("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$").test(value);
|
||||
const isIpv6 = new RE2("^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$").test(value);
|
||||
|
||||
if (isIpv4 || isIpv6) {
|
||||
return {
|
||||
type: CertSubjectAlternativeNameType.IP_ADDRESS,
|
||||
value
|
||||
};
|
||||
}
|
||||
|
||||
if (new RE2("^[^@]+@[^@]+\\.[^@]+$").test(value)) {
|
||||
return {
|
||||
type: CertSubjectAlternativeNameType.EMAIL,
|
||||
value
|
||||
};
|
||||
}
|
||||
|
||||
if (new RE2("^[a-zA-Z][a-zA-Z0-9+.-]*:").test(value)) {
|
||||
return {
|
||||
type: CertSubjectAlternativeNameType.URI,
|
||||
value
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: CertSubjectAlternativeNameType.DNS_NAME,
|
||||
value
|
||||
};
|
||||
};
|
||||
|
||||
export const certificateV3ServiceFactory = ({
|
||||
certificateDAL,
|
||||
certificateBodyDAL,
|
||||
@@ -775,7 +860,9 @@ export const certificateV3ServiceFactory = ({
|
||||
pkiSyncDAL,
|
||||
pkiSyncQueue,
|
||||
kmsService,
|
||||
projectDAL
|
||||
projectDAL,
|
||||
certificateIssuanceQueue,
|
||||
certificateRequestService
|
||||
}: TCertificateV3ServiceFactoryDep) => {
|
||||
const issueCertificateFromProfile = async ({
|
||||
profileId,
|
||||
@@ -859,7 +946,7 @@ export const certificateV3ServiceFactory = ({
|
||||
const result = await certificateDAL.transaction(async (tx) => {
|
||||
const effectiveAlgorithms = getEffectiveAlgorithms(effectiveSignatureAlgorithm, effectiveKeyAlgorithm);
|
||||
|
||||
return processSelfSignedCertificate({
|
||||
const selfSignedResult = await processSelfSignedCertificate({
|
||||
certificateRequest,
|
||||
template,
|
||||
profile,
|
||||
@@ -871,9 +958,31 @@ export const certificateV3ServiceFactory = ({
|
||||
projectDAL,
|
||||
tx
|
||||
});
|
||||
|
||||
const certRequestResult = await certificateRequestService.createCertificateRequest({
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
projectId: profile.projectId,
|
||||
tx,
|
||||
profileId: profile.id,
|
||||
commonName: certificateRequest.commonName,
|
||||
altNames: certificateRequest.altNames?.map((san) => san.value).join(","),
|
||||
keyUsages: convertKeyUsageArrayToLegacy(certificateRequest.keyUsages),
|
||||
extendedKeyUsages: convertExtendedKeyUsageArrayToLegacy(certificateRequest.extendedKeyUsages),
|
||||
notBefore: certificateRequest.notBefore,
|
||||
notAfter: certificateRequest.notAfter,
|
||||
keyAlgorithm: effectiveKeyAlgorithm,
|
||||
signatureAlgorithm: effectiveSignatureAlgorithm,
|
||||
status: CertificateRequestStatus.ISSUED,
|
||||
certificateId: selfSignedResult.certificateData.id
|
||||
});
|
||||
|
||||
return { ...selfSignedResult, certificateRequestId: certRequestResult.id };
|
||||
});
|
||||
|
||||
const { selfSignedResult, certificateData } = result;
|
||||
const { selfSignedResult, certificateData, certificateRequestId } = result;
|
||||
|
||||
const subjectCommonName =
|
||||
(selfSignedResult.certificateSubject.common_name as string) ||
|
||||
@@ -899,6 +1008,7 @@ export const certificateV3ServiceFactory = ({
|
||||
privateKey: selfSignedResult.privateKey.toString("utf8"),
|
||||
serialNumber: selfSignedResult.serialNumber,
|
||||
certificateId: certificateData.id,
|
||||
certificateRequestId,
|
||||
projectId: profile.projectId,
|
||||
profileName: profile.slug,
|
||||
commonName: subjectCommonName
|
||||
@@ -917,8 +1027,16 @@ export const certificateV3ServiceFactory = ({
|
||||
validateCaSupport(ca, "direct certificate issuance");
|
||||
validateAlgorithmCompatibility(ca, template);
|
||||
|
||||
const { certificate, certificateChain, issuingCaCertificate, privateKey, serialNumber } =
|
||||
await internalCaService.issueCertFromCa({
|
||||
const {
|
||||
certificate,
|
||||
certificateChain,
|
||||
issuingCaCertificate,
|
||||
privateKey,
|
||||
serialNumber,
|
||||
cert,
|
||||
certificateRequestId
|
||||
} = await certificateDAL.transaction(async (tx) => {
|
||||
const certResult = await internalCaService.issueCertFromCa({
|
||||
caId: ca.id,
|
||||
friendlyName: certificateSubject.common_name || "Certificate",
|
||||
commonName: certificateSubject.common_name || "",
|
||||
@@ -934,25 +1052,50 @@ export const certificateV3ServiceFactory = ({
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
isFromProfile: true
|
||||
isFromProfile: true,
|
||||
tx
|
||||
});
|
||||
|
||||
const cert = await certificateDAL.findOne({ serialNumber, caId: ca.id });
|
||||
if (!cert) {
|
||||
throw new NotFoundError({ message: "Certificate was issued but could not be found in database" });
|
||||
}
|
||||
const certificateRecord = await certificateDAL.findById(certResult.certificateId, tx);
|
||||
if (!certificateRecord) {
|
||||
throw new NotFoundError({ message: "Certificate was issued but could not be found in database" });
|
||||
}
|
||||
|
||||
const finalRenewBeforeDays = calculateFinalRenewBeforeDays(
|
||||
profile,
|
||||
certificateRequest.validity.ttl,
|
||||
new Date(cert.notAfter)
|
||||
);
|
||||
const finalRenewBeforeDays = calculateFinalRenewBeforeDays(
|
||||
profile,
|
||||
certificateRequest.validity.ttl,
|
||||
new Date(certificateRecord.notAfter)
|
||||
);
|
||||
|
||||
const updateData: { profileId: string; renewBeforeDays?: number } = { profileId };
|
||||
if (finalRenewBeforeDays !== undefined) {
|
||||
updateData.renewBeforeDays = finalRenewBeforeDays;
|
||||
}
|
||||
await certificateDAL.updateById(cert.id, updateData);
|
||||
const updateData: { profileId: string; renewBeforeDays?: number } = { profileId };
|
||||
if (finalRenewBeforeDays !== undefined) {
|
||||
updateData.renewBeforeDays = finalRenewBeforeDays;
|
||||
}
|
||||
await certificateDAL.updateById(certificateRecord.id, updateData, tx);
|
||||
|
||||
const certRequestResult = await certificateRequestService.createCertificateRequest({
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
projectId: profile.projectId,
|
||||
tx,
|
||||
caId: ca.id,
|
||||
profileId: profile.id,
|
||||
commonName: certificateRequest.commonName,
|
||||
altNames: certificateRequest.altNames?.map((san) => san.value).join(","),
|
||||
keyUsages: convertKeyUsageArrayToLegacy(certificateRequest.keyUsages),
|
||||
extendedKeyUsages: convertExtendedKeyUsageArrayToLegacy(certificateRequest.extendedKeyUsages),
|
||||
notBefore: certificateRequest.notBefore,
|
||||
notAfter: certificateRequest.notAfter,
|
||||
keyAlgorithm: effectiveKeyAlgorithm,
|
||||
signatureAlgorithm: effectiveSignatureAlgorithm,
|
||||
status: CertificateRequestStatus.ISSUED,
|
||||
certificateId: certResult.certificateId
|
||||
});
|
||||
|
||||
return { ...certResult, cert: certificateRecord, certificateRequestId: certRequestResult.id };
|
||||
});
|
||||
|
||||
let finalCertificateChain = bufferToString(certificateChain);
|
||||
if (removeRootsFromChain) {
|
||||
@@ -966,6 +1109,7 @@ export const certificateV3ServiceFactory = ({
|
||||
privateKey: bufferToString(privateKey),
|
||||
serialNumber,
|
||||
certificateId: cert.id,
|
||||
certificateRequestId,
|
||||
projectId: profile.projectId,
|
||||
profileName: profile.slug,
|
||||
commonName: cert.commonName || ""
|
||||
@@ -1049,33 +1193,64 @@ export const certificateV3ServiceFactory = ({
|
||||
const effectiveSignatureAlgorithm = extractedSignatureAlgorithm;
|
||||
const effectiveKeyAlgorithm = extractedKeyAlgorithm;
|
||||
|
||||
const { certificate, certificateChain, issuingCaCertificate, serialNumber } =
|
||||
await internalCaService.signCertFromCa({
|
||||
isInternal: true,
|
||||
caId: ca.id,
|
||||
csr,
|
||||
ttl: validity.ttl,
|
||||
altNames: undefined,
|
||||
notBefore: normalizeDateForApi(notBefore),
|
||||
notAfter: normalizeDateForApi(notAfter),
|
||||
signatureAlgorithm: effectiveSignatureAlgorithm,
|
||||
keyAlgorithm: effectiveKeyAlgorithm,
|
||||
isFromProfile: true
|
||||
const { certificate, certificateChain, issuingCaCertificate, serialNumber, cert, certificateRequestId } =
|
||||
await certificateDAL.transaction(async (tx) => {
|
||||
const certResult = await internalCaService.signCertFromCa({
|
||||
isInternal: true,
|
||||
caId: ca.id,
|
||||
csr,
|
||||
ttl: validity.ttl,
|
||||
altNames: undefined,
|
||||
notBefore: normalizeDateForApi(notBefore),
|
||||
notAfter: normalizeDateForApi(notAfter),
|
||||
signatureAlgorithm: effectiveSignatureAlgorithm,
|
||||
keyAlgorithm: effectiveKeyAlgorithm,
|
||||
isFromProfile: true,
|
||||
tx
|
||||
});
|
||||
|
||||
const signedCertRecord = await certificateDAL.findById(certResult.certificateId, tx);
|
||||
if (!signedCertRecord) {
|
||||
throw new NotFoundError({ message: "Certificate was signed but could not be found in database" });
|
||||
}
|
||||
|
||||
const finalRenewBeforeDays = calculateFinalRenewBeforeDays(
|
||||
profile,
|
||||
validity.ttl,
|
||||
new Date(signedCertRecord.notAfter)
|
||||
);
|
||||
|
||||
const updateData: { profileId: string; renewBeforeDays?: number } = { profileId };
|
||||
if (finalRenewBeforeDays !== undefined) {
|
||||
updateData.renewBeforeDays = finalRenewBeforeDays;
|
||||
}
|
||||
await certificateDAL.updateById(signedCertRecord.id, updateData, tx);
|
||||
|
||||
const certRequestResult = await certificateRequestService.createCertificateRequest({
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
projectId: profile.projectId,
|
||||
tx,
|
||||
caId: ca.id,
|
||||
profileId: profile.id,
|
||||
csr,
|
||||
commonName: mappedCertificateRequest.commonName,
|
||||
altNames: mappedCertificateRequest.subjectAlternativeNames?.map((san) => san.value).join(","),
|
||||
keyUsages: convertKeyUsageArrayToLegacy(mappedCertificateRequest.keyUsages),
|
||||
extendedKeyUsages: convertExtendedKeyUsageArrayToLegacy(mappedCertificateRequest.extendedKeyUsages),
|
||||
notBefore,
|
||||
notAfter,
|
||||
keyAlgorithm: effectiveKeyAlgorithm,
|
||||
signatureAlgorithm: effectiveSignatureAlgorithm,
|
||||
status: CertificateRequestStatus.ISSUED,
|
||||
certificateId: certResult.certificateId
|
||||
});
|
||||
|
||||
return { ...certResult, cert: signedCertRecord, certificateRequestId: certRequestResult.id };
|
||||
});
|
||||
|
||||
const cert = await certificateDAL.findOne({ serialNumber, caId: ca.id });
|
||||
if (!cert) {
|
||||
throw new NotFoundError({ message: "Certificate was signed but could not be found in database" });
|
||||
}
|
||||
|
||||
const finalRenewBeforeDays = calculateFinalRenewBeforeDays(profile, validity.ttl, new Date(cert.notAfter));
|
||||
|
||||
const updateData2: { profileId: string; renewBeforeDays?: number } = { profileId };
|
||||
if (finalRenewBeforeDays !== undefined) {
|
||||
updateData2.renewBeforeDays = finalRenewBeforeDays;
|
||||
}
|
||||
await certificateDAL.updateById(cert.id, updateData2);
|
||||
|
||||
const certificateString = extractCertificateFromBuffer(certificate as unknown as Buffer);
|
||||
let certificateChainString = extractCertificateFromBuffer(certificateChain as unknown as Buffer);
|
||||
if (removeRootsFromChain) {
|
||||
@@ -1088,6 +1263,7 @@ export const certificateV3ServiceFactory = ({
|
||||
certificateChain: certificateChainString,
|
||||
serialNumber,
|
||||
certificateId: cert.id,
|
||||
certificateRequestId,
|
||||
projectId: profile.projectId,
|
||||
profileName: profile.slug,
|
||||
commonName: cert.commonName || ""
|
||||
@@ -1100,8 +1276,7 @@ export const certificateV3ServiceFactory = ({
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
removeRootsFromChain
|
||||
actorOrgId
|
||||
}: TOrderCertificateFromProfileDTO): Promise<TCertificateOrderResponse> => {
|
||||
const profile = await validateProfileAndPermissions(
|
||||
profileId,
|
||||
@@ -1115,37 +1290,41 @@ export const certificateV3ServiceFactory = ({
|
||||
EnrollmentType.API
|
||||
);
|
||||
|
||||
const certificateRequest = {
|
||||
commonName: certificateOrder.commonName,
|
||||
keyUsages: certificateOrder.keyUsages,
|
||||
extendedKeyUsages: certificateOrder.extendedKeyUsages,
|
||||
subjectAlternativeNames: certificateOrder.altNames.map((san) => {
|
||||
let certType: CertSubjectAlternativeNameType;
|
||||
switch (san.type) {
|
||||
case "dns":
|
||||
certType = CertSubjectAlternativeNameType.DNS_NAME;
|
||||
break;
|
||||
case "ip":
|
||||
certType = CertSubjectAlternativeNameType.IP_ADDRESS;
|
||||
break;
|
||||
default:
|
||||
throw new BadRequestError({
|
||||
message: `Unsupported Subject Alternative Name type: ${san.type as string}`
|
||||
});
|
||||
}
|
||||
return {
|
||||
type: certType,
|
||||
value: san.value
|
||||
};
|
||||
}),
|
||||
validity: certificateOrder.validity,
|
||||
notBefore: certificateOrder.notBefore,
|
||||
notAfter: certificateOrder.notAfter,
|
||||
signatureAlgorithm: certificateOrder.signatureAlgorithm,
|
||||
keyAlgorithm: certificateOrder.keyAlgorithm
|
||||
};
|
||||
let certificateRequest: TCertificateRequest;
|
||||
let extractedKeyAlgorithm: string | undefined;
|
||||
let extractedSignatureAlgorithm: string | undefined;
|
||||
|
||||
if (certificateOrder.csr) {
|
||||
certificateRequest = extractCertificateRequestFromCSR(certificateOrder.csr);
|
||||
const algorithms = extractAlgorithmsFromCSR(certificateOrder.csr);
|
||||
extractedKeyAlgorithm = algorithms.keyAlgorithm;
|
||||
extractedSignatureAlgorithm = algorithms.signatureAlgorithm;
|
||||
certificateRequest.validity = certificateOrder.validity;
|
||||
if (certificateOrder.notBefore && certificateOrder.notAfter) {
|
||||
certificateRequest.notBefore = certificateOrder.notBefore;
|
||||
certificateRequest.notAfter = certificateOrder.notAfter;
|
||||
}
|
||||
} else {
|
||||
certificateRequest = {
|
||||
commonName: certificateOrder.commonName,
|
||||
keyUsages: certificateOrder.keyUsages,
|
||||
extendedKeyUsages: certificateOrder.extendedKeyUsages,
|
||||
subjectAlternativeNames: certificateOrder.altNames,
|
||||
validity: certificateOrder.validity,
|
||||
notBefore: certificateOrder.notBefore,
|
||||
notAfter: certificateOrder.notAfter,
|
||||
signatureAlgorithm: certificateOrder.signatureAlgorithm,
|
||||
keyAlgorithm: certificateOrder.keyAlgorithm
|
||||
};
|
||||
}
|
||||
|
||||
const mappedCertificateRequest = mapEnumsForValidation(certificateRequest);
|
||||
|
||||
if (certificateOrder.csr) {
|
||||
mappedCertificateRequest.keyAlgorithm = extractedKeyAlgorithm;
|
||||
mappedCertificateRequest.signatureAlgorithm = extractedSignatureAlgorithm;
|
||||
}
|
||||
|
||||
const validationResult = await certificateTemplateV2Service.validateCertificateRequest(
|
||||
profile.certificateTemplateId,
|
||||
mappedCertificateRequest
|
||||
@@ -1171,42 +1350,61 @@ export const certificateV3ServiceFactory = ({
|
||||
const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL;
|
||||
|
||||
if (caType === CaType.INTERNAL) {
|
||||
const certificateResult = await issueCertificateFromProfile({
|
||||
profileId,
|
||||
certificateRequest,
|
||||
throw new BadRequestError({
|
||||
message: "Certificate ordering is not supported for the specified CA type"
|
||||
});
|
||||
}
|
||||
|
||||
if (caType === CaType.ACME || caType === CaType.AZURE_AD_CS) {
|
||||
const orderId = randomUUID();
|
||||
|
||||
const certRequest = await certificateRequestService.createCertificateRequest({
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
removeRootsFromChain
|
||||
projectId: profile.projectId,
|
||||
caId: ca.id,
|
||||
profileId: profile.id,
|
||||
commonName: certificateOrder.commonName || "",
|
||||
keyUsages: certificateOrder.keyUsages ? convertEnumsToStringArray(certificateOrder.keyUsages) : [],
|
||||
extendedKeyUsages: certificateOrder.extendedKeyUsages
|
||||
? convertEnumsToStringArray(certificateOrder.extendedKeyUsages)
|
||||
: [],
|
||||
keyAlgorithm: certificateOrder.keyAlgorithm || "",
|
||||
signatureAlgorithm: certificateOrder.signatureAlgorithm || "",
|
||||
altNames: certificateOrder.altNames?.map((san) => san.value).join(",") || "",
|
||||
notBefore: certificateOrder.notBefore,
|
||||
notAfter: certificateOrder.notAfter,
|
||||
status: CertificateRequestStatus.PENDING
|
||||
});
|
||||
|
||||
const orderId = randomUUID();
|
||||
await certificateIssuanceQueue.queueCertificateIssuance({
|
||||
certificateId: orderId,
|
||||
profileId: profile.id,
|
||||
caId: profile.caId || "",
|
||||
ttl: certificateOrder.validity?.ttl || "1y",
|
||||
signatureAlgorithm: certificateOrder.signatureAlgorithm || "",
|
||||
keyAlgorithm: certificateRequest.keyAlgorithm || "",
|
||||
commonName: certificateRequest.commonName || "",
|
||||
altNames: certificateRequest.subjectAlternativeNames?.map((san) => san.value) || [],
|
||||
keyUsages: certificateRequest.keyUsages ? convertEnumsToStringArray(certificateRequest.keyUsages) : [],
|
||||
extendedKeyUsages: certificateRequest.extendedKeyUsages
|
||||
? convertEnumsToStringArray(certificateRequest.extendedKeyUsages)
|
||||
: [],
|
||||
certificateRequestId: certRequest.id,
|
||||
csr: certificateOrder.csr
|
||||
});
|
||||
|
||||
return {
|
||||
orderId,
|
||||
status: CertificateOrderStatus.VALID,
|
||||
subjectAlternativeNames: certificateOrder.altNames.map((san) => ({
|
||||
type: san.type,
|
||||
value: san.value,
|
||||
status: CertificateOrderStatus.VALID
|
||||
})),
|
||||
authorizations: [],
|
||||
finalize: `/api/v1/cert-manager/certificates/orders/${orderId}/completed`,
|
||||
certificate: certificateResult.certificate,
|
||||
projectId: certificateResult.projectId,
|
||||
profileName: certificateResult.profileName
|
||||
certificateRequestId: certRequest.id,
|
||||
projectId: certRequest.projectId,
|
||||
profileName: profile.slug
|
||||
};
|
||||
}
|
||||
|
||||
if (caType === CaType.ACME) {
|
||||
throw new BadRequestError({
|
||||
message: "ACME certificate ordering via profiles is not yet implemented."
|
||||
});
|
||||
}
|
||||
|
||||
throw new BadRequestError({
|
||||
message: `Certificate ordering is not supported for CA type: ${caType}`
|
||||
message: "Certificate ordering is not supported for the specified CA type"
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1218,7 +1416,9 @@ export const certificateV3ServiceFactory = ({
|
||||
actorOrgId,
|
||||
internal = false,
|
||||
removeRootsFromChain
|
||||
}: TRenewCertificateDTO & { internal?: boolean }): Promise<TCertificateFromProfileResponse> => {
|
||||
}: Omit<TRenewCertificateDTO, "certificateRequestId"> & {
|
||||
internal?: boolean;
|
||||
}): Promise<TCertificateFromProfileResponse> => {
|
||||
const renewalResult = await certificateDAL.transaction(async (tx) => {
|
||||
const originalCert = await certificateDAL.findById(certificateId, tx);
|
||||
if (!originalCert) {
|
||||
@@ -1231,14 +1431,30 @@ export const certificateV3ServiceFactory = ({
|
||||
});
|
||||
}
|
||||
|
||||
const originalSignatureAlgorithm = originalCert.signatureAlgorithm as CertSignatureAlgorithm;
|
||||
const originalKeyAlgorithm = originalCert.keyAlgorithm as CertKeyAlgorithm;
|
||||
// Validate and cast algorithms with fallbacks
|
||||
let originalSignatureAlgorithm = Object.values(CertSignatureAlgorithm).includes(
|
||||
originalCert.signatureAlgorithm as CertSignatureAlgorithm
|
||||
)
|
||||
? (originalCert.signatureAlgorithm as CertSignatureAlgorithm)
|
||||
: CertSignatureAlgorithm.RSA_SHA256;
|
||||
let originalKeyAlgorithm = Object.values(CertKeyAlgorithm).includes(originalCert.keyAlgorithm as CertKeyAlgorithm)
|
||||
? (originalCert.keyAlgorithm as CertKeyAlgorithm)
|
||||
: CertKeyAlgorithm.RSA_2048;
|
||||
|
||||
// For external CA certificates without stored algorithm info, extract from certificate
|
||||
if (!originalSignatureAlgorithm || !originalKeyAlgorithm) {
|
||||
throw new BadRequestError({
|
||||
message:
|
||||
"Original certificate does not have algorithm information stored. Cannot renew certificate issued before algorithm tracking was implemented."
|
||||
});
|
||||
const isExternalCA = originalCert.caId && !originalCert.caId.startsWith("internal");
|
||||
|
||||
if (isExternalCA) {
|
||||
// For external CA certificates, we can extract algorithm info from the cert or use defaults
|
||||
originalSignatureAlgorithm = originalSignatureAlgorithm || CertSignatureAlgorithm.RSA_SHA256;
|
||||
originalKeyAlgorithm = originalKeyAlgorithm || CertKeyAlgorithm.RSA_2048;
|
||||
} else {
|
||||
throw new BadRequestError({
|
||||
message:
|
||||
"Original certificate does not have algorithm information stored. Cannot renew certificate issued before algorithm tracking was implemented."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let profile = null;
|
||||
@@ -1305,7 +1521,10 @@ export const certificateV3ServiceFactory = ({
|
||||
});
|
||||
}
|
||||
|
||||
validateCaSupport(ca, "direct certificate issuance");
|
||||
const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL;
|
||||
if (caType === CaType.INTERNAL) {
|
||||
validateCaSupport(ca, "direct certificate issuance");
|
||||
}
|
||||
}
|
||||
|
||||
const templateId = profile?.certificateTemplateId || originalCert.certificateTemplateId;
|
||||
@@ -1331,42 +1550,10 @@ export const certificateV3ServiceFactory = ({
|
||||
|
||||
const certificateRequest = {
|
||||
commonName: originalCert.commonName || undefined,
|
||||
keyUsages: convertKeyUsageArrayFromLegacy(parseKeyUsages(originalCert.keyUsages)),
|
||||
extendedKeyUsages: convertExtendedKeyUsageArrayFromLegacy(
|
||||
parseExtendedKeyUsages(originalCert.extendedKeyUsages)
|
||||
),
|
||||
keyUsages: parseKeyUsages(originalCert.keyUsages),
|
||||
extendedKeyUsages: parseExtendedKeyUsages(originalCert.extendedKeyUsages),
|
||||
subjectAlternativeNames: originalCert.altNames
|
||||
? originalCert.altNames.split(",").map((san) => {
|
||||
const trimmed = san.trim();
|
||||
|
||||
const isIpv4 = new RE2("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$").test(trimmed);
|
||||
const isIpv6 = new RE2("^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$").test(trimmed);
|
||||
if (isIpv4 || isIpv6) {
|
||||
return {
|
||||
type: CertSubjectAlternativeNameType.IP_ADDRESS,
|
||||
value: trimmed
|
||||
};
|
||||
}
|
||||
|
||||
if (new RE2("^[^@]+@[^@]+\\.[^@]+$").test(trimmed)) {
|
||||
return {
|
||||
type: CertSubjectAlternativeNameType.EMAIL,
|
||||
value: trimmed
|
||||
};
|
||||
}
|
||||
|
||||
if (new RE2("^[a-zA-Z][a-zA-Z0-9+.-]*:").test(trimmed)) {
|
||||
return {
|
||||
type: CertSubjectAlternativeNameType.URI,
|
||||
value: trimmed
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: CertSubjectAlternativeNameType.DNS_NAME,
|
||||
value: trimmed
|
||||
};
|
||||
})
|
||||
? originalCert.altNames.split(",").map((san) => detectSanType(san.trim()))
|
||||
: [],
|
||||
validity: {
|
||||
ttl
|
||||
@@ -1410,41 +1597,66 @@ export const certificateV3ServiceFactory = ({
|
||||
throw new NotFoundError({ message: "Certificate Authority not found for CA-signed certificate renewal" });
|
||||
}
|
||||
|
||||
validateAlgorithmCompatibility(ca, {
|
||||
algorithms: template?.algorithms
|
||||
} as { algorithms?: { signature?: string[] } });
|
||||
const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL;
|
||||
|
||||
const caResult = await internalCaService.issueCertFromCa({
|
||||
caId: ca.id,
|
||||
friendlyName: originalCert.friendlyName || originalCert.commonName || "Renewed Certificate",
|
||||
commonName: originalCert.commonName || "",
|
||||
altNames: originalCert.altNames || "",
|
||||
ttl,
|
||||
notBefore: normalizeDateForApi(notBefore),
|
||||
notAfter: normalizeDateForApi(notAfter),
|
||||
keyUsages: parseKeyUsages(originalCert.keyUsages),
|
||||
extendedKeyUsages: parseExtendedKeyUsages(originalCert.extendedKeyUsages),
|
||||
signatureAlgorithm: originalSignatureAlgorithm,
|
||||
keyAlgorithm: originalKeyAlgorithm,
|
||||
isFromProfile: true,
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
internal: true,
|
||||
tx
|
||||
});
|
||||
// Only validate algorithm compatibility for internal CAs
|
||||
if (caType === CaType.INTERNAL) {
|
||||
validateAlgorithmCompatibility(ca, {
|
||||
algorithms: template?.algorithms
|
||||
} as { algorithms?: { signature?: string[] } });
|
||||
}
|
||||
|
||||
certificate = caResult.certificate;
|
||||
certificateChain = caResult.certificateChain;
|
||||
issuingCaCertificate = caResult.issuingCaCertificate;
|
||||
serialNumber = caResult.serialNumber;
|
||||
if (caType === CaType.INTERNAL) {
|
||||
// Internal CA renewal - existing logic
|
||||
const caResult = await internalCaService.issueCertFromCa({
|
||||
caId: ca.id,
|
||||
friendlyName: originalCert.friendlyName || originalCert.commonName || "Renewed Certificate",
|
||||
commonName: originalCert.commonName || "",
|
||||
altNames: originalCert.altNames || "",
|
||||
ttl,
|
||||
notBefore: normalizeDateForApi(notBefore),
|
||||
notAfter: normalizeDateForApi(notAfter),
|
||||
keyUsages: convertKeyUsageArrayToLegacy(parseKeyUsages(originalCert.keyUsages)),
|
||||
extendedKeyUsages: convertExtendedKeyUsageArrayToLegacy(
|
||||
parseExtendedKeyUsages(originalCert.extendedKeyUsages)
|
||||
),
|
||||
signatureAlgorithm: originalSignatureAlgorithm,
|
||||
keyAlgorithm: originalKeyAlgorithm,
|
||||
isFromProfile: true,
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
internal: true,
|
||||
tx
|
||||
});
|
||||
|
||||
const foundCert = await certificateDAL.findOne({ serialNumber, caId: ca.id }, tx);
|
||||
if (!foundCert) {
|
||||
throw new NotFoundError({ message: "Certificate was signed but could not be found in database" });
|
||||
certificate = caResult.certificate;
|
||||
certificateChain = caResult.certificateChain;
|
||||
issuingCaCertificate = caResult.issuingCaCertificate;
|
||||
serialNumber = caResult.serialNumber;
|
||||
|
||||
const foundCert = await certificateDAL.findById(caResult.certificateId, tx);
|
||||
if (!foundCert) {
|
||||
throw new NotFoundError({ message: "Certificate was signed but could not be found in database" });
|
||||
}
|
||||
newCert = foundCert;
|
||||
} else if (caType === CaType.ACME || caType === CaType.AZURE_AD_CS) {
|
||||
// External CA renewal - mark for async processing outside transaction
|
||||
return {
|
||||
isExternalCA: true,
|
||||
ca,
|
||||
profile,
|
||||
originalCert,
|
||||
originalSignatureAlgorithm,
|
||||
originalKeyAlgorithm,
|
||||
ttl
|
||||
};
|
||||
} else {
|
||||
throw new BadRequestError({
|
||||
message: `CA type ${String(caType)} does not support certificate renewal`
|
||||
});
|
||||
}
|
||||
newCert = foundCert;
|
||||
} else {
|
||||
// Self-signed certificate renewal
|
||||
const effectiveAlgorithms = getEffectiveAlgorithms(
|
||||
@@ -1513,6 +1725,28 @@ export const certificateV3ServiceFactory = ({
|
||||
|
||||
await addRenewedCertificateToSyncs(originalCert.id, newCert.id, { certificateSyncDAL }, tx);
|
||||
|
||||
const certRequestResult = await certificateRequestService.createCertificateRequest({
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
projectId: originalCert.projectId,
|
||||
tx,
|
||||
caId: ca?.id || originalCert.caId || undefined,
|
||||
profileId: originalCert.profileId || undefined,
|
||||
commonName: originalCert.commonName || undefined,
|
||||
altNames: originalCert.altNames || undefined,
|
||||
keyUsages: parseKeyUsages(originalCert.keyUsages),
|
||||
extendedKeyUsages: parseExtendedKeyUsages(originalCert.extendedKeyUsages),
|
||||
notBefore: new Date(newCert.notBefore),
|
||||
notAfter: new Date(newCert.notAfter),
|
||||
keyAlgorithm: originalKeyAlgorithm,
|
||||
signatureAlgorithm: originalSignatureAlgorithm,
|
||||
metadata: `Renewed from certificate ID: ${originalCert.id}`,
|
||||
status: CertificateRequestStatus.ISSUED,
|
||||
certificateId: newCert.id
|
||||
});
|
||||
|
||||
return {
|
||||
certificate,
|
||||
certificateChain,
|
||||
@@ -1520,10 +1754,76 @@ export const certificateV3ServiceFactory = ({
|
||||
serialNumber,
|
||||
newCert,
|
||||
originalCert,
|
||||
profile
|
||||
profile,
|
||||
certRequestResult
|
||||
};
|
||||
});
|
||||
|
||||
let certificateRequestId: string = renewalResult.certRequestResult?.id || "";
|
||||
|
||||
// Handle external CA renewals separately
|
||||
if ("isExternalCA" in renewalResult && renewalResult.isExternalCA) {
|
||||
const { ca, profile, originalCert, originalSignatureAlgorithm, originalKeyAlgorithm, ttl } = renewalResult;
|
||||
|
||||
const renewalOrderId = randomUUID();
|
||||
const altNamesArray = originalCert.altNames
|
||||
? originalCert.altNames.split(",").map((san: string) => san.trim())
|
||||
: [];
|
||||
|
||||
const certificateRequest = await certificateRequestService.createCertificateRequest({
|
||||
actor,
|
||||
actorId,
|
||||
actorAuthMethod,
|
||||
actorOrgId,
|
||||
projectId: originalCert.projectId,
|
||||
profileId: profile?.id,
|
||||
caId: ca.id,
|
||||
commonName: originalCert.commonName || undefined,
|
||||
altNames: originalCert.altNames || undefined,
|
||||
keyUsages: parseKeyUsages(originalCert.keyUsages),
|
||||
extendedKeyUsages: parseExtendedKeyUsages(originalCert.extendedKeyUsages),
|
||||
keyAlgorithm: originalKeyAlgorithm,
|
||||
signatureAlgorithm: originalSignatureAlgorithm,
|
||||
metadata: `Renewed from certificate ID: ${originalCert.id}`,
|
||||
status: CertificateRequestStatus.PENDING
|
||||
});
|
||||
|
||||
certificateRequestId = certificateRequest.id;
|
||||
|
||||
await certificateIssuanceQueue.queueCertificateIssuance({
|
||||
certificateId: renewalOrderId,
|
||||
profileId: profile?.id || "",
|
||||
caId: ca.id,
|
||||
commonName: originalCert.commonName || "",
|
||||
altNames: altNamesArray,
|
||||
ttl,
|
||||
signatureAlgorithm: originalSignatureAlgorithm,
|
||||
keyAlgorithm: originalKeyAlgorithm,
|
||||
keyUsages: convertEnumsToStringArray(parseKeyUsages(originalCert.keyUsages)),
|
||||
extendedKeyUsages: convertEnumsToStringArray(parseExtendedKeyUsages(originalCert.extendedKeyUsages)),
|
||||
isRenewal: true,
|
||||
originalCertificateId: certificateId,
|
||||
certificateRequestId: certificateRequest.id
|
||||
});
|
||||
|
||||
return {
|
||||
certificate: "", // External CA renewal is async
|
||||
certificateChain: "",
|
||||
issuingCaCertificate: "",
|
||||
serialNumber: "",
|
||||
certificateId: renewalOrderId,
|
||||
certificateRequestId: certificateRequest.id,
|
||||
projectId: originalCert.projectId,
|
||||
profileName: profile?.slug || "External CA Profile",
|
||||
commonName: originalCert.commonName || ""
|
||||
};
|
||||
}
|
||||
|
||||
// Type check to ensure we have internal CA renewal result
|
||||
if ("isExternalCA" in renewalResult) {
|
||||
throw new BadRequestError({ message: "External CA renewals should be handled asynchronously" });
|
||||
}
|
||||
|
||||
await triggerAutoSyncForCertificate(renewalResult.newCert.id, {
|
||||
certificateSyncDAL,
|
||||
pkiSyncDAL,
|
||||
@@ -1540,6 +1840,7 @@ export const certificateV3ServiceFactory = ({
|
||||
certificateChain: finalCertificateChain,
|
||||
serialNumber: renewalResult.serialNumber,
|
||||
certificateId: renewalResult.newCert.id,
|
||||
certificateRequestId,
|
||||
projectId: renewalResult.originalCert.projectId,
|
||||
profileName: renewalResult.profile?.slug || "Self-signed Certificate",
|
||||
commonName: renewalResult.originalCert.commonName || ""
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { TProjectPermission } from "@app/lib/types";
|
||||
|
||||
import { ACMESANType, CertificateOrderStatus } from "../certificate/certificate-types";
|
||||
import {
|
||||
CertExtendedKeyUsageType,
|
||||
CertKeyUsageType,
|
||||
@@ -45,7 +44,7 @@ export type TOrderCertificateFromProfileDTO = {
|
||||
profileId: string;
|
||||
certificateOrder: {
|
||||
altNames: Array<{
|
||||
type: ACMESANType;
|
||||
type: CertSubjectAlternativeNameType;
|
||||
value: string;
|
||||
}>;
|
||||
validity: {
|
||||
@@ -58,6 +57,8 @@ export type TOrderCertificateFromProfileDTO = {
|
||||
notAfter?: Date;
|
||||
signatureAlgorithm?: string;
|
||||
keyAlgorithm?: string;
|
||||
template?: string;
|
||||
csr?: string;
|
||||
};
|
||||
removeRootsFromChain?: boolean;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
@@ -69,34 +70,14 @@ export type TCertificateFromProfileResponse = {
|
||||
privateKey?: string;
|
||||
serialNumber: string;
|
||||
certificateId: string;
|
||||
certificateRequestId: string;
|
||||
projectId: string;
|
||||
profileName: string;
|
||||
commonName: string;
|
||||
};
|
||||
|
||||
export type TCertificateOrderResponse = {
|
||||
orderId: string;
|
||||
status: CertificateOrderStatus;
|
||||
subjectAlternativeNames: Array<{
|
||||
type: ACMESANType;
|
||||
value: string;
|
||||
status: CertificateOrderStatus;
|
||||
}>;
|
||||
authorizations: Array<{
|
||||
identifier: {
|
||||
type: ACMESANType;
|
||||
value: string;
|
||||
};
|
||||
status: CertificateOrderStatus;
|
||||
expires?: string;
|
||||
challenges: Array<{
|
||||
type: string;
|
||||
status: CertificateOrderStatus;
|
||||
url: string;
|
||||
token: string;
|
||||
}>;
|
||||
}>;
|
||||
finalize: string;
|
||||
certificateRequestId: string;
|
||||
certificate?: string;
|
||||
projectId: string;
|
||||
profileName: string;
|
||||
@@ -105,6 +86,7 @@ export type TCertificateOrderResponse = {
|
||||
export type TRenewCertificateDTO = {
|
||||
certificateId: string;
|
||||
removeRootsFromChain?: boolean;
|
||||
certificateRequestId?: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TUpdateRenewalConfigDTO = {
|
||||
|
||||
Reference in New Issue
Block a user