Merge remote-tracking branch 'origin/main' into feat/blockDuplicateSyncDestination

This commit is contained in:
Carlos Monastyrski
2025-10-27 18:04:01 -03:00
253 changed files with 11768 additions and 4854 deletions
@@ -345,6 +345,8 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) =>
case GitHubConnectionMethod.App:
case GitHubRadarConnectionMethod.App:
return "GitHub App";
case GitHubConnectionMethod.Pat:
return "Personal Access Token";
case AzureKeyVaultConnectionMethod.OAuth:
case AzureAppConfigurationConnectionMethod.OAuth:
case AzureClientSecretsConnectionMethod.OAuth:
@@ -1,4 +1,5 @@
export enum GitHubConnectionMethod {
OAuth = "oauth",
App = "github-app"
App = "github-app",
Pat = "pat"
}
@@ -248,10 +248,18 @@ export const makePaginatedGitHubRequest = async <T, R = T[]>(
): Promise<T[]> => {
const { credentials, method } = appConnection;
const token =
method === GitHubConnectionMethod.OAuth
? credentials.accessToken
: await getGitHubAppAuthToken(appConnection, gatewayService, gatewayV2Service);
let token: string;
switch (method) {
case GitHubConnectionMethod.OAuth:
token = credentials.accessToken;
break;
case GitHubConnectionMethod.Pat:
token = credentials.personalAccessToken;
break;
default:
token = await getGitHubAppAuthToken(appConnection, gatewayService, gatewayV2Service);
}
const baseUrl = `https://${await getGitHubInstanceApiUrl(appConnection)}${path}`;
const initialUrlObj = new URL(baseUrl);
@@ -460,6 +468,35 @@ export const validateGitHubConnectionCredentials = async (
gatewayV2Service: Pick<TGatewayV2ServiceFactory, "getPlatformConnectionDetailsByGatewayId">
) => {
const { credentials, method } = config;
// PAT validation
if (method === GitHubConnectionMethod.Pat) {
try {
const apiUrl = await getGitHubInstanceApiUrl(config);
await requestWithGitHubGateway(config, gatewayService, gatewayV2Service, {
url: `https://${apiUrl}/user`,
method: "GET",
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${credentials.personalAccessToken}`,
"X-GitHub-Api-Version": "2022-11-28"
}
});
return {
personalAccessToken: credentials.personalAccessToken,
instanceType: credentials.instanceType,
host: credentials.host
};
} catch (e: unknown) {
logger.error(e, "Unable to verify GitHub PAT connection");
throw new BadRequestError({
message: "Unable to validate Personal Access Token: verify token has proper permissions"
});
}
}
const {
INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_ID,
INF_APP_CONNECTION_GITHUB_OAUTH_CLIENT_SECRET,
@@ -38,6 +38,19 @@ export const GitHubConnectionAppInputCredentialsSchema = z.union([
})
]);
export const GitHubConnectionPatInputCredentialsSchema = z.union([
z.object({
personalAccessToken: z.string().trim().min(1, "Personal Access Token required"),
instanceType: z.literal("server"),
host: z.string().trim().min(1, "Host is required for server instance type")
}),
z.object({
personalAccessToken: z.string().trim().min(1, "Personal Access Token required"),
instanceType: z.literal("cloud").optional(),
host: z.string().trim().optional()
})
]);
export const GitHubConnectionOAuthOutputCredentialsSchema = z.union([
z.object({
accessToken: z.string(),
@@ -64,6 +77,19 @@ export const GitHubConnectionAppOutputCredentialsSchema = z.union([
})
]);
export const GitHubConnectionPatOutputCredentialsSchema = z.union([
z.object({
personalAccessToken: z.string(),
instanceType: z.literal("server"),
host: z.string().trim().min(1)
}),
z.object({
personalAccessToken: z.string(),
instanceType: z.literal("cloud").optional(),
host: z.string().trim().optional()
})
]);
export const ValidateGitHubConnectionCredentialsSchema = z.discriminatedUnion("method", [
z.object({
method: z.literal(GitHubConnectionMethod.App).describe(AppConnections.CREATE(AppConnection.GitHub).method),
@@ -76,6 +102,12 @@ export const ValidateGitHubConnectionCredentialsSchema = z.discriminatedUnion("m
credentials: GitHubConnectionOAuthInputCredentialsSchema.describe(
AppConnections.CREATE(AppConnection.GitHub).credentials
)
}),
z.object({
method: z.literal(GitHubConnectionMethod.Pat).describe(AppConnections.CREATE(AppConnection.GitHub).method),
credentials: GitHubConnectionPatInputCredentialsSchema.describe(
AppConnections.CREATE(AppConnection.GitHub).credentials
)
})
]);
@@ -88,7 +120,11 @@ export const CreateGitHubConnectionSchema = ValidateGitHubConnectionCredentialsS
export const UpdateGitHubConnectionSchema = z
.object({
credentials: z
.union([GitHubConnectionAppInputCredentialsSchema, GitHubConnectionOAuthInputCredentialsSchema])
.union([
GitHubConnectionAppInputCredentialsSchema,
GitHubConnectionOAuthInputCredentialsSchema,
GitHubConnectionPatInputCredentialsSchema
])
.optional()
.describe(AppConnections.UPDATE(AppConnection.GitHub).credentials)
})
@@ -110,6 +146,10 @@ export const GitHubConnectionSchema = z.intersection(
z.object({
method: z.literal(GitHubConnectionMethod.OAuth),
credentials: GitHubConnectionOAuthOutputCredentialsSchema
}),
z.object({
method: z.literal(GitHubConnectionMethod.Pat),
credentials: GitHubConnectionPatOutputCredentialsSchema
})
])
);
@@ -128,6 +168,13 @@ export const SanitizedGitHubConnectionSchema = z.discriminatedUnion("method", [
instanceType: z.union([z.literal("server"), z.literal("cloud")]).optional(),
host: z.string().optional()
})
}),
BaseGitHubConnectionSchema.extend({
method: z.literal(GitHubConnectionMethod.Pat),
credentials: z.object({
instanceType: z.union([z.literal("server"), z.literal("cloud")]).optional(),
host: z.string().optional()
})
})
]);
@@ -25,6 +25,23 @@ import {
THCVaultMountResponse
} from "./hc-vault-connection-types";
// HashiCorp Vault stores JSON data, so values can be any valid JSON type
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
export const convertVaultValueToString = (value: JsonValue): string => {
if (value === null) {
return "";
}
if (typeof value === "string") {
return value;
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
// For objects and arrays, serialize as JSON
return JSON.stringify(value);
};
// Concurrency limit for HC Vault API requests to avoid rate limiting
const HC_VAULT_CONCURRENCY_LIMIT = 20;
@@ -598,7 +615,7 @@ export const getHCVaultSecretsForPath = async (
// For KV v2: /v1/{mount}/data/{path}
const { data } = await requestWithHCVaultGateway<{
data: {
data: Record<string, string>; // KV v2 has nested data structure
data: Record<string, JsonValue>; // KV v2 has nested data structure, supports all JSON types
metadata: {
created_time: string;
deletion_time: string;
@@ -620,7 +637,7 @@ export const getHCVaultSecretsForPath = async (
// For KV v1: /v1/{mount}/{path}
const { data } = await requestWithHCVaultGateway<{
data: Record<string, string>; // KV v1 has flat data structure
data: Record<string, JsonValue>; // KV v1 has flat data structure, supports all JSON types
lease_duration: number;
lease_id: string;
renewable: boolean;
@@ -2,12 +2,14 @@
import { ForbiddenError, subject } from "@casl/ability";
import * as x509 from "@peculiar/x509";
import slugify from "@sindresorhus/slugify";
import { Knex } from "knex";
import { ActionProjectType, TableName, TCertificateAuthorities, TCertificateTemplates } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import {
ProjectPermissionActions,
ProjectPermissionCertificateActions,
ProjectPermissionCertificateProfileActions,
ProjectPermissionPkiTemplateActions,
ProjectPermissionSub
} from "@app/ee/services/permission/project-permission";
@@ -1180,7 +1182,9 @@ export const internalCertificateAuthorityServiceFactory = ({
extendedKeyUsages,
signatureAlgorithm,
keyAlgorithm,
isFromProfile
isFromProfile,
internal = false,
tx
}: TIssueCertFromCaDTO) => {
let ca: TCertificateAuthorityWithAssociatedCa | undefined;
let certificateTemplate: TCertificateTemplates | undefined;
@@ -1210,19 +1214,28 @@ export const internalCertificateAuthorityServiceFactory = ({
throw new NotFoundError({ message: `Internal CA with ID '${caId}' not found` });
}
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: ca.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
if (!internal) {
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: ca.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionCertificateActions.Create,
ProjectPermissionSub.Certificates
);
if (isFromProfile) {
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionCertificateProfileActions.IssueCert,
ProjectPermissionSub.CertificateProfiles
);
} else {
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionCertificateActions.Create,
ProjectPermissionSub.Certificates
);
}
}
if (ca.status !== CaStatus.ACTIVE) throw new BadRequestError({ message: "CA is not active" });
if (!ca.internalCa.activeCaCertId)
@@ -1473,7 +1486,7 @@ export const internalCertificateAuthorityServiceFactory = ({
plainText: Buffer.from(certificateChainPem)
});
await certificateDAL.transaction(async (tx) => {
const executeIssueCertOperations = async (transaction: Knex) => {
const cert = await certificateDAL.create(
{
caId: (ca as TCertificateAuthorities).id,
@@ -1488,9 +1501,11 @@ export const internalCertificateAuthorityServiceFactory = ({
notAfter: notAfterDate,
keyUsages: selectedKeyUsages,
extendedKeyUsages: selectedExtendedKeyUsages,
projectId: ca!.projectId
projectId: ca!.projectId,
keyAlgorithm: effectiveKeyAlgorithm,
signatureAlgorithm: signatureAlgorithm || ca!.internalCa!.keyAlgorithm
},
tx
transaction
);
await certificateBodyDAL.create(
@@ -1499,7 +1514,7 @@ export const internalCertificateAuthorityServiceFactory = ({
encryptedCertificate,
encryptedCertificateChain
},
tx
transaction
);
await certificateSecretDAL.create(
@@ -1507,7 +1522,7 @@ export const internalCertificateAuthorityServiceFactory = ({
certId: cert.id,
encryptedPrivateKey
},
tx
transaction
);
if (collectionId) {
@@ -1516,12 +1531,18 @@ export const internalCertificateAuthorityServiceFactory = ({
pkiCollectionId: collectionId,
certId: cert.id
},
tx
transaction
);
}
return cert;
});
};
if (tx) {
await executeIssueCertOperations(tx);
} else {
await certificateDAL.transaction(executeIssueCertOperations);
}
return {
certificate: leafCert.toString("pem"),
@@ -1593,10 +1614,17 @@ export const internalCertificateAuthorityServiceFactory = ({
actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionCertificateActions.Create,
ProjectPermissionSub.Certificates
);
if (dto.isFromProfile && dto.profileId) {
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionCertificateProfileActions.IssueCert,
ProjectPermissionSub.CertificateProfiles
);
} else {
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionCertificateActions.Create,
ProjectPermissionSub.Certificates
);
}
}
if (ca.status !== CaStatus.ACTIVE) throw new BadRequestError({ message: "CA is not active" });
@@ -1700,7 +1728,8 @@ export const internalCertificateAuthorityServiceFactory = ({
certificateAuthorityDAL,
certificateAuthoritySecretDAL,
projectDAL,
kmsService
kmsService,
signatureAlgorithm: alg
});
const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id });
@@ -1917,7 +1946,9 @@ export const internalCertificateAuthorityServiceFactory = ({
notAfter: notAfterDate,
keyUsages: selectedKeyUsages,
extendedKeyUsages: selectedExtendedKeyUsages,
projectId: ca!.projectId
projectId: ca!.projectId,
keyAlgorithm: keyAlgorithm || ca!.internalCa!.keyAlgorithm,
signatureAlgorithm: signatureAlgorithm || ca!.internalCa!.keyAlgorithm
},
tx
);
@@ -1,3 +1,4 @@
import { Knex } from "knex";
import { z } from "zod";
import { TCertificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal";
@@ -139,6 +140,9 @@ export type TIssueCertFromCaDTO = {
signatureAlgorithm?: CertSignatureAlgorithm;
keyAlgorithm?: CertKeyAlgorithm;
isFromProfile?: boolean;
profileId?: string;
internal?: boolean;
tx?: Knex;
} & Omit<TProjectPermission, "projectId">;
export type TSignCertFromCaDTO =
@@ -159,6 +163,7 @@ export type TSignCertFromCaDTO =
signatureAlgorithm?: string;
keyAlgorithm?: string;
isFromProfile?: boolean;
profileId?: string;
}
| ({
isInternal: false;
@@ -177,6 +182,7 @@ export type TSignCertFromCaDTO =
signatureAlgorithm?: string;
keyAlgorithm?: string;
isFromProfile?: boolean;
profileId?: string;
} & Omit<TProjectPermission, "projectId">);
export type TGetCaCertificateTemplatesDTO = {
@@ -175,6 +175,26 @@ export enum CertSignatureAlgorithm {
ECDSA_SHA512 = "ECDSA-SHA512"
}
export enum CertificateRenewalErrorType {
TEMPLATE_VALIDATION_FAILED = "TEMPLATE_VALIDATION_FAILED",
CA_NOT_FOUND = "CA_NOT_FOUND",
CA_INACTIVE = "CA_INACTIVE",
CERTIFICATE_OUTLIVES_CA = "CERTIFICATE_OUTLIVES_CA",
TTL_TOO_SHORT = "TTL_TOO_SHORT",
NOT_ELIGIBLE = "NOT_ELIGIBLE",
VALIDITY_EXCEEDS_MAXIMUM = "VALIDITY_EXCEEDS_MAXIMUM",
NOT_ALLOWED_BY_TEMPLATE = "NOT_ALLOWED_BY_TEMPLATE",
UNKNOWN_ERROR = "UNKNOWN_ERROR"
}
export const CERTIFICATE_RENEWAL_CONFIG = {
MIN_RENEW_BEFORE_DAYS: 1,
MAX_RENEW_BEFORE_DAYS: 30,
QUEUE_BATCH_SIZE: 100,
DAILY_CRON_SCHEDULE: "0 0 * * *",
QUEUE_START_DELAY_MS: 5000
} as const;
export const SAN_TYPE_OPTIONS = Object.values(CertSubjectAlternativeNameType);
export const KEY_USAGE_OPTIONS = Object.values(CertKeyUsageType);
export const EXTENDED_KEY_USAGE_OPTIONS = Object.values(CertExtendedKeyUsageType);
@@ -0,0 +1,183 @@
import * as x509 from "@peculiar/x509";
import { BadRequestError } from "@app/lib/errors";
import {
CertExtendedKeyUsageOIDToName,
CertKeyAlgorithm,
CertKeyUsage,
CertSignatureAlgorithm,
mapLegacyAltNameType,
TAltNameMapping,
TAltNameType
} from "../certificate/certificate-types";
import { parseDistinguishedName } from "../certificate-authority/certificate-authority-fns";
import { validateAndMapAltNameType } from "../certificate-authority/certificate-authority-validators";
import { TCertificateRequest } from "../certificate-template-v2/certificate-template-v2-types";
import { mapLegacyExtendedKeyUsageToStandard, mapLegacyKeyUsageToStandard } from "./certificate-constants";
/**
* Extracts certificate request data from a CSR string
* @param csr - The CSR in PEM format
* @returns TCertificateRequest object with parsed CSR data
*/
export const extractCertificateRequestFromCSR = (csr: string): TCertificateRequest => {
const csrObj = new x509.Pkcs10CertificateRequest(csr);
const subject = parseDistinguishedName(csrObj.subject);
const certificateRequest: TCertificateRequest = {
commonName: subject.commonName,
organization: subject.organization,
organizationUnit: subject.ou,
locality: subject.locality,
state: subject.province,
country: subject.country
};
const csrKeyUsageExtension = csrObj.getExtension("2.5.29.15") as x509.KeyUsagesExtension;
if (csrKeyUsageExtension) {
const csrKeyUsages = Object.values(CertKeyUsage).filter(
// eslint-disable-next-line no-bitwise
(keyUsage) => (x509.KeyUsageFlags[keyUsage] & csrKeyUsageExtension.usages) !== 0
);
certificateRequest.keyUsages = csrKeyUsages.map(mapLegacyKeyUsageToStandard);
}
const csrExtendedKeyUsageExtension = csrObj.getExtension("2.5.29.37") as x509.ExtendedKeyUsageExtension;
if (csrExtendedKeyUsageExtension) {
const csrExtendedKeyUsages = csrExtendedKeyUsageExtension.usages.map(
(ekuOid) => CertExtendedKeyUsageOIDToName[ekuOid as string]
);
certificateRequest.extendedKeyUsages = csrExtendedKeyUsages.map(mapLegacyExtendedKeyUsageToStandard);
}
const sanExtension = csrObj.extensions.find((ext) => ext.type === "2.5.29.17");
if (sanExtension) {
const sanNames = new x509.GeneralNames(sanExtension.value);
const altNamesArray: TAltNameMapping[] = sanNames.items
.filter(
(value) =>
value.type === TAltNameType.EMAIL ||
value.type === TAltNameType.DNS ||
value.type === TAltNameType.IP ||
value.type === TAltNameType.URL
)
.map((name): TAltNameMapping => {
const altNameType = validateAndMapAltNameType(name.value);
if (!altNameType) {
throw new BadRequestError({ message: `Invalid altName from CSR: ${name.value}` });
}
return altNameType;
});
certificateRequest.subjectAlternativeNames = altNamesArray.map((altName) => ({
type: mapLegacyAltNameType(altName.type),
value: altName.value
}));
}
return certificateRequest;
};
/**
* Extracts the key algorithm and signature algorithm from a CSR
* @param csr - The CSR in PEM format
* @returns Object containing keyAlgorithm and signatureAlgorithm
*/
export const extractAlgorithmsFromCSR = (csr: string) => {
const csrObj = new x509.Pkcs10CertificateRequest(csr);
// Extract key algorithm from public key
const { publicKey } = csrObj;
let keyAlgorithm: CertKeyAlgorithm;
if (publicKey.algorithm.name === "RSASSA-PKCS1-v1_5") {
const rsaPublicKey = publicKey as unknown as { algorithm: { modulusLength: number } };
const keySize = rsaPublicKey.algorithm.modulusLength;
switch (keySize) {
case 2048:
keyAlgorithm = CertKeyAlgorithm.RSA_2048;
break;
case 3072:
keyAlgorithm = CertKeyAlgorithm.RSA_3072;
break;
case 4096:
keyAlgorithm = CertKeyAlgorithm.RSA_4096;
break;
default:
throw new BadRequestError({
message: `Unsupported RSA key size in CSR: ${keySize}. Supported: 2048, 3072, 4096`
});
}
} else if (publicKey.algorithm.name === "ECDSA") {
const ecPublicKey = publicKey as unknown as { algorithm: { namedCurve: string } };
const { namedCurve } = ecPublicKey.algorithm;
switch (namedCurve) {
case "P-256":
keyAlgorithm = CertKeyAlgorithm.ECDSA_P256;
break;
case "P-384":
keyAlgorithm = CertKeyAlgorithm.ECDSA_P384;
break;
case "P-521":
keyAlgorithm = CertKeyAlgorithm.ECDSA_P521;
break;
default:
throw new BadRequestError({
message: `Unsupported ECDSA curve in CSR: ${namedCurve}. Supported: P-256, P-384, P-521`
});
}
} else {
throw new BadRequestError({
message: `Unsupported key algorithm in CSR: ${publicKey.algorithm.name}. Supported: RSASSA-PKCS1-v1_5, ECDSA`
});
}
const signatureAlgorithm = csrObj.signatureAlgorithm.name;
const hashName = (csrObj.signatureAlgorithm as unknown as { hash?: { name: string } }).hash?.name;
let normalizedSignatureAlg: CertSignatureAlgorithm;
if (signatureAlgorithm === "RSASSA-PKCS1-v1_5") {
switch (hashName) {
case "SHA-256":
normalizedSignatureAlg = CertSignatureAlgorithm.RSA_SHA256;
break;
case "SHA-384":
normalizedSignatureAlg = CertSignatureAlgorithm.RSA_SHA384;
break;
case "SHA-512":
normalizedSignatureAlg = CertSignatureAlgorithm.RSA_SHA512;
break;
default:
throw new BadRequestError({
message: `Unsupported RSA hash algorithm in CSR: ${hashName}. Supported: SHA-256, SHA-384, SHA-512`
});
}
} else if (signatureAlgorithm === "ECDSA") {
switch (hashName) {
case "SHA-256":
normalizedSignatureAlg = CertSignatureAlgorithm.ECDSA_SHA256;
break;
case "SHA-384":
normalizedSignatureAlg = CertSignatureAlgorithm.ECDSA_SHA384;
break;
case "SHA-512":
normalizedSignatureAlg = CertSignatureAlgorithm.ECDSA_SHA512;
break;
default:
throw new BadRequestError({
message: `Unsupported ECDSA hash algorithm in CSR: ${hashName}. Supported: SHA-256, SHA-384, SHA-512`
});
}
} else {
throw new BadRequestError({
message: `Unsupported signature algorithm in CSR: ${signatureAlgorithm}. Supported: RSASSA-PKCS1-v1_5, ECDSA`
});
}
return {
keyAlgorithm,
signatureAlgorithm: normalizedSignatureAlg
};
};
@@ -3,31 +3,15 @@ import * as x509 from "@peculiar/x509";
import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate";
import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
import { isCertChainValid } from "@app/services/certificate/certificate-fns";
import {
CertExtendedKeyUsageOIDToName,
CertKeyUsage,
mapLegacyAltNameType,
TAltNameMapping,
TAltNameType
} from "@app/services/certificate/certificate-types";
import { TCertificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal";
import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal";
import {
getCaCertChain,
getCaCertChains,
parseDistinguishedName
} from "@app/services/certificate-authority/certificate-authority-fns";
import { validateAndMapAltNameType } from "@app/services/certificate-authority/certificate-authority-validators";
import { getCaCertChain, getCaCertChains } from "@app/services/certificate-authority/certificate-authority-fns";
import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service";
import {
mapLegacyExtendedKeyUsageToStandard,
mapLegacyKeyUsageToStandard
} from "@app/services/certificate-common/certificate-constants";
import { extractCertificateRequestFromCSR } from "@app/services/certificate-common/certificate-csr-utils";
import { mapEnumsForValidation } from "@app/services/certificate-common/certificate-utils";
import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal";
import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types";
import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service";
import { TCertificateRequest } from "@app/services/certificate-template-v2/certificate-template-v2-types";
import { TEstEnrollmentConfigDALFactory } from "@app/services/enrollment-config/est-enrollment-config-dal";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TProjectDALFactory } from "@app/services/project/project-dal";
@@ -61,63 +45,6 @@ export const certificateEstV3ServiceFactory = ({
certificateProfileDAL,
estEnrollmentConfigDAL
}: TCertificateEstV3ServiceFactoryDep) => {
const extractCertificateRequestFromCSR = (csr: string): TCertificateRequest => {
const csrObj = new x509.Pkcs10CertificateRequest(csr);
const subject = parseDistinguishedName(csrObj.subject);
const certificateRequest: TCertificateRequest = {
commonName: subject.commonName,
organization: subject.organization,
organizationUnit: subject.ou,
locality: subject.locality,
state: subject.province,
country: subject.country
};
const csrKeyUsageExtension = csrObj.getExtension("2.5.29.15") as x509.KeyUsagesExtension;
if (csrKeyUsageExtension) {
const csrKeyUsages = Object.values(CertKeyUsage).filter(
// eslint-disable-next-line no-bitwise
(keyUsage) => (x509.KeyUsageFlags[keyUsage] & csrKeyUsageExtension.usages) !== 0
);
certificateRequest.keyUsages = csrKeyUsages.map(mapLegacyKeyUsageToStandard);
}
const csrExtendedKeyUsageExtension = csrObj.getExtension("2.5.29.37") as x509.ExtendedKeyUsageExtension;
if (csrExtendedKeyUsageExtension) {
const csrExtendedKeyUsages = csrExtendedKeyUsageExtension.usages.map(
(ekuOid) => CertExtendedKeyUsageOIDToName[ekuOid as string]
);
certificateRequest.extendedKeyUsages = csrExtendedKeyUsages.map(mapLegacyExtendedKeyUsageToStandard);
}
const sanExtension = csrObj.extensions.find((ext) => ext.type === "2.5.29.17");
if (sanExtension) {
const sanNames = new x509.GeneralNames(sanExtension.value);
const altNamesArray: TAltNameMapping[] = sanNames.items
.filter(
(value) =>
value.type === TAltNameType.EMAIL ||
value.type === TAltNameType.DNS ||
value.type === TAltNameType.IP ||
value.type === TAltNameType.URL
)
.map((name): TAltNameMapping => {
const altNameType = validateAndMapAltNameType(name.value);
if (!altNameType) {
throw new BadRequestError({ message: `Invalid altName from CSR: ${name.value}` });
}
return altNameType;
});
certificateRequest.subjectAlternativeNames = altNamesArray.map((altName) => ({
type: mapLegacyAltNameType(altName.type),
value: altName.value
}));
}
return certificateRequest;
};
const simpleEnrollByProfile = async ({
csr,
profileId,
@@ -109,7 +109,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estConfigEncryptedCaChain"),
db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigId"),
db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigAutoRenew"),
db.ref("autoRenewDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigAutoRenewDays")
db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigRenewBeforeDays")
)
.where(`${TableName.PkiCertificateProfile}.id`, id)
.first();
@@ -132,7 +132,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
? ({
id: result.apiConfigId,
autoRenew: !!result.apiConfigAutoRenew,
autoRenewDays: result.apiConfigAutoRenewDays || undefined
renewBeforeDays: result.apiConfigRenewBeforeDays || undefined
} as TCertificateProfileWithConfigs["apiConfig"])
: undefined;
@@ -264,7 +264,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estEncryptedCaChain"),
db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiId"),
db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenew"),
db.ref("autoRenewDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenewDays")
db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiRenewBeforeDays")
);
if (includeMetrics) {
@@ -290,7 +290,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estEncryptedCaChain"),
db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiId"),
db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenew"),
db.ref("autoRenewDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenewDays"),
db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiRenewBeforeDays"),
db.raw("COUNT(certificates.id) as total_certificates"),
db.raw(
'COUNT(CASE WHEN certificates."revokedAt" IS NULL AND certificates."notAfter" > ? THEN 1 END) as active_certificates',
@@ -333,7 +333,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
? {
id: result.apiId as string,
autoRenew: !!result.apiAutoRenew,
autoRenewDays: (result.apiAutoRenewDays as number) || undefined
renewBeforeDays: (result.apiRenewBeforeDays as number) || undefined
}
: undefined;
@@ -25,7 +25,7 @@ export const createCertificateProfileSchema = z
apiConfig: z
.object({
autoRenew: z.boolean().default(false),
autoRenewDays: z.number().min(1).max(365).optional()
renewBeforeDays: z.number().min(1).max(30).optional()
})
.optional()
})
@@ -75,7 +75,7 @@ export const updateCertificateProfileSchema = z
apiConfig: z
.object({
autoRenew: z.boolean().default(false),
autoRenewDays: z.number().min(1).max(365).optional()
renewBeforeDays: z.number().min(1).max(30).optional()
})
.optional()
})
@@ -110,7 +110,7 @@ describe("CertificateProfileService", () => {
apiConfig: {
id: "api-config-123",
autoRenew: true,
autoRenewDays: 30
renewBeforeDays: 30
}
};
@@ -202,7 +202,7 @@ describe("CertificateProfileService", () => {
certificateTemplateId: "template-123",
apiConfig: {
autoRenew: true,
autoRenewDays: 30
renewBeforeDays: 30
}
};
@@ -323,7 +323,7 @@ describe("CertificateProfileService", () => {
certificateTemplateId: "template-123",
apiConfig: {
autoRenew: true,
autoRenewDays: 30
renewBeforeDays: 30
}
};
@@ -761,7 +761,7 @@ describe("CertificateProfileService", () => {
certificateTemplateId: "template-123",
apiConfig: {
autoRenew: true,
autoRenewDays: 30
renewBeforeDays: 30
}
};
@@ -786,7 +786,7 @@ describe("CertificateProfileService", () => {
certificateTemplateId: "template-123",
apiConfig: {
autoRenew: true,
autoRenewDays: 7
renewBeforeDays: 7
}
};
@@ -808,7 +808,7 @@ describe("CertificateProfileService", () => {
expect(mockApiEnrollmentConfigDAL.create).toHaveBeenCalledWith(
{
autoRenew: true,
autoRenewDays: 7
renewBeforeDays: 7
},
undefined
);
@@ -225,7 +225,7 @@ export const certificateProfileServiceFactory = ({
const apiConfig = await apiEnrollmentConfigDAL.create(
{
autoRenew: data.apiConfig.autoRenew,
autoRenewDays: data.apiConfig.autoRenewDays
renewBeforeDays: data.apiConfig.renewBeforeDays
},
tx
);
@@ -343,7 +343,7 @@ export const certificateProfileServiceFactory = ({
existingProfile.apiConfigId,
{
autoRenew: apiConfig.autoRenew,
autoRenewDays: apiConfig.autoRenewDays
renewBeforeDays: apiConfig.renewBeforeDays
},
tx
);
@@ -26,7 +26,7 @@ export type TCertificateProfileUpdate = Omit<TPkiCertificateProfilesUpdate, "enr
};
apiConfig?: {
autoRenew?: boolean;
autoRenewDays?: number;
renewBeforeDays?: number;
};
};
@@ -52,7 +52,7 @@ export type TCertificateProfileWithConfigs = TCertificateProfile & {
apiConfig?: {
id: string;
autoRenew: boolean;
autoRenewDays?: number;
renewBeforeDays?: number;
};
metrics?: TCertificateProfileMetrics;
};
@@ -762,32 +762,36 @@ export const certificateTemplateV2ServiceFactory = ({
actorId,
actorAuthMethod,
actorOrgId,
templateId
templateId,
internal = false
}: {
actor: ActorType;
actorId: string;
actorAuthMethod: ActorAuthMethod;
actorOrgId: string;
templateId: string;
internal?: boolean;
}): Promise<TCertificateTemplateV2> => {
const template = await certificateTemplateV2DAL.findById(templateId);
if (!template) {
throw new NotFoundError({ message: "Certificate template not found" });
}
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: template.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
if (!internal) {
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: template.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPkiTemplateActions.Read,
ProjectPermissionSub.CertificateTemplates
);
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPkiTemplateActions.Read,
ProjectPermissionSub.CertificateTemplates
);
}
return template;
};
@@ -0,0 +1,163 @@
/* eslint-disable no-await-in-loop */
import { EventType, TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-types";
import { getConfig } from "@app/lib/config/env";
import { logger } from "@app/lib/logger";
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
import { ActorType } from "../auth/auth-type";
import { TCertificateDALFactory } from "../certificate/certificate-dal";
import { CERTIFICATE_RENEWAL_CONFIG } from "../certificate-common/certificate-constants";
import { TCertificateV3ServiceFactory } from "./certificate-v3-service";
type TCertificateV3QueueServiceFactoryDep = {
queueService: TQueueServiceFactory;
certificateDAL: Pick<TCertificateDALFactory, "findCertificatesEligibleForRenewal" | "updateById">;
certificateV3Service: TCertificateV3ServiceFactory;
auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">;
};
export const certificateV3QueueServiceFactory = ({
queueService,
certificateDAL,
certificateV3Service,
auditLogService
}: TCertificateV3QueueServiceFactoryDep) => {
const appCfg = getConfig();
const init = async () => {
if (appCfg.isSecondaryInstance) {
return;
}
await queueService.stopRepeatableJob(
QueueName.CertificateV3AutoRenewal,
QueueJobs.CertificateV3DailyAutoRenewal,
{ pattern: CERTIFICATE_RENEWAL_CONFIG.DAILY_CRON_SCHEDULE, utc: true },
QueueName.CertificateV3AutoRenewal
);
await queueService.startPg<QueueName.CertificateV3AutoRenewal>(
QueueJobs.CertificateV3DailyAutoRenewal,
async () => {
try {
logger.info(`${QueueJobs.CertificateV3DailyAutoRenewal}: queue task started`);
const { QUEUE_BATCH_SIZE } = CERTIFICATE_RENEWAL_CONFIG;
let offset = 0;
let hasMore = true;
let totalCertificatesFound = 0;
let totalCertificatesRenewed = 0;
while (hasMore) {
const certificates = await certificateDAL.findCertificatesEligibleForRenewal({
limit: QUEUE_BATCH_SIZE,
offset
});
if (certificates.length === 0) {
hasMore = false;
break;
}
totalCertificatesFound += certificates.length;
logger.info(
`${QueueJobs.CertificateV3DailyAutoRenewal}: found ${certificates.length} certificates eligible for renewal (batch ${Math.floor(offset / QUEUE_BATCH_SIZE) + 1}, total found so far: ${totalCertificatesFound})`
);
for (const certificate of certificates) {
try {
if (certificate.renewBeforeDays) {
const { MIN_RENEW_BEFORE_DAYS, MAX_RENEW_BEFORE_DAYS } = CERTIFICATE_RENEWAL_CONFIG;
if (
certificate.renewBeforeDays < MIN_RENEW_BEFORE_DAYS ||
certificate.renewBeforeDays > MAX_RENEW_BEFORE_DAYS
) {
// eslint-disable-next-line no-continue
continue;
}
}
await certificateV3Service.renewCertificate({
actor: ActorType.PLATFORM,
actorId: "",
actorAuthMethod: null,
actorOrgId: "",
certificateId: certificate.id,
internal: true
});
totalCertificatesRenewed += 1;
await auditLogService.createAuditLog({
projectId: certificate.projectId,
actor: {
type: ActorType.PLATFORM,
metadata: {}
},
event: {
type: EventType.AUTOMATED_RENEW_CERTIFICATE,
metadata: {
certificateId: certificate.id,
commonName: certificate.commonName || "",
profileId: certificate.profileId!,
renewBeforeDays: certificate.renewBeforeDays?.toString() || "",
profileName: certificate.profileName || ""
}
}
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(error, `Failed to renew certificate ${certificate.id}: ${errorMessage}`);
await auditLogService.createAuditLog({
projectId: certificate.projectId,
actor: {
type: ActorType.PLATFORM,
metadata: {}
},
event: {
type: EventType.AUTOMATED_RENEW_CERTIFICATE_FAILED,
metadata: {
certificateId: certificate.id,
commonName: certificate.commonName || "",
profileId: certificate.profileId || "",
renewBeforeDays: certificate.renewBeforeDays?.toString() || "",
profileName: certificate.profileName || "",
error: errorMessage
}
}
});
}
}
offset += QUEUE_BATCH_SIZE;
}
logger.info(
`${QueueJobs.CertificateV3DailyAutoRenewal}: queue task completed. Renewed ${totalCertificatesRenewed} certificates out of ${totalCertificatesFound}`
);
} catch (error) {
logger.error(error, `${QueueJobs.CertificateV3DailyAutoRenewal}: certificate renewal failed`);
throw error;
}
},
{
batchSize: 1,
workerCount: 1,
pollingIntervalSeconds: 60
}
);
await queueService.schedulePg(
QueueJobs.CertificateV3DailyAutoRenewal,
CERTIFICATE_RENEWAL_CONFIG.DAILY_CRON_SCHEDULE,
undefined,
{ tz: "UTC" }
);
};
return {
init
};
};
export type TCertificateV3QueueServiceFactory = ReturnType<typeof certificateV3QueueServiceFactory>;
@@ -7,10 +7,12 @@ import { ForbiddenError } from "@casl/ability";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
import { ACMESANType, CertificateOrderStatus } from "@app/services/certificate/certificate-types";
import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal";
import { ACMESANType, CertificateOrderStatus, 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";
import {
CertExtendedKeyUsageType,
@@ -23,14 +25,32 @@ import { EnrollmentType } from "@app/services/certificate-profile/certificate-pr
import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service";
import { ActorType, AuthMethod } from "../auth/auth-type";
import {
extractAlgorithmsFromCSR,
extractCertificateRequestFromCSR
} from "../certificate-common/certificate-csr-utils";
import { certificateV3ServiceFactory, TCertificateV3ServiceFactory } from "./certificate-v3-service";
vi.mock("../certificate-common/certificate-csr-utils", () => ({
extractCertificateRequestFromCSR: vi.fn(),
extractAlgorithmsFromCSR: vi.fn()
}));
describe("CertificateV3Service", () => {
let service: TCertificateV3ServiceFactory;
const mockCertificateDAL: Pick<TCertificateDALFactory, "findOne" | "updateById"> = {
const mockCertificateDAL: Pick<TCertificateDALFactory, "findOne" | "findById" | "updateById" | "transaction"> = {
findOne: vi.fn(),
updateById: vi.fn()
findById: vi.fn(),
updateById: vi.fn(),
transaction: vi.fn().mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
const mockTx = {};
return callback(mockTx);
})
};
const mockCertificateSecretDAL: Pick<TCertificateSecretDALFactory, "findOne"> = {
findOne: vi.fn()
};
const mockCertificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa"> = {
@@ -76,7 +96,7 @@ describe("CertificateV3Service", () => {
beforeEach(() => {
// Reset all mocks before each test
vi.clearAllMocks();
vi.resetAllMocks();
// Mock ForbiddenError.from static method
vi.spyOn(ForbiddenError, "from").mockReturnValue({
@@ -95,8 +115,20 @@ describe("CertificateV3Service", () => {
}
});
vi.mocked(extractCertificateRequestFromCSR).mockReturnValue({
commonName: "test.example.com",
keyUsages: [CertKeyUsageType.DIGITAL_SIGNATURE],
extendedKeyUsages: [CertExtendedKeyUsageType.SERVER_AUTH]
});
vi.mocked(extractAlgorithmsFromCSR).mockReturnValue({
keyAlgorithm: "RSA_2048" as any,
signatureAlgorithm: "RSA-SHA256" as any
});
service = certificateV3ServiceFactory({
certificateDAL: mockCertificateDAL,
certificateSecretDAL: mockCertificateSecretDAL,
certificateAuthorityDAL: mockCertificateAuthorityDAL,
certificateProfileDAL: mockCertificateProfileDAL,
certificateTemplateV2Service: mockCertificateTemplateV2Service,
@@ -641,6 +673,11 @@ describe("CertificateV3Service", () => {
vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile);
vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA);
vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate);
vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({
isValid: true,
errors: [],
warnings: []
});
vi.mocked(mockInternalCaService.signCertFromCa).mockResolvedValue(mockSignResult as any);
vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(mockCertRecord);
vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCertRecord);
@@ -1460,4 +1497,714 @@ describe("CertificateV3Service", () => {
).resolves.toBeDefined();
});
});
describe("renewCertificate", () => {
const mockOriginalCert = {
id: "cert-123",
status: CertStatus.ACTIVE,
serialNumber: "123456",
friendlyName: "Test Certificate",
commonName: "test.example.com",
notBefore: new Date("2024-01-01"),
notAfter: new Date("2024-02-01"), // 31 days
revokedAt: null,
renewedByCertificateId: null,
profileId: "profile-123",
renewBeforeDays: 7,
caId: "ca-123",
pkiSubscriberId: null,
keyUsages: ["digital_signature", "key_agreement"],
extendedKeyUsages: ["server_auth"],
altNames: "test.example.com,api.example.com",
projectId: "project-123",
createdAt: new Date(),
updatedAt: new Date(),
certificateTemplateId: "template-123",
revocationReason: null,
caCertId: null,
renewedFromCertificateId: null,
renewalError: null,
keyAlgorithm: "RSA_2048",
signatureAlgorithm: "RSA-SHA256"
};
const mockProfile = {
id: "profile-123",
projectId: "project-123",
enrollmentType: EnrollmentType.API,
caId: "ca-123",
certificateTemplateId: "template-123",
apiConfig: {
id: "api-config-123",
autoRenew: true,
renewBeforeDays: 14
},
createdAt: new Date(),
updatedAt: new Date(),
slug: "test-profile",
description: "Test profile"
};
const mockCA = {
id: "ca-123",
projectId: "project-123",
status: CaStatus.ACTIVE,
createdAt: new Date(),
updatedAt: new Date(),
enableDirectIssuance: true,
name: "Test CA",
requireTemplateForIssuance: false,
externalCa: undefined,
parentCaId: null,
type: "ROOT",
friendlyName: "Test CA",
organization: "Test Org",
ou: "Test OU",
country: "US",
province: "CA",
locality: "SF",
commonName: "Test CA",
keyAlgorithm: "RSA_2048",
notAfter: "2025-01-01T00:00:00Z",
notBefore: "2024-01-01T00:00:00Z",
maxPathLength: -1,
activeCaCertId: "cert-123",
dn: "CN=Test CA,O=Test Org,OU=Test OU,C=US",
serialNumber: "123456789",
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",
keyAlgorithm: "RSA_2048",
notAfter: "2025-01-01T00:00:00Z",
notBefore: "2024-01-01T00:00:00Z",
maxPathLength: -1,
activeCaCertId: "cert-123",
dn: "CN=Test CA,O=Test Org,OU=Test OU,C=US",
serialNumber: "123456789"
}
};
const mockTemplate = {
id: "template-123",
projectId: "project-123",
name: "Test Template",
createdAt: new Date(),
updatedAt: new Date(),
algorithms: {
signature: ["SHA256-RSA", "SHA384-RSA"],
keyType: ["RSA_2048", "RSA_4096"]
}
};
beforeEach(() => {
// Mock current date to be within renewal window
vi.useFakeTimers();
vi.setSystemTime(new Date("2024-01-26")); // 6 days before cert expires, within renewal window
});
afterEach(() => {
vi.useRealTimers();
});
it("should successfully renew eligible certificate", async () => {
// Mock the initial findById call
vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockOriginalCert);
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);
vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate);
vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({
isValid: true,
errors: [],
warnings: []
});
vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue({
certificate: "renewed-cert",
certificateChain: "renewed-chain",
issuingCaCertificate: "issuing-ca",
privateKey: "private-key",
serialNumber: "789012",
ca: mockCA
});
const newCert = { ...mockOriginalCert, id: "cert-456", serialNumber: "789012" };
vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(newCert);
vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(newCert);
// Mock the transaction to return the expected structure
vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
const mockTx = {};
const result = await callback(mockTx);
return result;
});
const result = await service.renewCertificate({
certificateId: "cert-123",
...mockActor
});
expect(result).toHaveProperty("certificate", "renewed-cert");
expect(result).toHaveProperty("certificateId", "cert-456");
expect(mockCertificateDAL.updateById).toHaveBeenCalledWith(
"cert-456",
{
profileId: "profile-123",
renewBeforeDays: 14,
renewedFromCertificateId: "cert-123"
},
{}
);
expect(mockCertificateDAL.updateById).toHaveBeenCalledWith(
"cert-123",
{
renewedByCertificateId: "cert-456",
renewalError: null
},
{}
);
});
it("should validate certificate against current template during renewal", async () => {
vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockOriginalCert);
vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile);
vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA);
vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate);
vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({
isValid: false,
errors: ["Subject alternative name not allowed"],
warnings: []
});
vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue({ id: "secret-123", certId: "cert-123" } as any);
// Mock updateById to handle the renewal error logging
vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockOriginalCert);
// Set up transaction mock to properly handle errors
vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
const mockTx = {};
return callback(mockTx);
});
await expect(
service.renewCertificate({
certificateId: "cert-123",
...mockActor
})
).rejects.toThrow(BadRequestError);
await expect(
service.renewCertificate({
certificateId: "cert-123",
...mockActor
})
).rejects.toThrow("Certificate renewal failed. Errors: Subject alternative name not allowed");
// Should store template validation error
expect(mockCertificateDAL.updateById).toHaveBeenCalledWith("cert-123", {
renewalError: "Template validation failed: Subject alternative name not allowed"
});
});
it("should reject renewal if certificate is not from a profile", async () => {
const certWithoutProfile = { ...mockOriginalCert, profileId: null };
vi.mocked(mockCertificateDAL.findById).mockResolvedValue(certWithoutProfile);
// Set up transaction mock to properly handle errors
vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
const mockTx = {};
return callback(mockTx);
});
await expect(
service.renewCertificate({
certificateId: "cert-123",
...mockActor
})
).rejects.toThrow(ForbiddenRequestError);
await expect(
service.renewCertificate({
certificateId: "cert-123",
...mockActor
})
).rejects.toThrow("Only certificates issued from a profile can be renewed");
});
it("should reject renewal if certificate was issued from CSR (external private key)", async () => {
vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockOriginalCert);
vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile);
vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue(null as any);
vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
const mockTx = {};
return callback(mockTx);
});
await expect(
service.renewCertificate({
certificateId: "cert-123",
...mockActor
})
).rejects.toThrow(ForbiddenRequestError);
await expect(
service.renewCertificate({
certificateId: "cert-123",
...mockActor
})
).rejects.toThrow("certificates issued from CSR (external private key) cannot be renewed");
});
it("should reject renewal if certificate is already renewed", async () => {
const alreadyRenewedCert = { ...mockOriginalCert, renewedByCertificateId: "cert-456" };
vi.mocked(mockCertificateDAL.findById).mockResolvedValue(alreadyRenewedCert);
vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile);
vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA);
vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue({ id: "secret-123", certId: "cert-123" } as any);
// Mock updateById to handle the renewal error logging
vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(alreadyRenewedCert);
// Set up transaction mock to properly handle errors
vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
const mockTx = {};
return callback(mockTx);
});
await expect(
service.renewCertificate({
certificateId: "cert-123",
...mockActor
})
).rejects.toThrow(BadRequestError);
await expect(
service.renewCertificate({
certificateId: "cert-123",
...mockActor
})
).rejects.toThrow("Certificate has already been renewed");
});
it("should reject renewal if certificate is expired", async () => {
const expiredCert = {
...mockOriginalCert,
notAfter: new Date("2024-01-20") // Expired 6 days ago
};
vi.mocked(mockCertificateDAL.findById).mockResolvedValue(expiredCert);
vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile);
vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA);
vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue({ id: "secret-123", certId: "cert-123" } as any);
// Mock updateById to handle the renewal error logging
vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(expiredCert);
// Set up transaction mock to properly handle errors
vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
const mockTx = {};
return callback(mockTx);
});
await expect(
service.renewCertificate({
certificateId: "cert-123",
...mockActor
})
).rejects.toThrow(BadRequestError);
await expect(
service.renewCertificate({
certificateId: "cert-123",
...mockActor
})
).rejects.toThrow("Certificate is already expired");
});
it("should reject renewal if certificate is revoked", async () => {
const revokedCert = {
...mockOriginalCert,
revokedAt: new Date("2024-01-15")
};
vi.mocked(mockCertificateDAL.findById).mockResolvedValue(revokedCert);
vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile);
vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA);
vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue({ id: "secret-123", certId: "cert-123" } as any);
// Mock updateById to handle the renewal error logging
vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(revokedCert);
// Set up transaction mock to properly handle errors
vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
const mockTx = {};
return callback(mockTx);
});
await expect(
service.renewCertificate({
certificateId: "cert-123",
...mockActor
})
).rejects.toThrow(BadRequestError);
await expect(
service.renewCertificate({
certificateId: "cert-123",
...mockActor
})
).rejects.toThrow("Certificate is revoked and cannot be renewed");
});
it("should reject renewal if CA is inactive", async () => {
const inactiveCA = { ...mockCA, status: CaStatus.DISABLED };
vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockOriginalCert);
vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile);
vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(inactiveCA);
vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue({ id: "secret-123", certId: "cert-123" } as any);
// Mock updateById to handle the renewal error logging
vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockOriginalCert);
// Set up transaction mock to properly handle errors
vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
const mockTx = {};
return callback(mockTx);
});
await expect(
service.renewCertificate({
certificateId: "cert-123",
...mockActor
})
).rejects.toThrow(BadRequestError);
await expect(
service.renewCertificate({
certificateId: "cert-123",
...mockActor
})
).rejects.toThrow("Certificate is not eligible for renewal: Certificate Authority is disabled, must be active");
});
it("should reject renewal if new certificate would outlive CA", async () => {
const shortLivedCA = {
...mockCA,
internalCa: {
...mockCA.internalCa,
notAfter: "2024-01-28T00:00:00Z"
}
};
vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockOriginalCert);
vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile);
vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(shortLivedCA);
vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue({ id: "secret-123", certId: "cert-123" } as any);
// Mock updateById to handle the renewal error logging
vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockOriginalCert);
// Set up transaction mock to properly handle errors
vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
const mockTx = {};
return callback(mockTx);
});
await expect(
service.renewCertificate({
certificateId: "cert-123",
...mockActor
})
).rejects.toThrow(BadRequestError);
await expect(
service.renewCertificate({
certificateId: "cert-123",
...mockActor
})
).rejects.toThrow(/New certificate would expire \(.+\) after its issuing CA \(.+\)/);
});
it("should allow manual renewal outside window (manual renewal always bypasses window)", async () => {
vi.setSystemTime(new Date("2024-01-15")); // 17 days before expiry, outside 7-day window
vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockOriginalCert);
vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile);
vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA);
vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue({ id: "secret-123", certId: "cert-123" } as any);
vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate);
vi.mocked(mockCertificateTemplateV2Service.validateCertificateRequest).mockResolvedValue({
isValid: true,
errors: [],
warnings: []
});
vi.mocked(mockInternalCaService.issueCertFromCa).mockResolvedValue({
certificate: "renewed-cert",
certificateChain: "renewed-chain",
issuingCaCertificate: "issuing-ca",
privateKey: "private-key",
serialNumber: "789012",
ca: mockCA
});
const newCert = { ...mockOriginalCert, id: "cert-456", serialNumber: "789012" };
vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(newCert);
vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(newCert);
// Set up transaction mock to properly handle the renewal process
vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise<unknown>) => {
const mockTx = {};
return callback(mockTx);
});
const result = await service.renewCertificate({
certificateId: "cert-123",
...mockActor
});
expect(result).toHaveProperty("certificate", "renewed-cert");
});
});
describe("updateRenewalConfig", () => {
it("should update renewal configuration successfully", async () => {
const mockCert = {
id: "cert-123",
profileId: "profile-123",
renewedByCertificateId: null,
notBefore: new Date("2026-01-01"),
notAfter: new Date("2026-02-01"),
projectId: "project-123",
status: CertStatus.ACTIVE,
revokedAt: null,
commonName: ""
};
const mockProfile = {
id: "profile-123",
enrollmentType: EnrollmentType.API,
projectId: "project-123"
};
vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockCert as any);
vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile as any);
vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue({ id: "secret-123", certId: "cert-123" } as any);
vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCert as any);
const result = await service.updateRenewalConfig({
actor: ActorType.USER,
actorId: "user-123",
actorAuthMethod: AuthMethod.EMAIL,
actorOrgId: "org-123",
certificateId: "cert-123",
renewBeforeDays: 7
});
expect(result).toEqual({
projectId: "project-123",
renewBeforeDays: 7,
commonName: ""
});
expect(mockCertificateDAL.updateById).toHaveBeenCalledWith("cert-123", {
renewBeforeDays: 7
});
});
it("should reject update if certificate is not from profile", async () => {
const mockCert = {
id: "cert-123",
profileId: null,
renewedByCertificateId: null,
projectId: "project-123"
};
vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockCert as any);
await expect(
service.updateRenewalConfig({
actor: ActorType.USER,
actorId: "user-123",
actorAuthMethod: AuthMethod.EMAIL,
actorOrgId: "org-123",
certificateId: "cert-123",
renewBeforeDays: 7
})
).rejects.toThrow(BadRequestError);
await expect(
service.updateRenewalConfig({
actor: ActorType.USER,
actorId: "user-123",
actorAuthMethod: AuthMethod.EMAIL,
actorOrgId: "org-123",
certificateId: "cert-123",
renewBeforeDays: 7
})
).rejects.toThrow("Certificate is not eligible for auto-renewal: certificate was not issued from a profile");
});
it("should reject update if certificate is already renewed", async () => {
const mockCert = {
id: "cert-123",
profileId: "profile-123",
renewedByCertificateId: "cert-456",
projectId: "project-123",
status: CertStatus.ACTIVE,
revokedAt: null,
notBefore: new Date("2026-01-01"),
notAfter: new Date("2026-02-01")
};
const mockProfile = {
id: "profile-123",
enrollmentType: EnrollmentType.API,
projectId: "project-123"
};
vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockCert as any);
vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile as any);
vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue({ id: "secret-123", certId: "cert-123" } as any);
await expect(
service.updateRenewalConfig({
actor: ActorType.USER,
actorId: "user-123",
actorAuthMethod: AuthMethod.EMAIL,
actorOrgId: "org-123",
certificateId: "cert-123",
renewBeforeDays: 7
})
).rejects.toThrow(BadRequestError);
await expect(
service.updateRenewalConfig({
actor: ActorType.USER,
actorId: "user-123",
actorAuthMethod: AuthMethod.EMAIL,
actorOrgId: "org-123",
certificateId: "cert-123",
renewBeforeDays: 7
})
).rejects.toThrow("Certificate is not eligible for auto-renewal: certificate has already been renewed");
});
it("should reject update if renewBeforeDays >= certificate TTL", async () => {
const mockCert = {
id: "cert-123",
profileId: "profile-123",
renewedByCertificateId: null,
notBefore: new Date("2026-01-01"),
notAfter: new Date("2026-01-08"),
projectId: "project-123",
status: CertStatus.ACTIVE,
revokedAt: null
};
const mockProfile = {
id: "profile-123",
enrollmentType: EnrollmentType.API,
projectId: "project-123"
};
vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockCert as any);
vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile as any);
vi.mocked(mockCertificateSecretDAL.findOne).mockResolvedValue({ id: "secret-123", certId: "cert-123" } as any);
await expect(
service.updateRenewalConfig({
actor: ActorType.USER,
actorId: "user-123",
actorAuthMethod: AuthMethod.EMAIL,
actorOrgId: "org-123",
certificateId: "cert-123",
renewBeforeDays: 8 // Greater than 7-day TTL
})
).rejects.toThrow(BadRequestError);
await expect(
service.updateRenewalConfig({
actor: ActorType.USER,
actorId: "user-123",
actorAuthMethod: AuthMethod.EMAIL,
actorOrgId: "org-123",
certificateId: "cert-123",
renewBeforeDays: 8
})
).rejects.toThrow("Invalid renewal configuration: renewal threshold exceeds certificate validity period");
});
});
describe("disableRenewalConfig", () => {
it("should disable renewal configuration successfully", async () => {
const mockCert = {
id: "cert-123",
profileId: "profile-123",
projectId: "project-123",
commonName: ""
};
const mockProfile = {
id: "profile-123",
enrollmentType: EnrollmentType.API,
projectId: "project-123"
};
vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockCert as any);
vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile as any);
vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCert as any);
const result = await service.disableRenewalConfig({
actor: ActorType.USER,
actorId: "user-123",
actorAuthMethod: AuthMethod.EMAIL,
actorOrgId: "org-123",
certificateId: "cert-123"
});
expect(result).toEqual({
projectId: "project-123",
commonName: ""
});
expect(mockCertificateDAL.updateById).toHaveBeenCalledWith("cert-123", {
renewBeforeDays: null
});
});
it("should reject disable if certificate is not from profile", async () => {
const mockCert = {
id: "cert-123",
profileId: null,
projectId: "project-123"
};
vi.mocked(mockCertificateDAL.findById).mockResolvedValue(mockCert as any);
await expect(
service.disableRenewalConfig({
actor: ActorType.USER,
actorId: "user-123",
actorAuthMethod: AuthMethod.EMAIL,
actorOrgId: "org-123",
certificateId: "cert-123"
})
).rejects.toThrow(BadRequestError);
await expect(
service.disableRenewalConfig({
actor: ActorType.USER,
actorId: "user-123",
actorAuthMethod: AuthMethod.EMAIL,
actorOrgId: "org-123",
certificateId: "cert-123"
})
).rejects.toThrow("Certificate is not eligible for auto-renewal: certificate was not issued from a profile");
});
});
});
@@ -1,36 +1,49 @@
import { ForbiddenError } from "@casl/ability";
import { randomUUID } from "crypto";
import RE2 from "re2";
import { ActionProjectType } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import {
ProjectPermissionCertificateActions,
ProjectPermissionCertificateProfileActions,
ProjectPermissionSub
} from "@app/ee/services/permission/project-permission";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type";
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal";
import {
CertExtendedKeyUsage,
CertificateOrderStatus,
CertKeyAlgorithm,
CertSignatureAlgorithm
CertKeyType,
CertKeyUsage,
CertSignatureAlgorithm,
CertStatus
} from "@app/services/certificate/certificate-types";
import {
TCertificateAuthorityDALFactory,
TCertificateAuthorityWithAssociatedCa
} from "@app/services/certificate-authority/certificate-authority-dal";
import { CaType } from "@app/services/certificate-authority/certificate-authority-enums";
import { CaStatus, CaType } from "@app/services/certificate-authority/certificate-authority-enums";
import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service";
import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal";
import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types";
import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service";
import { CertSubjectAlternativeNameType } from "../certificate-common/certificate-constants";
import {
extractAlgorithmsFromCSR,
extractCertificateRequestFromCSR
} from "../certificate-common/certificate-csr-utils";
import {
bufferToString,
buildCertificateSubjectFromTemplate,
buildSubjectAlternativeNamesFromTemplate,
convertExtendedKeyUsageArrayFromLegacy,
convertExtendedKeyUsageArrayToLegacy,
convertKeyUsageArrayFromLegacy,
convertKeyUsageArrayToLegacy,
mapEnumsForValidation,
normalizeDateForApi
@@ -38,13 +51,19 @@ import {
import {
TCertificateFromProfileResponse,
TCertificateOrderResponse,
TDisableRenewalConfigDTO,
TDisableRenewalResponse,
TIssueCertificateFromProfileDTO,
TOrderCertificateFromProfileDTO,
TSignCertificateFromProfileDTO
TRenewalConfigResponse,
TRenewCertificateDTO,
TSignCertificateFromProfileDTO,
TUpdateRenewalConfigDTO
} from "./certificate-v3-types";
type TCertificateV3ServiceFactoryDep = {
certificateDAL: Pick<TCertificateDALFactory, "findOne" | "updateById">;
certificateDAL: Pick<TCertificateDALFactory, "findOne" | "findById" | "updateById" | "transaction">;
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "findOne">;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa">;
certificateProfileDAL: Pick<TCertificateProfileDALFactory, "findByIdWithConfigs">;
certificateTemplateV2Service: Pick<
@@ -95,6 +114,77 @@ const validateProfileAndPermissions = async (
return profile;
};
const validateRenewalEligibility = (
certificate: {
id: string;
status: string;
notBefore: Date;
notAfter: Date;
revokedAt?: Date | null;
renewedByCertificateId?: string | null;
profileId?: string | null;
caId?: string | null;
pkiSubscriberId?: string | null;
},
ca: TCertificateAuthorityWithAssociatedCa
) => {
const errors: string[] = [];
if (certificate.status !== CertStatus.ACTIVE) {
errors.push(`Certificate status is ${certificate.status}, must be ${CertStatus.ACTIVE}`);
}
const now = new Date();
if (certificate.notAfter <= now) {
errors.push("Certificate is already expired");
}
if (certificate.revokedAt) {
errors.push("Certificate is revoked and cannot be renewed");
}
const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL;
const isInternalCa = caType === CaType.INTERNAL;
const isConnectedExternalCa = caType === CaType.ACME || caType === CaType.AZURE_AD_CS;
const isImportedCertificate = certificate.pkiSubscriberId != null && !certificate.profileId;
if (!isInternalCa && !isConnectedExternalCa) {
errors.push(`CA type ${String(caType)} does not support renewal`);
}
if (isImportedCertificate) {
errors.push("Externally imported certificates cannot be renewed");
}
if (ca.status !== CaStatus.ACTIVE) {
errors.push(`Certificate Authority is ${ca.status}, must be ${CaStatus.ACTIVE}`);
}
if (certificate.renewedByCertificateId) {
errors.push("Certificate has already been renewed");
}
const certificateTtlInDays = Math.ceil(
(certificate.notAfter.getTime() - certificate.notBefore.getTime()) / (24 * 60 * 60 * 1000)
);
if (ca.internalCa?.notAfter) {
const caExpiryDate = new Date(ca.internalCa.notAfter);
const proposedCertExpiryDate = new Date(now.getTime() + certificateTtlInDays * 24 * 60 * 60 * 1000);
if (proposedCertExpiryDate > caExpiryDate) {
errors.push(
`New certificate would expire (${proposedCertExpiryDate.toISOString()}) after its issuing CA (${caExpiryDate.toISOString()})`
);
}
}
return {
isEligible: errors.length === 0,
errors
};
};
const validateCaSupport = (ca: TCertificateAuthorityWithAssociatedCa, operation: string) => {
const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL;
if (caType !== CaType.INTERNAL) {
@@ -129,11 +219,11 @@ const validateAlgorithmCompatibility = (
const keyType = parts[parts.length - 1];
if (caKeyAlgorithm.startsWith("RSA")) {
return keyType === "RSA";
return keyType === CertKeyType.RSA;
}
if (caKeyAlgorithm.startsWith("EC")) {
return keyType === "ECDSA";
return keyType === CertKeyType.ECDSA;
}
return false;
@@ -155,8 +245,85 @@ const extractCertificateFromBuffer = (certData: Buffer | { rawData: Buffer } | s
return bufferToString(certData as unknown as Buffer);
};
const parseKeyUsages = (keyUsages: unknown): CertKeyUsage[] => {
if (!keyUsages) return [];
if (Array.isArray(keyUsages)) return keyUsages as CertKeyUsage[];
return (keyUsages as string).split(",").map((usage) => usage.trim() as CertKeyUsage);
};
const parseExtendedKeyUsages = (extendedKeyUsages: unknown): CertExtendedKeyUsage[] => {
if (!extendedKeyUsages) return [];
if (Array.isArray(extendedKeyUsages)) return extendedKeyUsages as CertExtendedKeyUsage[];
return (extendedKeyUsages as string).split(",").map((usage) => usage.trim() as CertExtendedKeyUsage);
};
const isValidRenewalTiming = (renewBeforeDays: number, certificateExpiryDate: Date): boolean => {
const renewalDate = new Date(certificateExpiryDate.getTime() - renewBeforeDays * 24 * 60 * 60 * 1000);
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(0, 0, 0, 0);
return renewalDate >= tomorrow;
};
const calculateRenewalThreshold = (
profileRenewBeforeDays: number | undefined,
certificateTtlInDays: number
): number | undefined => {
if (!profileRenewBeforeDays) {
return undefined;
}
if (certificateTtlInDays > profileRenewBeforeDays) {
return profileRenewBeforeDays;
}
return Math.max(1, certificateTtlInDays - 1);
};
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 numValue = parseInt(value, 10);
switch (unit) {
case "d":
return numValue;
case "h":
return Math.ceil(numValue / 24);
case "m":
return Math.ceil(numValue / (24 * 60));
default:
throw new BadRequestError({ message: `Unsupported TTL unit: ${unit}` });
}
};
const calculateFinalRenewBeforeDays = (
profile: { apiConfig?: { autoRenew?: boolean; renewBeforeDays?: number } },
ttl: string,
certificateExpiryDate: Date
): 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 isValidRenewalTiming(renewBeforeDays, certificateExpiryDate) ? renewBeforeDays : undefined;
};
export const certificateV3ServiceFactory = ({
certificateDAL,
certificateSecretDAL,
certificateAuthorityDAL,
certificateProfileDAL,
certificateTemplateV2Service,
@@ -198,7 +365,8 @@ export const certificateV3ServiceFactory = ({
actorId,
actorAuthMethod,
actorOrgId,
templateId: profile.certificateTemplateId
templateId: profile.certificateTemplateId,
internal: true
});
if (!template) {
throw new NotFoundError({ message: "Certificate template not found for this profile" });
@@ -222,10 +390,6 @@ export const certificateV3ServiceFactory = ({
validateCaSupport(ca, "direct certificate issuance");
if (!actorAuthMethod) {
throw new BadRequestError({ message: "Authentication method is required for certificate issuance" });
}
validateAlgorithmCompatibility(ca, template);
const effectiveSignatureAlgorithm = certificateRequest.signatureAlgorithm as CertSignatureAlgorithm | undefined;
@@ -274,7 +438,16 @@ export const certificateV3ServiceFactory = ({
throw new NotFoundError({ message: "Certificate was issued but could not be found in database" });
}
await certificateDAL.updateById(cert.id, { profileId });
const finalRenewBeforeDays = calculateFinalRenewBeforeDays(
profile,
certificateRequest.validity.ttl,
new Date(cert.notAfter)
);
await certificateDAL.updateById(cert.id, {
profileId,
renewBeforeDays: finalRenewBeforeDays
});
return {
certificate: bufferToString(certificate),
@@ -284,7 +457,8 @@ export const certificateV3ServiceFactory = ({
serialNumber,
certificateId: cert.id,
projectId: profile.projectId,
profileName: profile.slug
profileName: profile.slug,
commonName: cert.commonName || ""
};
};
@@ -294,8 +468,6 @@ export const certificateV3ServiceFactory = ({
validity,
notBefore,
notAfter,
signatureAlgorithm,
keyAlgorithm,
actor,
actorId,
actorAuthMethod,
@@ -319,38 +491,40 @@ export const certificateV3ServiceFactory = ({
validateCaSupport(ca, "CSR signing");
if (!actorAuthMethod) {
throw new BadRequestError({ message: "Authentication method is required for certificate signing" });
}
const template = await certificateTemplateV2Service.getTemplateV2ById({
actor,
actorId,
actorAuthMethod,
actorOrgId,
templateId: profile.certificateTemplateId
templateId: profile.certificateTemplateId,
internal: true
});
if (!template) {
throw new NotFoundError({ message: "Certificate template not found for this profile" });
}
const certificateRequest = extractCertificateRequestFromCSR(csr);
const mappedCertificateRequest = mapEnumsForValidation(certificateRequest);
const { keyAlgorithm: extractedKeyAlgorithm, signatureAlgorithm: extractedSignatureAlgorithm } =
extractAlgorithmsFromCSR(csr);
const validationResult = await certificateTemplateV2Service.validateCertificateRequest(
profile.certificateTemplateId,
mappedCertificateRequest
);
if (!validationResult.isValid) {
throw new BadRequestError({
message: `Certificate request validation failed: ${validationResult.errors.join(", ")}`
});
}
validateAlgorithmCompatibility(ca, template);
const effectiveSignatureAlgorithm = signatureAlgorithm;
const effectiveKeyAlgorithm = keyAlgorithm;
if (template.algorithms?.keyAlgorithm && !effectiveKeyAlgorithm) {
throw new BadRequestError({
message: "Key algorithm is required by template policy but not provided in request"
});
}
if (template.algorithms?.signature && !effectiveSignatureAlgorithm) {
throw new BadRequestError({
message: "Signature algorithm is required by template policy but not provided in request"
});
}
const effectiveSignatureAlgorithm = extractedSignatureAlgorithm;
const effectiveKeyAlgorithm = extractedKeyAlgorithm;
const { certificate, certificateChain, issuingCaCertificate, serialNumber } =
await internalCaService.signCertFromCa({
@@ -371,7 +545,12 @@ export const certificateV3ServiceFactory = ({
throw new NotFoundError({ message: "Certificate was signed but could not be found in database" });
}
await certificateDAL.updateById(cert.id, { profileId });
const finalRenewBeforeDays = calculateFinalRenewBeforeDays(profile, validity.ttl, new Date(cert.notAfter));
await certificateDAL.updateById(cert.id, {
profileId,
renewBeforeDays: finalRenewBeforeDays
});
const certificateString = extractCertificateFromBuffer(certificate as unknown as Buffer);
const certificateChainString = extractCertificateFromBuffer(certificateChain as unknown as Buffer);
@@ -383,7 +562,8 @@ export const certificateV3ServiceFactory = ({
serialNumber,
certificateId: cert.id,
projectId: profile.projectId,
profileName: profile.slug
profileName: profile.slug,
commonName: cert.commonName || ""
};
};
@@ -479,9 +659,405 @@ export const certificateV3ServiceFactory = ({
});
};
const renewCertificate = async ({
certificateId,
actor,
actorId,
actorAuthMethod,
actorOrgId,
internal = false
}: TRenewCertificateDTO & { internal?: boolean }): Promise<TCertificateFromProfileResponse> => {
const renewalResult = await certificateDAL.transaction(async (tx) => {
const originalCert = await certificateDAL.findById(certificateId, tx);
if (!originalCert) {
throw new NotFoundError({ message: "Certificate not found" });
}
if (!originalCert.profileId) {
throw new ForbiddenRequestError({
message: "Only certificates issued from a profile can be renewed"
});
}
const originalSignatureAlgorithm = originalCert.signatureAlgorithm as CertSignatureAlgorithm;
const originalKeyAlgorithm = originalCert.keyAlgorithm as CertKeyAlgorithm;
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 profile = await certificateProfileDAL.findByIdWithConfigs(originalCert.profileId);
if (!profile) {
throw new NotFoundError({ message: "Certificate profile not found" });
}
if (profile.enrollmentType !== EnrollmentType.API) {
throw new ForbiddenRequestError({
message: "Certificate is not eligible for renewal: EST certificates cannot be renewed through this endpoint"
});
}
const certificateSecret = await certificateSecretDAL.findOne({ certId: originalCert.id }, tx);
if (!certificateSecret) {
throw new ForbiddenRequestError({
message:
"Certificate is not eligible for renewal: certificates issued from CSR (external private key) cannot be renewed"
});
}
if (!internal) {
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: profile.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionCertificateProfileActions.IssueCert,
ProjectPermissionSub.CertificateProfiles
);
}
const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId);
if (!ca) {
throw new NotFoundError({ message: "Certificate Authority not found" });
}
const eligibilityCheck = validateRenewalEligibility(originalCert, ca);
if (!eligibilityCheck.isEligible) {
await certificateDAL.updateById(originalCert.id, {
renewalError: `Certificate is not eligible for renewal: ${eligibilityCheck.errors.join(", ")}`
});
throw new BadRequestError({
message: `Certificate is not eligible for renewal: ${eligibilityCheck.errors.join(", ")}`
});
}
validateCaSupport(ca, "direct certificate issuance");
const template = await certificateTemplateV2Service.getTemplateV2ById({
actor,
actorId,
actorAuthMethod,
actorOrgId,
templateId: profile.certificateTemplateId,
internal
});
if (!template) {
throw new NotFoundError({ message: "Certificate template not found for this profile" });
}
const originalTtlInDays = Math.ceil(
(new Date(originalCert.notAfter).getTime() - new Date(originalCert.notBefore).getTime()) / (1000 * 60 * 60 * 24)
);
const ttl = `${originalTtlInDays}d`;
const certificateRequest = {
commonName: originalCert.commonName || undefined,
keyUsages: convertKeyUsageArrayFromLegacy(parseKeyUsages(originalCert.keyUsages)),
extendedKeyUsages: convertExtendedKeyUsageArrayFromLegacy(
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
};
})
: [],
validity: {
ttl
},
signatureAlgorithm: originalCert.signatureAlgorithm || undefined,
keyAlgorithm: originalCert.keyAlgorithm || undefined
};
const validationResult = await certificateTemplateV2Service.validateCertificateRequest(
profile.certificateTemplateId,
certificateRequest
);
if (!validationResult.isValid) {
await certificateDAL.updateById(originalCert.id, {
renewalError: `Template validation failed: ${validationResult.errors.join(", ")}`
});
throw new BadRequestError({
message: `Certificate renewal failed. Errors: ${validationResult.errors.join(", ")}`
});
}
validateAlgorithmCompatibility(ca, template);
const notBefore = new Date();
const notAfter = new Date(Date.now() + parseTtlToDays(ttl) * 24 * 60 * 60 * 1000);
const finalRenewBeforeDays = calculateFinalRenewBeforeDays(profile, ttl, notAfter);
const { certificate, certificateChain, issuingCaCertificate, serialNumber } =
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
});
const newCert = await certificateDAL.findOne({ serialNumber, caId: ca.id }, tx);
if (!newCert) {
throw new NotFoundError({ message: "Certificate was signed but could not be found in database" });
}
await certificateDAL.updateById(
newCert.id,
{
profileId: originalCert.profileId,
renewBeforeDays: finalRenewBeforeDays,
renewedFromCertificateId: originalCert.id
},
tx
);
await certificateDAL.updateById(
originalCert.id,
{
renewedByCertificateId: newCert.id,
renewalError: null
},
tx
);
return {
certificate,
certificateChain,
issuingCaCertificate,
serialNumber,
newCert,
originalCert,
profile
};
});
return {
certificate: renewalResult.certificate,
issuingCaCertificate: renewalResult.issuingCaCertificate,
certificateChain: renewalResult.certificateChain,
serialNumber: renewalResult.serialNumber,
certificateId: renewalResult.newCert.id,
projectId: renewalResult.profile.projectId,
profileName: renewalResult.profile.slug,
commonName: renewalResult.originalCert.commonName || ""
};
};
const updateRenewalConfig = async ({
certificateId,
renewBeforeDays,
actor,
actorId,
actorAuthMethod,
actorOrgId
}: TUpdateRenewalConfigDTO): Promise<TRenewalConfigResponse> => {
const certificate = await certificateDAL.findById(certificateId);
if (!certificate) {
throw new NotFoundError({ message: "Certificate not found" });
}
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: certificate.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionCertificateActions.Edit,
ProjectPermissionSub.Certificates
);
if (!certificate.profileId) {
throw new BadRequestError({
message: "Certificate is not eligible for auto-renewal: certificate was not issued from a profile"
});
}
const profile = await certificateProfileDAL.findByIdWithConfigs(certificate.profileId);
if (!profile) {
throw new NotFoundError({ message: "Certificate profile not found" });
}
if (profile.enrollmentType !== EnrollmentType.API) {
throw new ForbiddenRequestError({
message: "Certificate is not eligible for auto-renewal: EST certificates cannot be auto-renewed"
});
}
const certificateSecret = await certificateSecretDAL.findOne({ certId: certificate.id });
if (!certificateSecret) {
throw new ForbiddenRequestError({
message:
"Certificate is not eligible for auto-renewal: certificates issued from CSR (external private key) cannot be auto-renewed"
});
}
if (certificate.status !== CertStatus.ACTIVE) {
throw new BadRequestError({
message: `Certificate is not eligible for auto-renewal: certificate status is ${certificate.status}, must be active`
});
}
const now = new Date();
if (certificate.notAfter <= now) {
throw new BadRequestError({
message: "Certificate is not eligible for auto-renewal: certificate has expired"
});
}
if (certificate.revokedAt) {
throw new BadRequestError({
message: "Certificate is not eligible for auto-renewal: certificate has been revoked"
});
}
if (certificate.renewedByCertificateId) {
throw new BadRequestError({
message: "Certificate is not eligible for auto-renewal: certificate has already been renewed"
});
}
const certificateTtlInDays = Math.ceil(
(new Date(certificate.notAfter).getTime() - new Date(certificate.notBefore).getTime()) / (24 * 60 * 60 * 1000)
);
if (renewBeforeDays >= certificateTtlInDays) {
throw new BadRequestError({
message: "Invalid renewal configuration: renewal threshold exceeds certificate validity period"
});
}
if (!isValidRenewalTiming(renewBeforeDays, new Date(certificate.notAfter))) {
throw new BadRequestError({
message: "Invalid renewal configuration: renewal would be triggered immediately or in the past"
});
}
await certificateDAL.updateById(certificateId, {
renewBeforeDays
});
return {
projectId: certificate.projectId,
renewBeforeDays,
commonName: certificate.commonName || ""
};
};
const disableRenewalConfig = async ({
certificateId,
actor,
actorId,
actorAuthMethod,
actorOrgId
}: TDisableRenewalConfigDTO): Promise<TDisableRenewalResponse> => {
const certificate = await certificateDAL.findById(certificateId);
if (!certificate) {
throw new NotFoundError({ message: "Certificate not found" });
}
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId: certificate.projectId,
actorAuthMethod,
actorOrgId,
actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionCertificateActions.Edit,
ProjectPermissionSub.Certificates
);
if (!certificate.profileId) {
throw new BadRequestError({
message: "Certificate is not eligible for auto-renewal: certificate was not issued from a profile"
});
}
const profile = await certificateProfileDAL.findByIdWithConfigs(certificate.profileId);
if (!profile) {
throw new NotFoundError({ message: "Certificate profile not found" });
}
if (profile.enrollmentType !== EnrollmentType.API) {
throw new ForbiddenRequestError({
message: "Certificate is not eligible for auto-renewal: EST certificates cannot be auto-renewed"
});
}
await certificateDAL.updateById(certificateId, {
renewBeforeDays: null
});
return {
projectId: certificate.projectId,
commonName: certificate.commonName || ""
};
};
return {
issueCertificateFromProfile,
signCertificateFromProfile,
orderCertificateFromProfile
orderCertificateFromProfile,
renewCertificate,
updateRenewalConfig,
disableRenewalConfig
};
};
@@ -35,8 +35,6 @@ export type TSignCertificateFromProfileDTO = {
};
notBefore?: Date;
notAfter?: Date;
signatureAlgorithm?: string;
keyAlgorithm?: string;
} & Omit<TProjectPermission, "projectId">;
export type TOrderCertificateFromProfileDTO = {
@@ -68,6 +66,7 @@ export type TCertificateFromProfileResponse = {
certificateId: string;
projectId: string;
profileName: string;
commonName: string;
};
export type TCertificateOrderResponse = {
@@ -97,3 +96,27 @@ export type TCertificateOrderResponse = {
projectId: string;
profileName: string;
};
export type TRenewCertificateDTO = {
certificateId: string;
} & Omit<TProjectPermission, "projectId">;
export type TUpdateRenewalConfigDTO = {
certificateId: string;
renewBeforeDays: number;
} & Omit<TProjectPermission, "projectId">;
export type TDisableRenewalConfigDTO = {
certificateId: string;
} & Omit<TProjectPermission, "projectId">;
export type TRenewalConfigResponse = {
projectId: string;
renewBeforeDays: number;
commonName: string;
};
export type TDisableRenewalResponse = {
projectId: string;
commonName: string;
};
@@ -1,7 +1,7 @@
import { TDbClient } from "@app/db";
import { TableName, TCertificates } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors";
import { ormify } from "@app/lib/knex";
import { ormify, selectAllTableCols } from "@app/lib/knex";
import { CertStatus } from "./certificate-types";
@@ -114,12 +114,94 @@ export const certificateDALFactory = (db: TDbClient) => {
}
};
const findCertificatesEligibleForRenewal = async ({
limit,
offset
}: {
limit: number;
offset: number;
}): Promise<(TCertificates & { profileName?: string })[]> => {
try {
const now = new Date();
const endOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999);
const certs = (await db
.replicaNode()(TableName.Certificate)
.select(selectAllTableCols(TableName.Certificate))
.select(db.ref("slug").withSchema(TableName.PkiCertificateProfile).as("profileName"))
.leftJoin(
TableName.PkiCertificateProfile,
`${TableName.Certificate}.profileId`,
`${TableName.PkiCertificateProfile}.id`
)
.innerJoin(TableName.CertificateSecret, `${TableName.Certificate}.id`, `${TableName.CertificateSecret}.certId`)
.where(`${TableName.Certificate}.status`, CertStatus.ACTIVE)
.whereNull(`${TableName.Certificate}.renewedByCertificateId`)
.whereNull(`${TableName.Certificate}.renewalError`)
.whereNull(`${TableName.Certificate}.revokedAt`)
.whereNotNull(`${TableName.Certificate}.profileId`)
.whereNotNull(`${TableName.Certificate}.notAfter`)
.where(`${TableName.Certificate}.notAfter`, ">", now)
.whereNotNull(`${TableName.Certificate}.renewBeforeDays`)
.where(`${TableName.Certificate}.renewBeforeDays`, ">", 0)
.whereRaw(
`"${TableName.Certificate}"."notAfter" - INTERVAL '1 day' * "${TableName.Certificate}"."renewBeforeDays" <= ?`,
[endOfDay]
)
.limit(limit)
.offset(offset)
.orderBy(`${TableName.Certificate}.notAfter`, "asc")) as TCertificates[];
return certs;
} catch (error) {
throw new DatabaseError({ error, name: "Find certificates eligible for renewal" });
}
};
const findWithPrivateKeyInfo = async (
filter: Partial<TCertificates>,
options?: { offset?: number; limit?: number; sort?: [string, "asc" | "desc"][] }
): Promise<(TCertificates & { hasPrivateKey: boolean })[]> => {
try {
let query = db
.replicaNode()(TableName.Certificate)
.leftJoin(TableName.CertificateSecret, `${TableName.Certificate}.id`, `${TableName.CertificateSecret}.certId`)
.select(selectAllTableCols(TableName.Certificate))
.select(db.ref(`${TableName.CertificateSecret}.certId`).as("privateKeyRef"))
.where(filter);
if (options?.offset) {
query = query.offset(options.offset);
}
if (options?.limit) {
query = query.limit(options.limit);
}
if (options?.sort) {
options.sort.forEach(([column, direction]) => {
query = query.orderBy(column, direction);
});
}
const results = await query;
return results.map((row) => {
return {
...row,
hasPrivateKey: row.privateKeyRef !== null
};
});
} catch (error) {
throw new DatabaseError({ error, name: "Find certificates with private key info" });
}
};
return {
...certificateOrm,
countCertificatesInProject,
countCertificatesForPkiSubscriber,
findLatestActiveCertForSubscriber,
findAllActiveCertsForSubscriber,
findExpiredSyncedCertificates
findExpiredSyncedCertificates,
findCertificatesEligibleForRenewal,
findWithPrivateKeyInfo
};
};
@@ -21,6 +21,11 @@ export enum CertKeyAlgorithm {
ECDSA_P521 = "EC_secp521r1"
}
export enum CertKeyType {
RSA = "RSA",
ECDSA = "ECDSA"
}
export enum CertSignatureAlgorithm {
RSA_SHA256 = "RSA-SHA256",
RSA_SHA384 = "RSA-SHA384",
@@ -69,15 +69,15 @@ export const apiEnrollmentConfigDALFactory = (db: TDbClient) => {
const profiles = await query
.where((qb) => {
void qb
.whereNull(`${TableName.PkiApiEnrollmentConfig}.autoRenewDays`)
.orWhere(`${TableName.PkiApiEnrollmentConfig}.autoRenewDays`, "<=", renewalThresholdDays);
.whereNull(`${TableName.PkiApiEnrollmentConfig}.renewBeforeDays`)
.orWhere(`${TableName.PkiApiEnrollmentConfig}.renewBeforeDays`, "<=", renewalThresholdDays);
})
.select((tx || db).ref("id").withSchema(TableName.PkiCertificateProfile))
.select((tx || db).ref("name").withSchema(TableName.PkiCertificateProfile))
.select((tx || db).ref("projectId").withSchema(TableName.PkiCertificateProfile))
.select((tx || db).ref("autoRenewDays").withSchema(TableName.PkiCertificateProfile));
.select((tx || db).ref("renewBeforeDays").withSchema(TableName.PkiCertificateProfile));
return profiles as Array<{ id: string; name: string; projectId: string; autoRenewDays?: number }>;
return profiles as Array<{ id: string; name: string; projectId: string; renewBeforeDays?: number }>;
} catch (error) {
throw new DatabaseError({ error, name: "Find profiles for auto renewal" });
}
@@ -25,5 +25,5 @@ export interface TEstConfigData {
export interface TApiConfigData {
autoRenew: boolean;
autoRenewDays?: number;
renewBeforeDays?: number;
}
@@ -16,6 +16,7 @@ import { AppConnection } from "../app-connection/app-connection-enums";
import { decryptAppConnectionCredentials } from "../app-connection/app-connection-fns";
import { TAppConnectionServiceFactory } from "../app-connection/app-connection-service";
import {
convertVaultValueToString,
getHCVaultAuthMounts,
getHCVaultKubernetesAuthRoles,
getHCVaultSecretsForPath,
@@ -592,7 +593,7 @@ export const externalMigrationServiceFactory = ({
projectId,
secrets: Object.entries(vaultSecrets).map(([secretKey, secretValue]) => ({
secretKey,
secretValue
secretValue: convertVaultValueToString(secretValue)
}))
});
@@ -2,13 +2,13 @@ interface PrincipalArnEntity {
Partition: string;
Service: "iam" | "sts";
AccountNumber: string;
Type: "user" | "role" | "instance-profile";
Type: "user" | "role" | "instance-profile" | "assumed-role";
Path: string;
FriendlyName: string;
SessionInfo: string; // Only populated for assumed-role
}
export const extractPrincipalArnEntity = (arn: string): PrincipalArnEntity => {
export const extractPrincipalArnEntity = (arn: string, formatAsIamRole: boolean = false): PrincipalArnEntity => {
// split the ARN into parts using ":" as the delimiter
const fullParts = arn.split(":");
if (fullParts.length !== 6) {
@@ -49,7 +49,7 @@ export const extractPrincipalArnEntity = (arn: string): PrincipalArnEntity => {
}
// assumed roles use a special format where the friendly name is the role name
const [roleName, sessionId] = rest;
finalType = "role"; // treat assumed role case as role
finalType = formatAsIamRole ? "role" : "assumed-role";
friendlyName = roleName;
sessionInfo = sessionId;
break;
@@ -83,9 +83,11 @@ export const extractPrincipalArnEntity = (arn: string): PrincipalArnEntity => {
* Extracts the identity ARN from the GetCallerIdentity response to one of the following formats:
* - arn:aws:iam::123456789012:user/MyUserName
* - arn:aws:iam::123456789012:role/MyRoleName
* - arn:aws-us-gov:iam::123456789012:user/MyUserName (GovCloud)
* - arn:aws-us-gov:iam::123456789012:role/MyRoleName (GovCloud)
*/
export const extractPrincipalArn = (arn: string) => {
const entity = extractPrincipalArnEntity(arn);
export const extractPrincipalArn = (arn: string, formatAsIamRole: boolean = false) => {
const entity = extractPrincipalArnEntity(arn, formatAsIamRole);
return `arn:aws:iam::${entity.AccountNumber}:${entity.Type}/${entity.FriendlyName}`;
return `arn:${entity.Partition}:${formatAsIamRole ? "iam" : entity.Service}::${entity.AccountNumber}:${entity.Type}/${entity.FriendlyName}`;
};
@@ -21,6 +21,7 @@ import {
UnauthorizedError
} from "@app/lib/errors";
import { extractIPDetails, isValidIpOrCidr } from "@app/lib/ip";
import { logger } from "@app/lib/logger";
import { ActorType, AuthTokenType } from "../auth/auth-type";
import { TIdentityDALFactory } from "../identity/identity-dal";
@@ -147,6 +148,8 @@ export const identityAwsAuthServiceFactory = ({
if (identityAwsAuth.allowedPrincipalArns) {
// validate if Arn is in the list of allowed Principal ARNs
const formattedArn = extractPrincipalArn(Arn);
const isArnAllowed = identityAwsAuth.allowedPrincipalArns
.split(",")
.map((principalArn) => principalArn.trim())
@@ -155,13 +158,18 @@ export const identityAwsAuthServiceFactory = ({
// considers exact matches + wildcard matches
// heavily validated in router
const regex = new RE2(`^${principalArn.replaceAll("*", ".*")}$`);
return regex.test(extractPrincipalArn(Arn));
return regex.test(formattedArn) || regex.test(extractPrincipalArn(Arn, true));
});
if (!isArnAllowed)
if (!isArnAllowed) {
logger.error(
`AWS Auth Login: AWS principal ARN not allowed [principal-arn=${formattedArn}] [raw-arn=${Arn}] [identity-id=${identity.id}]`
);
throw new UnauthorizedError({
message: "Access denied: AWS principal ARN not allowed."
message: `Access denied: AWS principal ARN not allowed. [principal-arn=${formattedArn}]`
});
}
}
const identityAccessToken = await identityAwsAuthDAL.transaction(async (tx) => {
@@ -4,7 +4,10 @@ import { z } from "zod";
const twelveDigitRegex = new RE2(/^\d{12}$/);
// akhilmhdh: change this to a normal function later. Checked no redosable at the moment
const arnRegex = new RE2(/^arn:aws:iam::\d{12}:(user\/[a-zA-Z0-9_.@+*/-]+|role\/[a-zA-Z0-9_.@+*/-]+|\*)$/);
const arnRegex = new RE2(
/^arn:aws(?:-us-gov)?:(iam|sts)::\d{12}:(user\/[a-zA-Z0-9_.@+*/-]+|role\/[a-zA-Z0-9_.@+*/-]+|assumed-role\/[a-zA-Z0-9_.@+*/-]+|\*)$/
);
export const validateAccountIds = z
.string()
@@ -52,7 +55,7 @@ export const validatePrincipalArns = z
},
{
message:
"Each ARN must be in the format of 'arn:aws:iam::123456789012:user/UserName', 'arn:aws:iam::123456789012:role/RoleName', or 'arn:aws:iam::123456789012:*'."
"Each ARN must be in the format of 'arn:aws:iam::123456789012:user/UserName', 'arn:aws:iam::123456789012:role/RoleName', or 'arn:aws:iam::123456789012:*', 'arn:aws:sts::123456789012:assumed-role/RoleName'. GovCloud ARNs (arn:aws-us-gov:...) are also supported."
}
)
// Transform to normalize the spaces around commas
@@ -719,7 +719,8 @@ export const identityKubernetesAuthServiceFactory = ({
);
}
const shouldUpdateGatewayId = Boolean(gatewayId);
// Strict check to see if gateway ID is undefined. It should update the gateway ID to null if its strictly set to null.
const shouldUpdateGatewayId = Boolean(gatewayId !== undefined);
const gatewayIdValue = isGatewayV1 ? gatewayId : null;
const gatewayV2IdValue = isGatewayV1 ? null : gatewayId;
+21 -6
View File
@@ -12,6 +12,7 @@ import {
TExternalKmsProviderFns
} from "@app/ee/services/external-kms/providers/model";
import { THsmServiceFactory } from "@app/ee/services/hsm/hsm-service";
import { THsmStatus } from "@app/ee/services/hsm/hsm-types";
import { KeyStorePrefixes, PgSqlLock, TKeyStoreFactory } from "@app/keystore/keystore";
import { TEnvConfig } from "@app/lib/config/env";
import { symmetricCipherService, SymmetricKeyAlgorithm } from "@app/lib/crypto/cipher";
@@ -1077,17 +1078,22 @@ export const kmsServiceFactory = ({
return { id, name, orgId, isExternal };
};
const startService = async () => {
const startService = async (hsmStatus: THsmStatus) => {
const kmsRootConfig = await kmsRootConfigDAL.transaction(async (tx) => {
await tx.raw("SELECT pg_advisory_xact_lock(?)", [PgSqlLock.KmsRootKeyInit]);
// check if KMS root key was already generated and saved in DB
const existingRootConfig = await kmsRootConfigDAL.findById(KMS_ROOT_CONFIG_UUID);
if (existingRootConfig) return existingRootConfig;
logger.info("KMS: Generating new ROOT Key");
const newRootKey = crypto.randomBytes(32);
const encryptedRootKey = await $encryptRootKey(newRootKey, RootKeyEncryptionStrategy.Software).catch((err) => {
logger.error({ hsmEnabled: hsmService.isActive() }, "KMS: Failed to encrypt ROOT Key");
const isHsmActive = hsmStatus.isHsmConfigured;
logger.info(`KMS: Generating new ROOT Key with ${isHsmActive ? "HSM" : "software"} encryption`);
const newRootKey = isHsmActive ? await hsmService.randomBytes(32) : crypto.randomBytes(32);
const encryptionStrategy = isHsmActive ? RootKeyEncryptionStrategy.HSM : RootKeyEncryptionStrategy.Software;
const encryptedRootKey = await $encryptRootKey(newRootKey, encryptionStrategy).catch((err) => {
logger.error({ hsmEnabled: isHsmActive, encryptionStrategy }, "KMS: Failed to encrypt ROOT Key");
throw err;
});
@@ -1095,7 +1101,7 @@ export const kmsServiceFactory = ({
// @ts-expect-error id is kept as fixed for idempotence and to avoid race condition
id: KMS_ROOT_CONFIG_UUID,
encryptedRootKey,
encryptionStrategy: RootKeyEncryptionStrategy.Software
encryptionStrategy
});
return newRootConfig;
});
@@ -1117,6 +1123,15 @@ export const kmsServiceFactory = ({
return;
}
if (strategy === RootKeyEncryptionStrategy.Software) {
if (!envConfig.ROOT_ENCRYPTION_KEY && !envConfig.ENCRYPTION_KEY) {
throw new BadRequestError({
message:
"Root KMS encryption strategy is set to software. Please set the ENCRYPTION_KEY environment variable and restart your deployment before trying to update the encryption strategy to software mode."
});
}
}
const decryptedRootKey = await $decryptRootKey(kmsRootConfig);
const encryptedRootKey = await $encryptRootKey(decryptedRootKey, strategy);
-10
View File
@@ -1,10 +0,0 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TOrgBotDALFactory = ReturnType<typeof orgBotDALFactory>;
export const orgBotDALFactory = (db: TDbClient) => {
const orgBotOrm = ormify(db, TableName.OrgBot);
return orgBotOrm;
};
+1 -38
View File
@@ -58,7 +58,6 @@ import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-
import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
import { TUserDALFactory } from "../user/user-dal";
import { TIncidentContactsDALFactory } from "./incident-contacts-dal";
import { TOrgBotDALFactory } from "./org-bot-dal";
import { TOrgDALFactory } from "./org-dal";
import { deleteOrgMembershipsFn } from "./org-fns";
import {
@@ -82,7 +81,6 @@ type TOrgServiceFactoryDep = {
secretV2BridgeDAL: Pick<TSecretV2BridgeDALFactory, "find">;
folderDAL: Pick<TSecretFolderDALFactory, "findByProjectId">;
orgDAL: TOrgDALFactory;
orgBotDAL: TOrgBotDALFactory;
roleDAL: TRoleDALFactory;
userDAL: TUserDALFactory;
groupDAL: TGroupDALFactory;
@@ -136,7 +134,6 @@ export const orgServiceFactory = ({
projectKeyDAL,
orgMembershipDAL,
tokenService,
orgBotDAL,
licenseService,
samlConfigDAL,
oidcConfigDAL,
@@ -614,23 +611,6 @@ export const orgServiceFactory = ({
},
trx?: Knex
) => {
const { privateKey, publicKey } = await crypto.encryption().asymmetric().generateKeyPair();
const key = crypto.randomBytes(32).toString("base64");
const {
ciphertext: encryptedPrivateKey,
iv: privateKeyIV,
tag: privateKeyTag,
encoding: privateKeyKeyEncoding,
algorithm: privateKeyAlgorithm
} = crypto.encryption().symmetric().encryptWithRootEncryptionKey(privateKey);
const {
ciphertext: encryptedSymmetricKey,
iv: symmetricKeyIV,
tag: symmetricKeyTag,
encoding: symmetricKeyKeyEncoding,
algorithm: symmetricKeyAlgorithm
} = crypto.encryption().symmetric().encryptWithRootEncryptionKey(key);
const customerId = await licenseService.generateOrgCustomerId(orgName, userEmail);
const createOrg = async (tx: Knex) => {
@@ -658,24 +638,7 @@ export const orgServiceFactory = ({
tx
);
}
await orgBotDAL.create(
{
name: org.name,
publicKey,
privateKeyIV,
encryptedPrivateKey,
symmetricKeyIV,
symmetricKeyTag,
encryptedSymmetricKey,
symmetricKeyAlgorithm,
orgId: org.id,
privateKeyTag,
privateKeyAlgorithm,
privateKeyKeyEncoding,
symmetricKeyKeyEncoding
},
tx
);
return org;
};
@@ -0,0 +1,61 @@
import { TPamAccountServiceFactory } from "@app/ee/services/pam-account/pam-account-service";
import { getConfig } from "@app/lib/config/env";
import { logger } from "@app/lib/logger";
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
type TPamAccountRotationServiceFactoryDep = {
queueService: TQueueServiceFactory;
pamAccountService: Pick<TPamAccountServiceFactory, "rotateAllDueAccounts">;
};
export type TPamAccountRotationServiceFactory = ReturnType<typeof pamAccountRotationServiceFactory>;
export const pamAccountRotationServiceFactory = ({
queueService,
pamAccountService
}: TPamAccountRotationServiceFactoryDep) => {
const appCfg = getConfig();
const init = async () => {
if (appCfg.isSecondaryInstance) {
return;
}
await queueService.stopRepeatableJob(
QueueName.PamAccountRotation,
QueueJobs.PamAccountRotation,
{ pattern: "0 * * * *", utc: true },
QueueName.PamAccountRotation // job id
);
await queueService.startPg<QueueName.PamAccountRotation>(
QueueJobs.PamAccountRotation,
async () => {
try {
logger.info(`${QueueName.PamAccountRotation}: pam account rotation task started`);
await pamAccountService.rotateAllDueAccounts();
logger.info(`${QueueName.PamAccountRotation}: pam account rotation task completed`);
} catch (error) {
logger.error(error, `${QueueName.PamAccountRotation}: pam account rotation failed`);
throw error;
}
},
{
batchSize: 1,
workerCount: 1,
pollingIntervalSeconds: 5 * 60
}
);
await queueService.schedulePg(
QueueJobs.PamAccountRotation,
"0 * * * *", // Schedule to run every hour
undefined,
{ tz: "UTC" }
);
};
return {
init
};
};
@@ -154,7 +154,7 @@ type TProjectServiceFactoryDep = {
>;
pkiSubscriberDAL: Pick<TPkiSubscriberDALFactory, "find">;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "find" | "findWithAssociatedCa">;
certificateDAL: Pick<TCertificateDALFactory, "find" | "countCertificatesInProject">;
certificateDAL: Pick<TCertificateDALFactory, "find" | "countCertificatesInProject" | "findWithPrivateKeyInfo">;
certificateTemplateDAL: Pick<TCertificateTemplateDALFactory, "getCertTemplatesByProjectId">;
pkiAlertDAL: Pick<TPkiAlertDALFactory, "find">;
pkiCollectionDAL: Pick<TPkiCollectionDALFactory, "find">;
@@ -938,13 +938,13 @@ export const projectServiceFactory = ({
ProjectPermissionSub.Certificates
);
const certificates = await certificateDAL.find(
const certificates = await certificateDAL.findWithPrivateKeyInfo(
{
projectId,
...(friendlyName && { friendlyName }),
...(commonName && { commonName })
},
{ offset, limit, sort: [["updatedAt", "desc"]] }
{ offset, limit, sort: [["notAfter", "desc"]] }
);
const count = await certificateDAL.countCertificatesInProject({
@@ -211,10 +211,18 @@ export const GithubSyncFns = {
}
const { connection } = secretSync;
const token =
connection.method === GitHubConnectionMethod.OAuth
? connection.credentials.accessToken
: await getGitHubAppAuthToken(connection, gatewayService, gatewayV2Service);
let token: string;
switch (connection.method) {
case GitHubConnectionMethod.OAuth:
token = connection.credentials.accessToken;
break;
case GitHubConnectionMethod.Pat:
token = connection.credentials.personalAccessToken;
break;
default:
token = await getGitHubAppAuthToken(connection, gatewayService, gatewayV2Service);
}
const encryptedSecrets = await getEncryptedSecrets(secretSync, gatewayService, gatewayV2Service);
const publicKey = await getPublicKey(secretSync, gatewayService, gatewayV2Service, token);
@@ -269,10 +277,18 @@ export const GithubSyncFns = {
const secretMap = Object.fromEntries(Object.entries(ogSecretMap).map(([i, v]) => [i.toUpperCase(), v]));
const { connection } = secretSync;
const token =
connection.method === GitHubConnectionMethod.OAuth
? connection.credentials.accessToken
: await getGitHubAppAuthToken(connection, gatewayService, gatewayV2Service);
let token: string;
switch (connection.method) {
case GitHubConnectionMethod.OAuth:
token = connection.credentials.accessToken;
break;
case GitHubConnectionMethod.Pat:
token = connection.credentials.personalAccessToken;
break;
default:
token = await getGitHubAppAuthToken(connection, gatewayService, gatewayV2Service);
}
const encryptedSecrets = await getEncryptedSecrets(secretSync, gatewayService, gatewayV2Service);
@@ -11,6 +11,7 @@ import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore";
import { getConfig } from "@app/lib/config/env";
import { logger } from "@app/lib/logger";
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
import { SecretNameSchema } from "@app/server/lib/schemas";
import { decryptAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns";
import { ActorType } from "@app/services/auth/auth-type";
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
@@ -408,6 +409,24 @@ export const secretSyncQueueFactory = ({
if (!Object.keys(importedSecrets).length) return {};
let invalidNameCount = 0;
let errorMessage = "";
for (const [key] of Object.entries(importedSecrets)) {
const result = SecretNameSchema.safeParse(key);
if (!result.success) {
invalidNameCount += 1;
if (errorMessage === "") errorMessage = result.error.issues[0]?.message;
}
}
if (invalidNameCount > 0) {
throw new SecretSyncError({
message: `Found ${invalidNameCount} invalid secret name${invalidNameCount === 1 ? "" : "s"}. ${errorMessage}`,
shouldRetry: false
});
}
const importedSecretMap: TSecretMap = {};
const secretMap = await $getInfisicalSecrets(secretSync, false);