Merge remote-tracking branch 'origin/main' into feat/PKI-67

This commit is contained in:
Carlos Monastyrski
2025-12-03 00:17:29 -03:00
92 changed files with 5065 additions and 933 deletions

View File

@@ -57,3 +57,5 @@ docs/documentation/platform/pki/enrollment-methods/api.mdx:generic-api-key:93
docs/documentation/platform/pki/enrollment-methods/api.mdx:private-key:139
docs/documentation/platform/pki/certificate-syncs/aws-secrets-manager.mdx:private-key:62
docs/documentation/platform/pki/certificate-syncs/chef.mdx:private-key:61
backend/src/services/certificate-request/certificate-request-service.test.ts:private-key:246
backend/src/services/certificate-request/certificate-request-service.test.ts:private-key:248

View File

@@ -185,6 +185,9 @@ COPY --from=backend-runner /app /backend
COPY --from=frontend-runner /app ./backend/frontend-build
# Make export-assets script executable for CDN asset extraction
RUN chmod +x /backend/scripts/export-assets.sh
ARG INFISICAL_PLATFORM_VERSION
ENV INFISICAL_PLATFORM_VERSION $INFISICAL_PLATFORM_VERSION

View File

@@ -174,6 +174,9 @@ ENV CAPTCHA_SITE_KEY=$CAPTCHA_SITE_KEY
COPY --from=backend-runner /app /backend
COPY --from=frontend-runner /app ./backend/frontend-build
# Make export-assets script executable for CDN asset extraction
RUN chmod +x /backend/scripts/export-assets.sh
ARG INFISICAL_PLATFORM_VERSION
ENV INFISICAL_PLATFORM_VERSION $INFISICAL_PLATFORM_VERSION

View File

@@ -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}"
"""

View File

@@ -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)

View File

@@ -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",

View File

@@ -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

View File

@@ -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;

View File

@@ -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,

View File

@@ -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);
}

View File

@@ -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");
});
}
}

View File

@@ -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>>;

View File

@@ -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";

View File

@@ -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",

View File

@@ -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>;

View File

@@ -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

View File

@@ -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,

View File

@@ -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 };
};

View File

@@ -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
};
};

View File

@@ -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,

View File

@@ -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>;
};

View File

@@ -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 = [

View File

@@ -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,

View File

@@ -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
})
})
}
},

View File

@@ -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,

View File

@@ -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,

View File

@@ -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
};
};

View File

@@ -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
};
};

View File

@@ -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

View File

@@ -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
};

View File

@@ -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
};
};

View File

@@ -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
};

View File

@@ -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;
};

View File

@@ -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;

View File

@@ -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>;

View File

@@ -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,

View File

@@ -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
};
};

View File

@@ -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;

View File

@@ -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
};
};

View File

@@ -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);
});
});
});

View File

@@ -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
};
};

View File

@@ -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;
};

View File

@@ -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;
};

View File

@@ -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
});

View File

@@ -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
});
certificate = caResult.certificate;
certificateChain = caResult.certificateChain;
issuingCaCertificate = caResult.issuingCaCertificate;
serialNumber = caResult.serialNumber;
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" });
// Only validate algorithm compatibility for internal CAs
if (caType === CaType.INTERNAL) {
validateAlgorithmCompatibility(ca, {
algorithms: template?.algorithms
} as { algorithms?: { signature?: string[] } });
}
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
});
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 || ""

View File

@@ -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 = {

View File

@@ -775,7 +775,23 @@
"group": "Infisical PAM",
"pages": [
"documentation/platform/pam/overview",
"documentation/platform/pam/session-recording"
{
"group": "Getting Started",
"pages": [
"documentation/platform/pam/getting-started/setup",
"documentation/platform/pam/getting-started/resources",
"documentation/platform/pam/getting-started/accounts"
]
},
"documentation/platform/pam/architecture"
]
},
{
"group": "Product Reference",
"pages": [
"documentation/platform/pam/product-reference/auditing",
"documentation/platform/pam/product-reference/session-recording",
"documentation/platform/pam/product-reference/credential-rotation"
]
}
]

View File

@@ -0,0 +1,77 @@
---
title: "Architecture"
sidebarTitle: "Architecture"
description: "Learn about the architecture, components, and security model of Infisical PAM."
---
Infisical PAM utilizes a secure, proxy-based architecture designed to provide access to private resources without exposing them directly to the internet. This system relies on a combination of the Infisical CLI, a Relay server, and a self-hosted Gateway. For more information on Gateways, refer to the [Gateway Overview](/documentation/platform/gateways/overview).
## Core Components
The architecture consists of three main components working in unison:
<Steps>
<Step title="Infisical CLI">
The client-side interface used to initiate access requests. It creates a local listener that forwards traffic securely to the Gateway.
</Step>
<Step title="Infisical Gateway">
A lightweight service deployed within your private network (e.g., VPC, on-prem). It acts as a proxy, intercepting traffic to enforce policies and record sessions before forwarding requests to the target resource.
</Step>
<Step title="Target Resource">
The actual infrastructure being accessed, such as a PostgreSQL database, a Linux server, or a web application.
</Step>
</Steps>
## Access Flow
```mermaid
graph LR
subgraph Client ["User Environment"]
CLI["Infisical CLI"]
end
Relay["Relay Server"]
subgraph Network ["Private Network (VPC)"]
Gateway["Infisical Gateway"]
DB[("Target Resource (Database/Server)")]
end
CLI <-->|Encrypted Tunnel| Relay
Relay <-->|Reverse Tunnel| Gateway
Gateway <-->|Native Protocol| DB
```
When a user accesses a resource (e.g., via `infisical access`), the following workflow occurs:
1. **Connection Initiation**: The Infisical CLI initiates a connection to the Relay server.
2. **Tunnel Establishment**: The Relay facilitates an end-to-end encrypted tunnel between the CLI and the Gateway.
3. **Proxy & Credential Injection**: The Gateway authenticates the request and connects to the target resource on the user's behalf. It automatically injects the necessary credentials (e.g., database passwords, SSH keys), ensuring the user never directly handles sensitive secrets.
4. **Traffic Forwarding**: Traffic flows securely from the user's machine, through the Relay, to the Gateway, and finally to the resource.
## Session Recording & Auditing
![Session Logging](/images/pam/architecture/session-logging.png)
A key feature of the Gateway is its ability to act as a "middleman" for all session traffic.
- **Interception**: Because the Gateway sits between the secure tunnel and the target resource, it intercepts all data flowing through the connection.
- **Logging**: This traffic is logged as part of [Session Recording](/documentation/platform/pam/product-reference/session-recording). The Gateway temporarily stores encrypted session logs locally.
- **Upload**: Once the session concludes, the logs are securely uploaded to the Infisical platform for storage and review.
## Security Architecture
The PAM security model allows you to maintain a zero-trust environment while enabling convenient access.
### End-to-End Encryption
The connection between the Infisical CLI (client) and the Gateway is end-to-end encrypted. The Relay server acts solely as a router for encrypted packets and **cannot decrypt or inspect** the traffic passing through it.
### Network Security
The Gateway uses **SSH reverse tunnels** to connect to the Relay. This design offers significant security benefits:
- **No Inbound Ports**: You do not need to open any inbound firewall ports (like 22 or 5432) to the internet.
- **Outbound-Only**: The Gateway only requires outbound connectivity to the Relay server and Infisical API.
For a deep dive into the underlying cryptography, certificate management, and isolation guarantees, refer to the [Gateway Security Architecture](/documentation/platform/gateways/security).
### Deployment
For instructions on setting up the necessary infrastructure, see the [Gateway Deployment Guide](/documentation/platform/gateways/gateway-deployment).

View File

@@ -0,0 +1,47 @@
---
title: "PAM Account"
sidebarTitle: "Accounts"
description: "Learn how to create and manage accounts in PAM to control access to resources like databases and servers."
---
An **Account** contains the credentials (such as a username and password) used to connect to a [Resource](/documentation/platform/pam/getting-started/resources).
## Relationship to Resources
Accounts belong to Resources. A single Resource can have multiple Accounts associated with it, each with different permission levels.
For example, your database would normally have multiple accounts. You might have a superuser account for admins, a standard read/write account for applications, and a read-only account for reporting.
In PAM, these are represented as:
- **Resource**: `Production Database` (PostgreSQL)
- **Account 1**: `postgres` (Superuser)
- **Account 2**: `app_user` (Read/Write)
- **Account 3**: `analytics` (Read-only)
When a user requests access in PAM, they request access to a specific **Account** on a **Resource**.
## Creating an Account
<Info>
**Prerequisite**: You must have at least one [Resource](/documentation/platform/pam/getting-started/resources) created before adding accounts.
</Info>
To add an account, navigate to the **Accounts** tab in your PAM project and click **Add Account**.
![Add Account Button](/images/pam/getting-started/accounts/add-account-button.png)
Next, select the **Resource** that this account belongs to.
![Select Resource](/images/pam/getting-started/accounts/select-resource.png)
After selecting a resource, provide the credentials (username, password, etc.) for this account. The required fields vary depending on the resource type. For example, for a Linux server, you would enter the username and the corresponding password or SSH key.
![Create Account](/images/pam/getting-started/accounts/create-account.png)
Clicking **Create Account** will trigger a validation check. Infisical will attempt to connect to the resource using the provided credentials to verify they are valid.
## Automated Credential Rotation
Infisical supports automated credential rotation for some accounts on select resources, allowing you to automatically change passwords at set intervals to enhance security.
To learn more about how to configure this, please refer to the [Credential Rotation guide](/documentation/platform/pam/product-reference/credential-rotation).

View File

@@ -0,0 +1,45 @@
---
title: "PAM Resource"
sidebarTitle: "Resources"
description: "Learn how to add and configure resources like databases and servers, and set up automated credential rotation."
---
A resource represents a target system, such as a database, server, or application, that you want to manage access to. Some examples of resources are:
- PostgreSQL Database
- MCP Server
- Linux Server
- Web Application
## Prerequisites
Before you can create a resource, you must have an **Infisical Gateway** deployed that is able to reach the target resource over the network.
The Gateway acts as a secure bridge, allowing Infisical to reach your private infrastructure without exposing it to the public internet. When creating a resource, you will be asked to specify which Gateway should be used to connect to it.
[Read the Gateway Deployment Guide](/documentation/platform/gateways/gateway-deployment)
## Creating a Resource
To add a resource, navigate to the **Resources** tab in your PAM project and click **Add Resource**.
![Add Resource Button](/images/pam/getting-started/resources/add-resource-button.png)
Next, select the type of resource you want to add.
![Select Resource Type](/images/pam/getting-started/resources/select-resource-type.png)
After selecting a resource type, provide the necessary connection details. The required fields vary depending on the resource type.
**Important**: You must select the **Gateway** that has network access to this resource.
In this PostgreSQL example, you provide details such as host, port, gateway, and database name.
![Create Resource](/images/pam/getting-started/resources/create-resource.png)
Clicking **Create Resource** will trigger a connection test from the selected Gateway to your target resource. If the connection fails, an error message will be displayed to help you troubleshoot (usually indicating a network firewall issue between the Gateway and the Resource).
## Automated Credential Rotation
Some resources, such as PostgreSQL, support automated credential rotation to enhance your security posture. This feature requires configuring a privileged "Rotation Account" on the resource.
To learn more about how to configure this, please refer to the [Credential Rotation guide](/documentation/platform/pam/product-reference/credential-rotation).

View File

@@ -0,0 +1,35 @@
---
title: "Setup"
sidebarTitle: "Setup"
description: "This guide provides a step-by-step walkthrough for configuring Infisical's Privileged Access Management (PAM). Learn how to deploy a gateway, define resources, and grant your team secure, audited access to critical infrastructure."
---
Infisical's Privileged Access Management (PAM) solution enables you to provide developers with secure, just-in-time access to your critical infrastructure, such as databases, servers, and web applications. Instead of sharing static credentials, your team can request temporary access through Infisical, which is then brokered through a secure gateway with full auditing and session recording.
Getting started involves a few key components:
- **Gateways:** A lightweight service you deploy in your own infrastructure to act as a secure entry point to your private resources.
- **Resources:** The specific systems you want to manage access to (e.g., a PostgreSQL database or an SSH server).
- **Accounts:** The privileged credentials (e.g., a database user or an SSH user) that Infisical uses to connect to a resource on behalf of a user.
The following steps will guide you through the entire setup process, from deploying your first gateway to establishing a secure connection.
<Steps>
<Step title="Deploy a Gateway">
Before you can manage any resources, you must deploy an **Infisical Gateway** within your infrastructure. This component is responsible for brokering connections to your private resources.
[Read the Gateway Deployment Guide](/documentation/platform/gateways/gateway-deployment)
</Step>
<Step title="Create a Resource">
Once the Gateway is active, define a **Resource** in Infisical (e.g., "Production Database"). You will link this resource to your deployed Gateway so Infisical knows how to reach it.
[Learn about Resources](/documentation/platform/pam/getting-started/resources)
</Step>
<Step title="Add Accounts">
Add **Accounts** to your Resource (e.g., `postgres` or `read_only_user`). These represent the actual PAM users or privileged identities that are utilized when a user connects.
[Learn about Accounts](/documentation/platform/pam/getting-started/accounts)
</Step>
<Step title="Connect">
Users can now use the Infisical CLI to securely connect to the resource using the defined accounts, with full auditing and session recording enabled.
</Step>
</Steps>

View File

@@ -1,45 +1,67 @@
---
title: "Infisical PAM"
title: "Overview"
sidebarTitle: "Overview"
description: "Learn how to manage access to resources like databases, servers, and accounts with policy-based controls and approvals."
description: "Manage and secure access to critical infrastructure like databases and servers with policy-based controls and approvals."
---
Infisical Privileged Access Management (PAM) provides a centralized way to manage and secure access to your critical infrastructure. It allows you to enforce fine-grained, policy-based controls over resources like databases, servers, and more, ensuring that only authorized users can access sensitive systems, and only when they need to.
### How it Works
## The PAM Workflow
Infisical PAM employs a resource-based model to organize and manage access. This model is designed to be intuitive and scalable.
At its core, Infisical PAM is designed to decouple **user identity** from **infrastructure credentials**. Instead of sharing static passwords or SSH keys, users authenticate with their SSO identity, and Infisical handles the rest.
#### 1. Create a Resource
Here is how a typical access lifecycle looks:
The first step is to define a resource you want to manage. A resource represents a target system, such as a PostgreSQL database. When creating a resource, you'll provide the necessary connection details, like the host and port.
1. **Discovery**: A user logs into Infisical and sees a catalog of resources (databases, servers) and accounts they are allowed to access.
2. **Connection**: The user selects a resource and an account (e.g., "Production DB" as `read_only`). They initiate the connection via the Infisical CLI.
3. **Credential Injection**: Infisical validates the request. If allowed, it establishes a secure tunnel and automatically injects the credentials for the target account. **The user never sees the underlying password or key.**
4. **Monitoring**: The session is established. All traffic is intercepted, logged, and recorded for audit purposes.
![Create Resource](/images/pam/overview/create-resource.png)
## Core Concepts
#### 2. Add Accounts to the Resource
To successfully implement Infisical PAM, it is essential to understand the relationship between the following components:
Once a resource is created, you can add accounts to it. An account represents a specific set of credentials (e.g., a username and password) that can be used to access the resource. This allows you to manage multiple sets of credentials for a single database or server from one place.
<CardGroup cols={3}>
<Card title="Gateway" icon="server">
A lightweight service deployed in your network that acts as a secure bridge to your private infrastructure.
</Card>
<Card title="Resource" icon="database">
The specific target you are protecting (e.g., a PostgreSQL database or an Ubuntu server).
</Card>
<Card title="Account" icon="user-lock">
The specific identity on the Resource that the user is trying to access. One Resource can have multiple Accounts.
</Card>
</CardGroup>
![Create Account](/images/pam/overview/create-account.png)
### Relationship Model
### Infisical PAM Features
The hierarchy is structured as follows:
#### Session Logging and Auditing
```mermaid
graph TD
GW[Gateway] --> |Provides Access| DB[Resource: Production DB]
GW[Gateway] --> |Provides Access| SRV[Resource: Linux Server]
DB --> A1[Account: admin]
DB --> A2[Account: readonly]
SRV --> A3[Account: ubuntu]
```
- **Session Logging**: All user sessions are extensively logged, providing a detailed and searchable record of activities performed during a session.
- **Audit Logging**: Every significant event, such as a user starting a session or accessing an account's credentials, is recorded in audit logs. This gives you complete visibility over your project.
1. **Gateway**: Deployed once per network/VPC. It provides connectivity to all resources in that environment.
2. **Resource**: Configured within Infisical. It points to a specific IP/Host accessible by the Gateway.
3. **Account**: Defined under a Resource. Users request access to a specific *Account* on a *Resource*.
![Session Page](/images/pam/overview/session-page.png)
## Network Architecture
#### Automated Credential Rotation
Infisical PAM uses a secure proxy-based architecture to connect users to resources without direct network exposure.
Infisical PAM can automatically rotate account credentials to enhance your security posture.
When a user accesses a resource, their connection is routed securely through a Relay to your self-hosted Gateway, which then connects to the target resource. This ensures zero-trust access without exposing your infrastructure to the public internet.
Heres how it works:
1. **Add a Rotation Account**: On the resource level, you configure a "rotation account." This is a master or privileged account that has the necessary permissions to change the passwords of other accounts on that same resource.
![Credential Rotation Account](/images/pam/overview/credential-rotation-account.png)
For a deep dive into the technical architecture and security model, see [Architecture](/documentation/platform/pam/architecture).
2. **Configure Rotation on Accounts**: For each individual account you want to rotate, you can simply enable rotation and set a desired interval (e.g., every 30 days).
![Rotate Credentials Account](/images/pam/overview/rotate-credentials-account.png)
## Core Capabilities
Infisical will then use the rotation account on the resource to automatically update the credentials of the target account at the specified interval, eliminating credential staleness.
- **[Auditing](/documentation/platform/pam/product-reference/auditing)**: Track and review a comprehensive log of all user actions and system events.
- **[Session Recording](/documentation/platform/pam/product-reference/session-recording)**: Record and playback user sessions for security reviews, compliance, and troubleshooting.
- **[Automated Credential Rotation](/documentation/platform/pam/product-reference/credential-rotation)**: Automatically rotate credentials for supported resources to minimize the risk of compromised credentials.

View File

@@ -0,0 +1,23 @@
---
title: "Auditing"
sidebarTitle: "Auditing"
description: "Learn how Infisical audits all actions across your PAM project."
---
## What's Audited
Infisical logs a wide range of actions to provide a complete audit trail for your PAM project. These actions include:
- Session Start and End
- Fetching session credentials
- Creating, updating, or deleting resources, accounts, folders, and sessions
<Info>
Please note: Audit logs track metadata about sessions (e.g., start/end times), but not the specific commands executed *within* them. For detailed in-session activity, check out [Session Recording](/documentation/platform/pam/product-reference/session-recording).
</Info>
## Viewing Audit Logs
You can view, search, and filter all events from the **Audit Logs** page within your PAM project.
![Audit Logs](/images/pam/product-reference/auditing/audit-logs.png)

View File

@@ -0,0 +1,47 @@
---
title: "Credential Rotation"
sidebarTitle: "Credential Rotation"
description: "Learn how to automate credential rotation for your PAM resources."
---
Automated Credential Rotation enhances your security posture by automatically changing the passwords of your accounts at set intervals. This minimizes the risk of compromised credentials by ensuring that even if a password is leaked, it remains valid only for a short period.
## How it Works
When rotation is enabled, Infisical's Gateway connects to the target resource using a privileged "Rotation Account". It then executes the necessary commands to change the password for the target user account to a new, cryptographically secure random value.
## Configuration
Setting up automated rotation requires a two-step configuration: first at the Resource level, and then at the individual Account level.
<Steps>
<Step title="Configure Rotation Account on Resource">
A **Rotation Account** is a master or privileged account that has the necessary permissions to change the passwords of other users on the target system.
When creating or editing a [Resource](/documentation/platform/pam/getting-started/resources), you must provide the credentials for this privileged account.
*Example: For a PostgreSQL database, this would typically be the `postgres` superuser or another role with `ALTER ROLE` privileges.*
![Credential Rotation Account](/images/pam/getting-started/resources/credential-rotation-account.png)
</Step>
<Step title="Enable Rotation on Account">
Once the resource has a rotation account configured, you can enable rotation for individual [Accounts](/documentation/platform/pam/getting-started/accounts) that belong to that resource.
In the account settings:
1. Toggle **Enable Rotation**.
2. Set the **Rotation Interval** (e.g., every 7 days, 30 days).
![Rotate Credentials Account](/images/pam/getting-started/resources/rotate-credentials-account.png)
</Step>
</Steps>
## Supported Resources
Automated rotation is currently supported for the following resource types:
- **PostgreSQL**: Requires a user with `ALTER ROLE` permissions.
<Note>
We are constantly adding support for more resource types.
</Note>

View File

@@ -0,0 +1,60 @@
---
title: "Session Recording"
sidebarTitle: "Session Recording"
description: "Learn how Infisical records and stores session activity for auditing and monitoring."
---
Infisical PAM provides robust session recording capabilities to help you audit and monitor user activity across your infrastructure.
## How It Works
When a user initiates a session by accessing an account, a recording of the session begins. The Gateway securely caches all recording data in temporary encrypted files on its local system.
Once the session concludes, the gateway transmits the complete recording to the Infisical platform for long-term, centralized storage. This asynchronous process ensures that sessions remain operational even if the connection to the Infisical platform is temporarily lost. After the upload is complete, administrators can search and review the session logs on the Infisical platform.
## What's Captured
The content captured during a session depends on the type of resource being accessed.
<AccordionGroup>
<Accordion title="Database Sessions">
Infisical captures all queries executed and their corresponding responses, including timestamps for each action.
</Accordion>
<Accordion title="SSH Sessions">
Infisical captures all commands executed and their corresponding responses, including timestamps for each action.
</Accordion>
</AccordionGroup>
## Viewing Recordings
To review session recordings:
1. Navigate to the **Sessions** page in your PAM project.
2. Click on a session from the list to view its details.
![PAM Sessions](/images/pam/product-reference/session-recording/sessions-page.png)
The session details page provides key information, including the complete session logs, connection status, the user who initiated it, and more.
![PAM Individual Session](/images/pam/product-reference/session-recording/individual-session-page.png)
### Searching Logs
You can use the search bar to quickly find relevant information:
**Sessions page:** Search across all session logs to locate specific queries or outputs.
![PAM Sessions Search](/images/pam/product-reference/session-recording/sessions-page-search.png)
**Individual session page:** Search within that specific session's logs to pinpoint activity.
![PAM Individual Session Search](/images/pam/product-reference/session-recording/individual-session-page-search.png)
## FAQ
<AccordionGroup>
<Accordion title="Are session recordings encrypted?">
Yes. All session recordings are encrypted at rest by default, ensuring your data is always secure.
</Accordion>
<Accordion title="Why aren't recordings streamed in real-time?">
Currently, Infisical uses an asynchronous approach where the gateway records the entire session locally before uploading it. This design makes your PAM sessions more resilient, as they don't depend on a constant, active connection to the Infisical platform. We may introduce live streaming capabilities in a future release.
</Accordion>
</AccordionGroup>

View File

@@ -1,60 +0,0 @@
---
title: "Session Recording"
sidebarTitle: "Session Recording"
description: "Learn how Infisical records and stores session activity for auditing and monitoring."
---
Infisical's Privileged Access Management (PAM) provides robust session recording capabilities to help you audit and monitor user activity across your infrastructure.
## How It Works
When a user initiates a session through the Infisical Gateway, a recording of the session begins. The gateway securely caches all recording data in temporary encrypted files on its local system.
Once the session concludes, the gateway transmits the complete recording to the Infisical platform for long-term, centralized storage. This asynchronous process ensures that sessions remain operational even if the connection to the Infisical platform is temporarily lost. After the upload is complete, administrators can search and review the session logs in the Infisical UI.
## What's Captured
The content captured during a session depends on the type of resource being accessed.
### Database Sessions
For database connections, Infisical captures all queries executed and their corresponding responses.
<Note>
Support for additional resource types like SSH, RDP, Kubernetes, and MCP is coming soon.
</Note>
## Viewing Recordings
To review session recordings:
1. Navigate to the **PAM Sessions** page in your project.
2. Click on a session from the list to view its details.
![PAM Sessions](/images/pam/session-recording/sessions-page.png)
The session details page provides key information, including the complete session logs, connection status, the user who initiated it, and more.
![PAM Individual Session](/images/pam/session-recording/individual-session-page.png)
### Searching Logs
You can use the search bar to quickly find relevant information:
- **On the main Sessions page:** Search across all session logs to locate specific queries or outputs.
- **On an individual session page:** Search within that specific session's logs to pinpoint activity.
![PAM Sessions Search](/images/pam/session-recording/sessions-page-search.png)
![PAM Individual Session Search](/images/pam/session-recording/individual-session-page-search.png)
## FAQ
<AccordionGroup>
<Accordion title="Are session recordings encrypted?">
Yes. All session recordings are encrypted at rest by default, ensuring your audit data is always secure.
</Accordion>
<Accordion title="Why aren't recordings streamed in real-time?">
Currently, Infisical uses an asynchronous approach where the gateway records the entire session locally before uploading it. This design makes your PAM sessions more resilient, as they don't depend on a constant, active connection to the Infisical platform. We may introduce live streaming capabilities in a future release.
</Accordion>
</AccordionGroup>

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 598 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 577 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 563 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 503 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 419 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 576 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 415 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 462 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 500 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 570 KiB

View File

@@ -0,0 +1,106 @@
---
title: "CDN Caching for Static Assets"
description: "How to set up CDN caching to prevent version skew issues during deployments"
---
This guide explains a common issue with frontend asset caching during deployments and how to solve it using a CDN.
## The Problem: Version Skew
Modern frontend build tools like Vite generate content-hashed filenames for static assets (e.g., `main-abc123.js`). Each build produces unique filenames based on file contents. During deployments, this can cause a race condition:
1. User loads `index.html` which references `main-abc123.js`
2. New deployment replaces containers with a new build
3. New containers only serve `main-xyz789.js` (new build)
4. User's browser requests `main-abc123.js` from cached HTML
5. Request returns **404** — the old asset no longer exists
This results in broken pages, failed SPA navigation, and requires users to manually refresh.
<Note>
This is a documented limitation in Vite's official guidance: [Load Error Handling](https://vite.dev/guide/build#load-error-handling)
</Note>
### Current Behavior
Infisical includes a built-in workaround that detects version mismatches and triggers a page reload. While functional, this introduces a noticeable delay for users during deployments.
## The Solution: External Asset Storage
The solution is to store static assets externally (e.g., S3, GCS, Azure Blob) and serve them through a CDN (e.g., CloudFront, Cloud CDN, Cloudflare). Assets are uploaded **before** container deployment, ensuring old versions remain available.
### How It Works
```mermaid
flowchart LR
User[User Browser]
CDN[CDN]
S3[(Object Storage)]
App[Your Infrastructure]
User --> CDN
CDN -->|"/assets/*"| S3
CDN -->|"/* (default)"| App
```
The key points:
- **Asset persistence**: Old assets remain available even after new deployments
- **Deployment order**: Upload new assets before deploying new containers
- **Long cache TTL**: Content-hashed files can be cached indefinitely (we recommend 30 days)
- **Automatic cleanup**: Configure lifecycle rules to expire old assets after 30 days
At Infisical, we use **CloudFront + S3** for this purpose, but you can use any CDN and object storage combination that fits your infrastructure.
## Exporting Assets
Infisical provides a built-in command to export frontend assets from the Docker image:
```bash
# Export as tar archive to stdout
docker run --rm infisical/infisical npm run --silent assets:export > assets.tar
# Extract the archive
tar -xf assets.tar
ls assets/ # Content-hashed JS/CSS files
```
Or export directly to a mounted directory:
```bash
docker run --rm -v $(pwd)/cdn-assets:/output \
infisical/infisical npm run --silent assets:export /output
```
### What Gets Exported
The command exports the `/assets` directory containing:
- JavaScript bundles (e.g., `main-abc123.js`, `chunk-def456.js`)
- CSS files (e.g., `styles-789xyz.css`)
- Other static assets with content hashes
These files are safe to cache with long TTLs because their filenames change whenever the content changes.
## Integration with Your Pipeline
The general deployment flow should be:
1. **Build** your new Docker image (or pull the official Infisical image)
2. **Export** assets using `npm run assets:export`
3. **Upload** assets to your object storage
4. **Deploy** the new container version
```bash
# Example: Export and upload to S3
docker run --rm infisical/infisical:$VERSION npm run --silent assets:export > assets.tar
tar -xf assets.tar
aws s3 sync assets s3://your-bucket/assets --cache-control "public, max-age=2592000"
# Then deploy your container
```
<Warning>
Always upload assets **before** deploying the new container. This ensures the assets referenced by the new `index.html` exist before users can access them.
</Warning>

View File

@@ -8,15 +8,15 @@
http-equiv="Content-Security-Policy"
content="
default-src 'self';
connect-src 'self' https://*.posthog.com http://127.0.0.1:* https://cdn.jsdelivr.net/npm/@lottiefiles/dotlottie-web@0.38.2/dist/dotlottie-player.wasm;
script-src 'self' https://*.posthog.com https://js.stripe.com https://api.stripe.com https://widget.intercom.io https://js.intercomcdn.com https://hcaptcha.com https://*.hcaptcha.com 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net/npm/@lottiefiles/dotlottie-web@0.38.2/dist/dotlottie-player.wasm;
style-src 'self' 'unsafe-inline' https://hcaptcha.com https://*.hcaptcha.com;
connect-src 'self' https://d1zwf0dwl0k2ky.cloudfront.net https://*.posthog.com http://127.0.0.1:* https://cdn.jsdelivr.net/npm/@lottiefiles/dotlottie-web@0.38.2/dist/dotlottie-player.wasm;
script-src 'self' https://d1zwf0dwl0k2ky.cloudfront.net https://*.posthog.com https://js.stripe.com https://api.stripe.com https://widget.intercom.io https://js.intercomcdn.com https://hcaptcha.com https://*.hcaptcha.com 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net/npm/@lottiefiles/dotlottie-web@0.38.2/dist/dotlottie-player.wasm;
style-src 'self' https://d1zwf0dwl0k2ky.cloudfront.net 'unsafe-inline' https://hcaptcha.com https://*.hcaptcha.com;
child-src https://api.stripe.com;
frame-src https://js.stripe.com/ https://api.stripe.com https://www.youtube.com/ https://hcaptcha.com https://*.hcaptcha.com;
connect-src 'self' wss://nexus-websocket-a.intercom.io https://api-iam.intercom.io https://api.heroku.com/ https://id.heroku.com/oauth/authorize https://id.heroku.com/oauth/token https://checkout.stripe.com https://app.posthog.com https://api.stripe.com https://api.pwnedpasswords.com http://127.0.0.1:* https://hcaptcha.com https://*.hcaptcha.com;
img-src 'self' https://static.intercomassets.com https://js.intercomcdn.com https://downloads.intercomcdn.com https://*.stripe.com https://i.ytimg.com/ data:;
media-src https://js.intercomcdn.com;
font-src 'self' https://fonts.intercomcdn.com/ https://fonts.gstatic.com;
connect-src 'self' https://d1zwf0dwl0k2ky.cloudfront.net wss://nexus-websocket-a.intercom.io https://api-iam.intercom.io https://api.heroku.com/ https://id.heroku.com/oauth/authorize https://id.heroku.com/oauth/token https://checkout.stripe.com https://app.posthog.com https://api.stripe.com https://api.pwnedpasswords.com http://127.0.0.1:* https://hcaptcha.com https://*.hcaptcha.com;
img-src 'self' https://d1zwf0dwl0k2ky.cloudfront.net https://static.intercomassets.com https://js.intercomcdn.com https://downloads.intercomcdn.com https://*.stripe.com https://i.ytimg.com/ data:;
media-src https://d1zwf0dwl0k2ky.cloudfront.net https://js.intercomcdn.com;
font-src 'self' https://d1zwf0dwl0k2ky.cloudfront.net https://fonts.intercomcdn.com/ https://fonts.gstatic.com;
"
/>
<title>Infisical</title>

View File

@@ -189,10 +189,12 @@ export const useGetCaCertTemplates = (caId: string) => {
export const useGetAzureAdcsTemplates = ({
caId,
projectId
projectId,
isAzureAdcsCa
}: {
caId: string;
projectId: string;
isAzureAdcsCa: boolean;
}) => {
return useQuery({
queryKey: caKeys.getAzureAdcsTemplates(caId, projectId),
@@ -202,6 +204,6 @@ export const useGetAzureAdcsTemplates = ({
}>(`/api/v1/cert-manager/ca/azure-ad-cs/${caId}/templates?projectId=${projectId}`);
return data;
},
enabled: Boolean(caId && projectId)
enabled: Boolean(caId && projectId && isAzureAdcsCa)
});
};

View File

@@ -22,14 +22,25 @@ export type TCertificateProfile = {
apiConfigId?: string;
createdAt: string;
updatedAt: string;
externalConfigs?: Record<string, unknown> | null;
certificateAuthority?: {
id: string;
projectId?: string;
status: string;
name: string;
isExternal?: boolean;
externalType?: string | null;
};
};
export type TCertificateProfileWithDetails = TCertificateProfile & {
certificateAuthority?: {
id: string;
projectId: string;
projectId?: string;
status: string;
name: string;
isExternal?: boolean;
externalType?: string | null;
};
certificateTemplate?: {
id: string;
@@ -72,6 +83,7 @@ export type TCreateCertificateProfileDTO = {
renewBeforeDays?: number;
};
acmeConfig?: unknown;
externalConfigs?: Record<string, unknown> | null;
};
export type TUpdateCertificateProfileDTO = {
@@ -90,6 +102,7 @@ export type TUpdateCertificateProfileDTO = {
renewBeforeDays?: number;
};
acmeConfig?: unknown;
externalConfigs?: Record<string, unknown> | null;
};
export type TDeleteCertificateProfileDTO = {

View File

@@ -13,6 +13,8 @@ import {
TRenewCertificateDTO,
TRenewCertificateResponse,
TRevokeCertDTO,
TUnifiedCertificateIssuanceDTO,
TUnifiedCertificateIssuanceResponse,
TUpdateRenewalConfigDTO
} from "./types";
@@ -185,3 +187,31 @@ export const useDownloadCertPkcs12 = () => {
}
});
};
export const useUnifiedCertificateIssuance = () => {
const queryClient = useQueryClient();
return useMutation<TUnifiedCertificateIssuanceResponse, object, TUnifiedCertificateIssuanceDTO>({
mutationFn: async (body) => {
const { projectSlug, ...requestData } = body;
const { data } = await apiRequest.post<TUnifiedCertificateIssuanceResponse>(
"/api/v1/cert-manager/certificates",
requestData
);
return data;
},
onSuccess: (_, { projectSlug }) => {
queryClient.invalidateQueries({
queryKey: ["certificate-profiles", "list"]
});
queryClient.invalidateQueries({
queryKey: pkiSubscriberKeys.allPkiSubscriberCertificates()
});
queryClient.invalidateQueries({
queryKey: projectKeys.allProjectCertificates()
});
queryClient.invalidateQueries({
queryKey: projectKeys.forProjectCertificates(projectSlug)
});
}
});
};

View File

@@ -7,7 +7,11 @@ import { TCertificate } from "./types";
export const certKeys = {
getCertById: (serialNumber: string) => [{ serialNumber }, "cert"],
getCertBody: (serialNumber: string) => [{ serialNumber }, "certBody"],
getCertBundle: (serialNumber: string) => [{ serialNumber }, "certBundle"]
getCertBundle: (serialNumber: string) => [{ serialNumber }, "certBundle"],
getCertificateRequest: (requestId: string, projectSlug: string) => [
{ requestId, projectSlug },
"certificateRequest"
]
};
export const useGetCert = (serialNumber: string) => {

View File

@@ -64,6 +64,7 @@ export type TRenewCertificateResponse = {
serialNumber: string;
certificateId: string;
projectId: string;
certificateRequestId?: string;
};
export type TUpdateRenewalConfigDTO = {
@@ -79,3 +80,59 @@ export type TDownloadPkcs12DTO = {
password: string;
alias: string;
};
export type TUnifiedCertificateIssuanceDTO = {
projectSlug: string;
profileId: string;
projectId: string;
csr?: string;
attributes?: {
commonName?: string;
keyUsages?: string[];
extendedKeyUsages?: string[];
altNames?: Array<{
type: string;
value: string;
}>;
signatureAlgorithm: string;
keyAlgorithm: string;
subjectAlternativeNames?: Array<{
type: string;
value: string;
}>;
ttl: string;
notBefore?: string;
notAfter?: string;
};
removeRootsFromChain?: boolean;
};
export type TUnifiedCertificateResponse = {
certificate: {
certificate: string;
issuingCaCertificate: string;
certificateChain: string;
privateKey?: string;
serialNumber: string;
certificateId: string;
};
certificateRequestId: string;
};
export type TCertificateRequestResponse = {
certificateRequestId: string;
status: "pending" | "issued" | "failed";
projectId: string;
};
export type TUnifiedCertificateIssuanceResponse =
| TUnifiedCertificateResponse
| TCertificateRequestResponse;
export type TCertificateRequestDetails = {
status: "pending" | "issued" | "failed";
certificate: TCertificate | null;
errorMessage: string | null;
createdAt: string;
updatedAt: string;
};

View File

@@ -20,9 +20,9 @@ import {
} from "@app/components/v2";
import { useProject } from "@app/context";
import { useGetCert } from "@app/hooks/api";
import { useCreateCertificateV3 } from "@app/hooks/api/ca";
import { EnrollmentType, useListCertificateProfiles } from "@app/hooks/api/certificateProfiles";
import { CertExtendedKeyUsage, CertKeyUsage } from "@app/hooks/api/certificates/enums";
import { useUnifiedCertificateIssuance } from "@app/hooks/api/certificates/mutations";
import { useGetCertificateTemplateV2ById } from "@app/hooks/api/certificateTemplates/queries";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { CertSubjectAlternativeNameType } from "@app/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/certificate-constants";
@@ -103,10 +103,11 @@ type Props = {
};
type TCertificateDetails = {
serialNumber: string;
certificate: string;
certificateChain: string;
privateKey: string;
serialNumber?: string;
certificate?: string;
certificateChain?: string;
privateKey?: string;
issuingCaCertificate?: string;
};
export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }: Props) => {
@@ -122,12 +123,11 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
const { data: profilesData } = useListCertificateProfiles({
projectId: currentProject?.id || "",
enrollmentType: EnrollmentType.API
enrollmentType: EnrollmentType.API,
includeConfigs: true
});
const { mutateAsync: createCertificate } = useCreateCertificateV3({
projectId: currentProject?.id
});
const { mutateAsync: issueCertificate } = useUnifiedCertificateIssuance();
const formResolver = useMemo(() => {
return zodResolver(createSchema(shouldShowSubjectSection));
@@ -243,7 +243,7 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
keyUsages,
extendedKeyUsages
}: FormData) => {
if (!currentProject?.slug) {
if (!currentProject?.slug || !currentProject?.id) {
createNotification({
text: "Project not found. Please refresh and try again.",
type: "error"
@@ -275,44 +275,70 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
}
}
const certificateRequest: any = {
profileId: formProfileId,
projectSlug: currentProject.slug,
ttl,
signatureAlgorithm,
keyAlgorithm,
keyUsages: filterUsages(keyUsages) as CertKeyUsage[],
extendedKeyUsages: filterUsages(extendedKeyUsages) as CertExtendedKeyUsage[]
};
try {
// Prepare unified request
const request: any = {
profileId: formProfileId,
projectSlug: currentProject.slug,
projectId: currentProject.id,
attributes: {
ttl,
signatureAlgorithm: signatureAlgorithm || "",
keyAlgorithm: keyAlgorithm || "",
keyUsages: filterUsages(keyUsages) as CertKeyUsage[],
extendedKeyUsages: filterUsages(extendedKeyUsages) as CertExtendedKeyUsage[]
}
};
if (constraints.shouldShowSubjectSection && commonName) {
certificateRequest.commonName = commonName;
}
if (constraints.shouldShowSanSection && subjectAltNames && subjectAltNames.length > 0) {
const formattedSans = formatSubjectAltNames(subjectAltNames);
if (formattedSans && formattedSans.length > 0) {
certificateRequest.altNames = formattedSans;
if (constraints.shouldShowSubjectSection && commonName) {
request.attributes.commonName = commonName;
}
if (constraints.shouldShowSanSection && subjectAltNames && subjectAltNames.length > 0) {
const formattedSans = formatSubjectAltNames(subjectAltNames);
if (formattedSans && formattedSans.length > 0) {
request.attributes.altNames = formattedSans;
}
}
const response = await issueCertificate(request);
// Handle certificate issuance response
if ("certificate" in response && response.certificate) {
const certData = response.certificate;
const certificateDetailsToSet = {
serialNumber: certData.serialNumber || "",
certificate: certData.certificate || "",
certificateChain: certData.certificateChain || "",
privateKey: certData.privateKey || "",
issuingCaCertificate: certData.issuingCaCertificate || ""
};
setCertificateDetails(certificateDetailsToSet);
createNotification({
text: "Successfully created certificate",
type: "success"
});
} else {
// Certificate request - async processing
createNotification({
text: `Certificate request submitted successfully. This may take a few minutes to process. Certificate Request ID: ${response.certificateRequestId}`,
type: "success"
});
handlePopUpToggle("issueCertificate", false);
}
} catch (error) {
createNotification({
text: `Failed to request certificate: ${(error as Error)?.message || "Unknown error"}`,
type: "error"
});
}
const { serialNumber, certificate, certificateChain, privateKey } =
await createCertificate(certificateRequest);
setCertificateDetails({
serialNumber,
certificate,
certificateChain,
privateKey
});
createNotification({
text: "Successfully created certificate",
type: "success"
});
},
[
currentProject?.slug,
createCertificate,
issueCertificate,
constraints.shouldShowSubjectSection,
constraints.shouldShowSanSection
]
@@ -343,10 +369,10 @@ export const CertificateIssuanceModal = ({ popUp, handlePopUpToggle, profileId }
<ModalContent title={getModalTitle()} subTitle={getModalSubTitle()}>
{certificateDetails && (
<CertificateContent
serialNumber={certificateDetails.serialNumber}
certificate={certificateDetails.certificate}
certificateChain={certificateDetails.certificateChain}
privateKey={certificateDetails.privateKey}
serialNumber={certificateDetails.serialNumber!}
certificate={certificateDetails.certificate!}
certificateChain={certificateDetails.certificateChain!}
privateKey={certificateDetails.privateKey!}
/>
)}
{cert && (

View File

@@ -20,12 +20,16 @@ export const CertificateRenewalModal = ({ popUp, handlePopUpToggle }: Props) =>
const onRenewConfirm = async () => {
const { certificateId } = popUp.renewCertificate.data as { certificateId: string };
await renewCertificate({
const result = await renewCertificate({
certificateId
});
const notificationText = result.certificateRequestId
? `Certificate renewal initiated successfully. Certificate Request ID: ${result.certificateRequestId}`
: "Certificate renewed successfully";
createNotification({
text: "Certificate renewed successfully",
text: notificationText,
type: "success"
});

View File

@@ -125,7 +125,7 @@ export const CertificatesSection = () => {
onClick={() => handlePopUpOpen("issueCertificate")}
isDisabled={!isAllowed}
>
Issue
Request
</Button>
</div>
)}

View File

@@ -213,7 +213,8 @@ export const PkiSubscriberModal = ({ popUp, handlePopUpToggle }: Props) => {
// Fetch Azure ADCS templates when Azure CA is selected
const { data: azureTemplates } = useGetAzureAdcsTemplates({
caId: selectedCa?.type === CaType.AZURE_AD_CS ? selectedCaId : "",
projectId
projectId,
isAzureAdcsCa: true
});
// Initialize form with ALL subscriber data including template

View File

@@ -9,6 +9,7 @@ import { createNotification } from "@app/components/notifications";
import {
Button,
Checkbox,
FilterableSelect,
FormControl,
Input,
Modal,
@@ -19,7 +20,8 @@ import {
Tooltip
} from "@app/components/v2";
import { useProject, useSubscription } from "@app/context";
import { useListCasByProjectId } from "@app/hooks/api/ca/queries";
import { CaType } from "@app/hooks/api/ca/enums";
import { useGetAzureAdcsTemplates, useListCasByProjectId } from "@app/hooks/api/ca/queries";
import {
EnrollmentType,
IssuerType,
@@ -77,7 +79,12 @@ const createSchema = z
renewBeforeDays: z.number().min(1).max(365).optional()
})
.optional(),
acmeConfig: z.object({}).optional()
acmeConfig: z.object({}).optional(),
externalConfigs: z
.object({
template: z.string().min(1, "Azure ADCS template is required")
})
.optional()
})
.refine(
(data) => {
@@ -212,7 +219,12 @@ const editSchema = z
renewBeforeDays: z.number().min(1).max(365).optional()
})
.optional(),
acmeConfig: z.object({}).optional()
acmeConfig: z.object({}).optional(),
externalConfigs: z
.object({
template: z.string().optional()
})
.optional()
})
.refine(
(data) => {
@@ -339,7 +351,7 @@ export const CreateProfileModal = ({
const { currentProject } = useProject();
const { subscription } = useSubscription();
const { data: caData } = useListCasByProjectId(currentProject?.id || "");
const { data: allCaData } = useListCasByProjectId(currentProject?.id || "");
const { data: templateData } = useListCertificateTemplatesV2({
projectId: currentProject?.id || "",
limit: 100,
@@ -351,9 +363,23 @@ export const CreateProfileModal = ({
const isEdit = mode === "edit" && profile;
const certificateAuthorities = caData || [];
const certificateAuthorities = (allCaData || []).map((ca) => ({
...ca,
groupType: ca.type === "internal" ? "internal" : "external"
}));
const certificateTemplates = templateData?.certificateTemplates || [];
const getGroupHeaderLabel = (groupType: "internal" | "external") => {
switch (groupType) {
case "internal":
return "Internal CAs";
case "external":
return "External CAs";
default:
return "";
}
};
const { control, handleSubmit, reset, watch, setValue, formState } = useForm<FormData>({
resolver: zodResolver(isEdit ? editSchema : createSchema),
defaultValues: isEdit
@@ -380,7 +406,17 @@ export const CreateProfileModal = ({
renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30
}
: undefined,
acmeConfig: profile.enrollmentType === EnrollmentType.ACME ? {} : undefined
acmeConfig: profile.enrollmentType === EnrollmentType.ACME ? {} : undefined,
externalConfigs: profile.externalConfigs
? {
template:
typeof profile.externalConfigs === "object" &&
profile.externalConfigs !== null &&
typeof profile.externalConfigs.template === "string"
? profile.externalConfigs.template
: ""
}
: undefined
}
: {
slug: "",
@@ -393,15 +429,28 @@ export const CreateProfileModal = ({
autoRenew: false,
renewBeforeDays: 30
},
acmeConfig: {}
acmeConfig: {},
externalConfigs: undefined
}
});
const watchedEnrollmentType = watch("enrollmentType");
const watchedIssuerType = watch("issuerType");
const watchedCertificateAuthorityId = watch("certificateAuthorityId");
const watchedDisableBootstrapValidation = watch("estConfig.disableBootstrapCaValidation");
const watchedAutoRenew = watch("apiConfig.autoRenew");
// Get the selected CA to check if it's Azure ADCS
const selectedCa = certificateAuthorities.find((ca) => ca.id === watchedCertificateAuthorityId);
const isAzureAdcsCa = selectedCa?.type === CaType.AZURE_AD_CS;
// Fetch Azure ADCS templates if needed
const { data: azureAdcsTemplatesData } = useGetAzureAdcsTemplates({
caId: watchedCertificateAuthorityId || "",
projectId: currentProject?.id || "",
isAzureAdcsCa
});
useEffect(() => {
if (isEdit && profile) {
reset({
@@ -427,10 +476,38 @@ export const CreateProfileModal = ({
renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30
}
: undefined,
acmeConfig: profile.enrollmentType === EnrollmentType.ACME ? {} : undefined
acmeConfig: profile.enrollmentType === EnrollmentType.ACME ? {} : undefined,
externalConfigs: profile.externalConfigs
? {
template:
typeof profile.externalConfigs === "object" &&
profile.externalConfigs !== null &&
typeof profile.externalConfigs.template === "string"
? profile.externalConfigs.template
: ""
}
: undefined
});
}
}, [isEdit, profile, reset]);
}, [isEdit, profile, reset, allCaData]);
// Additional effect to reset external configs when Azure ADCS templates are loaded
useEffect(() => {
if (
isEdit &&
profile &&
isAzureAdcsCa &&
azureAdcsTemplatesData?.templates &&
profile.externalConfigs &&
typeof profile.externalConfigs === "object" &&
profile.externalConfigs !== null &&
typeof profile.externalConfigs.template === "string"
) {
// Re-set the external configs to ensure the template value is properly set
// after the Azure ADCS templates have been loaded
setValue("externalConfigs.template", profile.externalConfigs.template);
}
}, [isEdit, profile, isAzureAdcsCa, azureAdcsTemplatesData, setValue]);
const onFormSubmit = async (data: FormData) => {
if (!isEdit && !subscription?.pkiAcme && data.enrollmentType === EnrollmentType.ACME) {
@@ -444,6 +521,18 @@ export const CreateProfileModal = ({
if (!currentProject?.id && !isEdit) return;
// Validate Azure ADCS template requirement
if (
isAzureAdcsCa &&
(!data.externalConfigs?.template || data.externalConfigs.template.trim() === "")
) {
createNotification({
text: "Azure ADCS Certificate Authority requires a template to be specified",
type: "error"
});
return;
}
if (isEdit) {
const updateData: TUpdateCertificateProfileDTO = {
profileId: profile.id,
@@ -460,6 +549,11 @@ export const CreateProfileModal = ({
updateData.acmeConfig = data.acmeConfig;
}
// Add external configs if present
if (data.externalConfigs) {
updateData.externalConfigs = data.externalConfigs;
}
await updateProfile.mutateAsync(updateData);
} else {
if (!currentProject?.id) {
@@ -491,6 +585,11 @@ export const CreateProfileModal = ({
createData.acmeConfig = data.acmeConfig;
}
// Add external configs if present
if (data.externalConfigs) {
createData.externalConfigs = data.externalConfigs;
}
await createProfile.mutateAsync(createData);
}
@@ -587,30 +686,82 @@ export const CreateProfileModal = ({
<Controller
control={control}
name="certificateAuthorityId"
render={({ field: { onChange, value, ...field }, fieldState: { error } }) => (
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
label="Issuing CA"
isRequired
isError={Boolean(error)}
errorText={error?.message}
>
<Select
{...field}
value={value || undefined}
onValueChange={onChange}
<FilterableSelect
value={certificateAuthorities.find((ca) => ca.id === value) || null}
onChange={(selectedCaValue) => {
if (Array.isArray(selectedCaValue)) {
onChange(selectedCaValue[0]?.id || "");
} else if (
selectedCaValue &&
typeof selectedCaValue === "object" &&
"id" in selectedCaValue
) {
onChange(selectedCaValue.id || "");
} else {
onChange("");
}
}}
getOptionLabel={(ca) =>
ca.type === "internal" && ca.configuration.friendlyName
? ca.configuration.friendlyName
: ca.name
}
getOptionValue={(ca) => ca.id}
options={certificateAuthorities}
groupBy="groupType"
getGroupHeaderLabel={getGroupHeaderLabel}
placeholder="Select a certificate authority"
className="w-full"
position="popper"
isDisabled={Boolean(isEdit)}
>
{certificateAuthorities.map((ca) => (
<SelectItem key={ca.id} value={ca.id}>
{ca.type === "internal" && ca.configuration.friendlyName
? ca.configuration.friendlyName
: ca.name}
</SelectItem>
))}
</Select>
className="w-full"
/>
</FormControl>
)}
/>
)}
{/* Azure ADCS Template Selection */}
{isAzureAdcsCa && (
<Controller
control={control}
name="externalConfigs.template"
render={({ field: { onChange, value }, fieldState: { error } }) => (
<FormControl
label="Windows ADCS Template"
isRequired
isError={Boolean(error)}
errorText={error?.message}
>
<FilterableSelect
value={
azureAdcsTemplatesData?.templates.find((template) => template.id === value) ||
null
}
onChange={(selectedTemplate) => {
if (Array.isArray(selectedTemplate)) {
onChange(selectedTemplate[0]?.id || "");
} else if (
selectedTemplate &&
typeof selectedTemplate === "object" &&
"id" in selectedTemplate
) {
onChange(selectedTemplate.id || "");
} else {
onChange("");
}
}}
getOptionLabel={(template) => template.name}
getOptionValue={(template) => template.id}
options={azureAdcsTemplatesData?.templates || []}
placeholder="Select an Azure ADCS certificate template"
className="w-full"
/>
</FormControl>
)}
/>

View File

@@ -1,3 +1,4 @@
/* eslint-disable no-nested-ternary */
import { useCallback } from "react";
import { subject } from "@casl/ability";
import {
@@ -101,7 +102,9 @@ export const ProfileRow = ({
<span className="text-sm text-mineshaft-300">
{profile.issuerType === IssuerType.SELF_SIGNED
? "Self-signed"
: caData?.configuration.friendlyName ||
: profile.certificateAuthority?.isExternal
? profile.certificateAuthority.name
: caData?.configuration.friendlyName ||
caData?.configuration.commonName ||
profile.caId}
</span>