mirror of
https://github.com/Infisical/infisical.git
synced 2026-05-02 03:02:03 -04:00
merge main
This commit is contained in:
2
.github/workflows/release_helm_gateway.yaml
vendored
2
.github/workflows/release_helm_gateway.yaml
vendored
@@ -24,6 +24,8 @@ jobs:
|
||||
|
||||
- name: Set up chart-testing
|
||||
uses: helm/chart-testing-action@v2.7.0
|
||||
with:
|
||||
yamale_version: "6.0.0"
|
||||
|
||||
- name: Run chart-testing (lint)
|
||||
run: ct lint --config ct.yaml --charts helm-charts/infisical-gateway
|
||||
|
||||
@@ -27,6 +27,8 @@ jobs:
|
||||
|
||||
- name: Set up chart-testing
|
||||
uses: helm/chart-testing-action@v2.7.0
|
||||
with:
|
||||
yamale_version: "6.0.0"
|
||||
|
||||
- name: Run chart-testing (lint)
|
||||
run: ct lint --config ct.yaml --charts helm-charts/infisical-gateway
|
||||
|
||||
3072
backend/package-lock.json
generated
3072
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -40,10 +40,10 @@
|
||||
"type:check": "node --max-old-space-size=8192 ./node_modules/.bin/tsc --noEmit",
|
||||
"lint:fix": "node --max-old-space-size=8192 ./node_modules/.bin/eslint --fix --ext js,ts ./src",
|
||||
"lint": "node --max-old-space-size=8192 ./node_modules/.bin/eslint 'src/**/*.ts'",
|
||||
"test:unit": "vitest run -c vitest.unit.config.ts",
|
||||
"test:e2e": "vitest run -c vitest.e2e.config.ts --bail=1",
|
||||
"test:e2e-watch": "vitest -c vitest.e2e.config.ts --bail=1",
|
||||
"test:e2e-coverage": "vitest run --coverage -c vitest.e2e.config.ts",
|
||||
"test:unit": "vitest run -c vitest.unit.config.mts",
|
||||
"test:e2e": "vitest run -c vitest.e2e.config.mts --bail=1",
|
||||
"test:e2e-watch": "vitest -c vitest.e2e.config.mts --bail=1",
|
||||
"test:e2e-coverage": "vitest run --coverage -c vitest.e2e.config.mts",
|
||||
"generate:component": "tsx ./scripts/create-backend-file.ts",
|
||||
"generate:schema": "tsx ./scripts/generate-schema-types.ts && eslint --fix --ext ts ./src/db/schemas",
|
||||
"auditlog-migration:latest": "node ./dist/db/rename-migrations-to-mjs.mjs && knex --knexfile ./dist/db/auditlog-knexfile.mjs --client pg migrate:latest",
|
||||
@@ -98,7 +98,7 @@
|
||||
"@types/jsrp": "^0.2.6",
|
||||
"@types/libsodium-wrappers": "^0.7.13",
|
||||
"@types/lodash.isequal": "^4.5.8",
|
||||
"@types/node": "^20.17.30",
|
||||
"@types/node": "^20.19.0",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"@types/passport-google-oauth20": "^2.0.14",
|
||||
"@types/pg": "^8.10.9",
|
||||
@@ -130,10 +130,10 @@
|
||||
"ts-node": "^10.9.2",
|
||||
"tsc-alias": "^1.8.8",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"tsup": "^8.0.1",
|
||||
"tsup": "^8.5.0",
|
||||
"tsx": "^4.4.0",
|
||||
"typescript": "^5.3.2",
|
||||
"vitest": "^1.2.2"
|
||||
"vitest": "^3.0.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-elasticache": "^3.637.0",
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
if (await knex.schema.hasColumn(TableName.PkiApiEnrollmentConfig, "autoRenewDays")) {
|
||||
await knex.schema.alterTable(TableName.PkiApiEnrollmentConfig, (t) => {
|
||||
t.renameColumn("autoRenewDays", "renewBeforeDays");
|
||||
});
|
||||
}
|
||||
|
||||
if (!(await knex.schema.hasColumn(TableName.Certificate, "renewBeforeDays"))) {
|
||||
await knex.schema.alterTable(TableName.Certificate, (t) => {
|
||||
t.integer("renewBeforeDays").nullable();
|
||||
t.uuid("renewedFromCertificateId").nullable();
|
||||
t.uuid("renewedByCertificateId").nullable();
|
||||
t.text("renewalError").nullable();
|
||||
t.string("keyAlgorithm").nullable();
|
||||
t.string("signatureAlgorithm").nullable();
|
||||
t.foreign("renewedFromCertificateId").references("id").inTable(TableName.Certificate).onDelete("SET NULL");
|
||||
t.foreign("renewedByCertificateId").references("id").inTable(TableName.Certificate).onDelete("SET NULL");
|
||||
t.index("renewedFromCertificateId");
|
||||
t.index("renewedByCertificateId");
|
||||
t.index("renewBeforeDays");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
if (await knex.schema.hasColumn(TableName.Certificate, "renewBeforeDays")) {
|
||||
await knex.schema.alterTable(TableName.Certificate, (t) => {
|
||||
t.dropForeign(["renewedFromCertificateId"]);
|
||||
t.dropForeign(["renewedByCertificateId"]);
|
||||
t.dropIndex("renewedFromCertificateId");
|
||||
t.dropIndex("renewedByCertificateId");
|
||||
t.dropIndex("renewBeforeDays");
|
||||
t.dropColumn("renewBeforeDays");
|
||||
t.dropColumn("renewedFromCertificateId");
|
||||
t.dropColumn("renewedByCertificateId");
|
||||
t.dropColumn("renewalError");
|
||||
t.dropColumn("keyAlgorithm");
|
||||
t.dropColumn("signatureAlgorithm");
|
||||
});
|
||||
}
|
||||
|
||||
if (await knex.schema.hasColumn(TableName.PkiApiEnrollmentConfig, "renewBeforeDays")) {
|
||||
await knex.schema.alterTable(TableName.PkiApiEnrollmentConfig, (t) => {
|
||||
t.renameColumn("renewBeforeDays", "autoRenewDays");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
|
||||
export async function up(knex: Knex): Promise<void> {
|
||||
const hasOrgBlockDuplicateColumn = await knex.schema.hasColumn(
|
||||
TableName.Organization,
|
||||
"blockDuplicateSecretSyncDestinations"
|
||||
);
|
||||
if (!hasOrgBlockDuplicateColumn) {
|
||||
await knex.schema.table(TableName.Organization, (table) => {
|
||||
table.boolean("blockDuplicateSecretSyncDestinations").notNullable().defaultTo(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
const hasOrgBlockDuplicateColumn = await knex.schema.hasColumn(
|
||||
TableName.Organization,
|
||||
"blockDuplicateSecretSyncDestinations"
|
||||
);
|
||||
if (hasOrgBlockDuplicateColumn) {
|
||||
await knex.schema.table(TableName.Organization, (table) => {
|
||||
table.dropColumn("blockDuplicateSecretSyncDestinations");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,13 @@ export const CertificatesSchema = z.object({
|
||||
extendedKeyUsages: z.string().array().nullable().optional(),
|
||||
projectId: z.string(),
|
||||
pkiSubscriberId: z.string().uuid().nullable().optional(),
|
||||
profileId: z.string().uuid().nullable().optional()
|
||||
profileId: z.string().uuid().nullable().optional(),
|
||||
renewBeforeDays: z.number().nullable().optional(),
|
||||
renewedFromCertificateId: z.string().uuid().nullable().optional(),
|
||||
renewedByCertificateId: z.string().uuid().nullable().optional(),
|
||||
renewalError: z.string().nullable().optional(),
|
||||
keyAlgorithm: z.string().nullable().optional(),
|
||||
signatureAlgorithm: z.string().nullable().optional()
|
||||
});
|
||||
|
||||
export type TCertificates = z.infer<typeof CertificatesSchema>;
|
||||
|
||||
@@ -40,7 +40,8 @@ export const OrganizationsSchema = z.object({
|
||||
googleSsoAuthEnforced: z.boolean().default(false),
|
||||
googleSsoAuthLastUsed: z.date().nullable().optional(),
|
||||
parentOrgId: z.string().uuid().nullable().optional(),
|
||||
rootOrgId: z.string().uuid().nullable().optional()
|
||||
rootOrgId: z.string().uuid().nullable().optional(),
|
||||
blockDuplicateSecretSyncDestinations: z.boolean().default(false)
|
||||
});
|
||||
|
||||
export type TOrganizations = z.infer<typeof OrganizationsSchema>;
|
||||
|
||||
@@ -10,7 +10,7 @@ import { TImmutableDBKeys } from "./models";
|
||||
export const PkiApiEnrollmentConfigsSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
autoRenew: z.boolean().default(false).nullable().optional(),
|
||||
autoRenewDays: z.number().nullable().optional(),
|
||||
renewBeforeDays: z.number().nullable().optional(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date()
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ import { z } from "zod";
|
||||
|
||||
import { DynamicSecretLeasesSchema } from "@app/db/schemas";
|
||||
import { ApiDocsTags, DYNAMIC_SECRET_LEASES } from "@app/lib/api-docs";
|
||||
import { daysToMillisecond } from "@app/lib/dates";
|
||||
import { removeTrailingSlash } from "@app/lib/fn";
|
||||
import { ms } from "@app/lib/ms";
|
||||
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
|
||||
@@ -32,8 +31,8 @@ export const registerDynamicSecretLeaseRouter = async (server: FastifyZodProvide
|
||||
const valMs = ms(val);
|
||||
if (valMs < 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||
if (valMs > daysToMillisecond(1))
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||
if (valMs > ms("10y"))
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than 10 years" });
|
||||
}),
|
||||
path: z.string().trim().default("/").transform(removeTrailingSlash).describe(DYNAMIC_SECRET_LEASES.CREATE.path),
|
||||
environmentSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.CREATE.environmentSlug),
|
||||
@@ -127,8 +126,8 @@ export const registerDynamicSecretLeaseRouter = async (server: FastifyZodProvide
|
||||
const valMs = ms(val);
|
||||
if (valMs < 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||
if (valMs > daysToMillisecond(1))
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||
if (valMs > ms("10y"))
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than 10 years" });
|
||||
}),
|
||||
projectSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.RENEW.projectSlug),
|
||||
path: z
|
||||
|
||||
@@ -2,7 +2,6 @@ import { z } from "zod";
|
||||
|
||||
import { DynamicSecretLeasesSchema } from "@app/db/schemas";
|
||||
import { ApiDocsTags, DYNAMIC_SECRET_LEASES } from "@app/lib/api-docs";
|
||||
import { daysToMillisecond } from "@app/lib/dates";
|
||||
import { removeTrailingSlash } from "@app/lib/fn";
|
||||
import { ms } from "@app/lib/ms";
|
||||
import { writeLimit } from "@app/server/config/rateLimiter";
|
||||
@@ -32,8 +31,8 @@ export const registerKubernetesDynamicSecretLeaseRouter = async (server: Fastify
|
||||
const valMs = ms(val);
|
||||
if (valMs < 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be greater than 1min" });
|
||||
if (valMs > daysToMillisecond(1))
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||
if (valMs > ms("10y"))
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than 10 years" });
|
||||
}),
|
||||
path: z.string().trim().default("/").transform(removeTrailingSlash).describe(DYNAMIC_SECRET_LEASES.CREATE.path),
|
||||
environmentSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.CREATE.environmentSlug),
|
||||
|
||||
@@ -3,7 +3,6 @@ import { z } from "zod";
|
||||
import { DynamicSecretLeasesSchema } from "@app/db/schemas";
|
||||
import { DynamicSecretProviderSchema } from "@app/ee/services/dynamic-secret/providers/models";
|
||||
import { ApiDocsTags, DYNAMIC_SECRETS } from "@app/lib/api-docs";
|
||||
import { daysToMillisecond } from "@app/lib/dates";
|
||||
import { removeTrailingSlash } from "@app/lib/fn";
|
||||
import { ms } from "@app/lib/ms";
|
||||
import { isValidHandleBarTemplate } from "@app/lib/template/validate-handlebars";
|
||||
@@ -60,8 +59,8 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) =>
|
||||
const valMs = ms(val);
|
||||
if (valMs < 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||
if (valMs > daysToMillisecond(1))
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||
if (valMs > ms("10y"))
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than 10 years" });
|
||||
}),
|
||||
maxTTL: z
|
||||
.string()
|
||||
@@ -72,8 +71,8 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) =>
|
||||
const valMs = ms(val);
|
||||
if (valMs < 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||
if (valMs > daysToMillisecond(1))
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||
if (valMs > ms("10y"))
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than 10 years" });
|
||||
})
|
||||
.nullable(),
|
||||
path: z.string().describe(DYNAMIC_SECRETS.CREATE.path).trim().default("/").transform(removeTrailingSlash),
|
||||
@@ -130,8 +129,8 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) =>
|
||||
const valMs = ms(val);
|
||||
if (valMs < 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||
if (valMs > daysToMillisecond(1))
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||
if (valMs > ms("10y"))
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than 10 years" });
|
||||
}),
|
||||
maxTTL: z
|
||||
.string()
|
||||
@@ -142,8 +141,8 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) =>
|
||||
const valMs = ms(val);
|
||||
if (valMs < 60 * 1000)
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
|
||||
if (valMs > daysToMillisecond(1))
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" });
|
||||
if (valMs > ms("10y"))
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than 10 years" });
|
||||
})
|
||||
.nullable(),
|
||||
newName: z.string().describe(DYNAMIC_SECRETS.UPDATE.newName).optional(),
|
||||
|
||||
@@ -340,6 +340,8 @@ export enum EventType {
|
||||
ISSUE_PKI_SUBSCRIBER_CERT = "issue-pki-subscriber-cert",
|
||||
SIGN_PKI_SUBSCRIBER_CERT = "sign-pki-subscriber-cert",
|
||||
AUTOMATED_RENEW_SUBSCRIBER_CERT = "automated-renew-subscriber-cert",
|
||||
AUTOMATED_RENEW_CERTIFICATE = "automated-renew-certificate",
|
||||
AUTOMATED_RENEW_CERTIFICATE_FAILED = "automated-renew-certificate-failed",
|
||||
LIST_PKI_SUBSCRIBER_CERTS = "list-pki-subscriber-certs",
|
||||
GET_SUBSCRIBER_ACTIVE_CERT_BUNDLE = "get-subscriber-active-cert-bundle",
|
||||
CREATE_KMS = "create-kms",
|
||||
@@ -367,6 +369,9 @@ export enum EventType {
|
||||
ISSUE_CERTIFICATE_FROM_PROFILE = "issue-certificate-from-profile",
|
||||
SIGN_CERTIFICATE_FROM_PROFILE = "sign-certificate-from-profile",
|
||||
ORDER_CERTIFICATE_FROM_PROFILE = "order-certificate-from-profile",
|
||||
RENEW_CERTIFICATE = "renew-certificate",
|
||||
UPDATE_CERTIFICATE_RENEWAL_CONFIG = "update-certificate-renewal-config",
|
||||
DISABLE_CERTIFICATE_RENEWAL_CONFIG = "disable-certificate-renewal-config",
|
||||
ATTEMPT_CREATE_SLACK_INTEGRATION = "attempt-create-slack-integration",
|
||||
ATTEMPT_REINSTALL_SLACK_INTEGRATION = "attempt-reinstall-slack-integration",
|
||||
GET_PROJECT_SLACK_CONFIG = "get-project-slack-config",
|
||||
@@ -2458,6 +2463,29 @@ interface AutomatedRenewPkiSubscriberCert {
|
||||
};
|
||||
}
|
||||
|
||||
interface AutomatedRenewCertificate {
|
||||
type: EventType.AUTOMATED_RENEW_CERTIFICATE;
|
||||
metadata: {
|
||||
certificateId: string;
|
||||
commonName: string;
|
||||
profileId: string;
|
||||
renewBeforeDays: string;
|
||||
profileName: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface AutomatedRenewCertificateFailed {
|
||||
type: EventType.AUTOMATED_RENEW_CERTIFICATE_FAILED;
|
||||
metadata: {
|
||||
certificateId: string;
|
||||
commonName: string;
|
||||
profileId: string;
|
||||
renewBeforeDays: string;
|
||||
profileName: string;
|
||||
error: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface SignPkiSubscriberCert {
|
||||
type: EventType.SIGN_PKI_SUBSCRIBER_CERT;
|
||||
metadata: {
|
||||
@@ -2720,6 +2748,16 @@ interface OrderCertificateFromProfile {
|
||||
};
|
||||
}
|
||||
|
||||
interface RenewCertificate {
|
||||
type: EventType.RENEW_CERTIFICATE;
|
||||
metadata: {
|
||||
originalCertificateId: string;
|
||||
newCertificateId: string;
|
||||
profileName: string;
|
||||
commonName: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface AttemptCreateSlackIntegration {
|
||||
type: EventType.ATTEMPT_CREATE_SLACK_INTEGRATION;
|
||||
metadata: {
|
||||
@@ -4009,6 +4047,23 @@ interface PamResourceDeleteEvent {
|
||||
};
|
||||
}
|
||||
|
||||
interface UpdateCertificateRenewalConfigEvent {
|
||||
type: EventType.UPDATE_CERTIFICATE_RENEWAL_CONFIG;
|
||||
metadata: {
|
||||
certificateId: string;
|
||||
renewBeforeDays: string;
|
||||
commonName: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface DisableCertificateRenewalConfigEvent {
|
||||
type: EventType.DISABLE_CERTIFICATE_RENEWAL_CONFIG;
|
||||
metadata: {
|
||||
certificateId: string;
|
||||
commonName: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type Event =
|
||||
| CreateSubOrganizationEvent
|
||||
| UpdateSubOrganizationEvent
|
||||
@@ -4216,6 +4271,7 @@ export type Event =
|
||||
| IssueCertificateFromProfile
|
||||
| SignCertificateFromProfile
|
||||
| OrderCertificateFromProfile
|
||||
| RenewCertificate
|
||||
| GetAzureAdCsTemplatesEvent
|
||||
| AttemptCreateSlackIntegration
|
||||
| AttemptReinstallSlackIntegration
|
||||
@@ -4373,4 +4429,8 @@ export type Event =
|
||||
| PamResourceGetEvent
|
||||
| PamResourceCreateEvent
|
||||
| PamResourceUpdateEvent
|
||||
| PamResourceDeleteEvent;
|
||||
| PamResourceDeleteEvent
|
||||
| UpdateCertificateRenewalConfigEvent
|
||||
| DisableCertificateRenewalConfigEvent
|
||||
| AutomatedRenewCertificate
|
||||
| AutomatedRenewCertificateFailed;
|
||||
|
||||
@@ -112,7 +112,7 @@ export const dynamicSecretServiceFactory = ({
|
||||
|
||||
const existingDynamicSecret = await dynamicSecretDAL.findOne({ name, folderId: folder.id });
|
||||
if (existingDynamicSecret)
|
||||
throw new BadRequestError({ message: "Provided dynamic secret already exist under the folder" });
|
||||
throw new BadRequestError({ message: "Provided dynamic secret already exists under the folder" });
|
||||
|
||||
const selectedProvider = dynamicSecretProviders[provider.type];
|
||||
const inputs = await selectedProvider.validateProviderInputs(provider.inputs, { projectId });
|
||||
@@ -265,7 +265,7 @@ export const dynamicSecretServiceFactory = ({
|
||||
if (newName) {
|
||||
const existingDynamicSecret = await dynamicSecretDAL.findOne({ name: newName, folderId: folder.id });
|
||||
if (existingDynamicSecret)
|
||||
throw new BadRequestError({ message: "Provided dynamic secret already exist under the folder" });
|
||||
throw new BadRequestError({ message: "Provided dynamic secret already exists under the folder" });
|
||||
}
|
||||
const { encryptor: secretManagerEncryptor, decryptor: secretManagerDecryptor } =
|
||||
await kmsService.createCipherPairWithDataKey({
|
||||
|
||||
@@ -1517,7 +1517,7 @@ export const secretApprovalRequestServiceFactory = ({
|
||||
}))
|
||||
);
|
||||
if (secrets.length)
|
||||
throw new BadRequestError({ message: `Secret already exist: ${secrets.map((el) => el.key).join(",")}` });
|
||||
throw new BadRequestError({ message: `Secret already exists: ${secrets.map((el) => el.key).join(",")}` });
|
||||
|
||||
commits.push(
|
||||
...createdSecrets.map((createdSecret) => ({
|
||||
|
||||
@@ -7,6 +7,7 @@ import https from "https";
|
||||
import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic-secret-fns";
|
||||
import { splitPemChain } from "@app/services/certificate/certificate-fns";
|
||||
|
||||
import { getConfig } from "../config/env";
|
||||
import { BadRequestError } from "../errors";
|
||||
import { GatewayProxyProtocol } from "../gateway/types";
|
||||
import { logger } from "../logger";
|
||||
@@ -80,6 +81,8 @@ const createGatewayConnection = async (
|
||||
gateway: { clientCertificate: string; clientPrivateKey: string; serverCertificateChain: string },
|
||||
protocol: GatewayProxyProtocol
|
||||
): Promise<net.Socket> => {
|
||||
const appCfg = getConfig();
|
||||
|
||||
const protocolToAlpn = {
|
||||
[GatewayProxyProtocol.Http]: "infisical-http-proxy",
|
||||
[GatewayProxyProtocol.Tcp]: "infisical-tcp-proxy",
|
||||
@@ -94,7 +97,8 @@ const createGatewayConnection = async (
|
||||
minVersion: "TLSv1.2",
|
||||
maxVersion: "TLSv1.3",
|
||||
rejectUnauthorized: true,
|
||||
ALPNProtocols: [protocolToAlpn[protocol]]
|
||||
ALPNProtocols: [protocolToAlpn[protocol]],
|
||||
checkServerIdentity: appCfg.isDevelopmentMode ? () => undefined : tls.checkServerIdentity
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
@@ -78,6 +78,7 @@ export enum QueueName {
|
||||
SecretReminderMigration = "secret-reminder-migration",
|
||||
UserNotification = "user-notification",
|
||||
HealthAlert = "health-alert",
|
||||
CertificateV3AutoRenewal = "certificate-v3-auto-renewal",
|
||||
PamAccountRotation = "pam-account-rotation"
|
||||
}
|
||||
|
||||
@@ -128,6 +129,7 @@ export enum QueueJobs {
|
||||
SecretReminderMigration = "secret-reminder-migration",
|
||||
UserNotification = "user-notification-job",
|
||||
HealthAlert = "health-alert",
|
||||
CertificateV3DailyAutoRenewal = "certificate-v3-daily-auto-renewal",
|
||||
PamAccountRotation = "pam-account-rotation"
|
||||
}
|
||||
|
||||
@@ -359,6 +361,10 @@ export type TQueueJobTypes = {
|
||||
name: QueueJobs.HealthAlert;
|
||||
payload: undefined;
|
||||
};
|
||||
[QueueName.CertificateV3AutoRenewal]: {
|
||||
name: QueueJobs.CertificateV3DailyAutoRenewal;
|
||||
payload: undefined;
|
||||
};
|
||||
[QueueName.PamAccountRotation]: {
|
||||
name: QueueJobs.PamAccountRotation;
|
||||
payload: undefined;
|
||||
|
||||
@@ -138,6 +138,11 @@ export const injectIdentity = fp(
|
||||
return;
|
||||
}
|
||||
|
||||
// Authentication is handled on a route-level
|
||||
if (req.url === "/api/v1/relays/heartbeat-instance-relay") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Authentication is handled on a route-level here.
|
||||
if (req.url.includes("/api/v1/workflow-integrations/microsoft-teams/message-endpoint")) {
|
||||
return;
|
||||
|
||||
@@ -177,6 +177,7 @@ import { certificateTemplateEstConfigDALFactory } from "@app/services/certificat
|
||||
import { certificateTemplateServiceFactory } from "@app/services/certificate-template/certificate-template-service";
|
||||
import { certificateTemplateV2DALFactory } from "@app/services/certificate-template-v2/certificate-template-v2-dal";
|
||||
import { certificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service";
|
||||
import { certificateV3QueueServiceFactory } from "@app/services/certificate-v3/certificate-v3-queue";
|
||||
import { certificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service";
|
||||
import { cmekServiceFactory } from "@app/services/cmek/cmek-service";
|
||||
import { convertorServiceFactory } from "@app/services/convertor/convertor-service";
|
||||
@@ -1957,6 +1958,8 @@ export const registerRoutes = async (
|
||||
secretImportDAL,
|
||||
permissionService,
|
||||
appConnectionService,
|
||||
projectDAL,
|
||||
orgDAL,
|
||||
folderDAL,
|
||||
secretSyncQueue,
|
||||
projectBotService,
|
||||
@@ -2136,6 +2139,7 @@ export const registerRoutes = async (
|
||||
|
||||
const certificateV3Service = certificateV3ServiceFactory({
|
||||
certificateDAL,
|
||||
certificateSecretDAL,
|
||||
certificateAuthorityDAL,
|
||||
certificateProfileDAL,
|
||||
certificateTemplateV2Service,
|
||||
@@ -2143,6 +2147,13 @@ export const registerRoutes = async (
|
||||
permissionService
|
||||
});
|
||||
|
||||
const certificateV3Queue = certificateV3QueueServiceFactory({
|
||||
queueService,
|
||||
certificateDAL,
|
||||
certificateV3Service,
|
||||
auditLogService
|
||||
});
|
||||
|
||||
const certificateEstV3Service = certificateEstV3ServiceFactory({
|
||||
internalCertificateAuthorityService,
|
||||
certificateTemplateV2Service,
|
||||
@@ -2330,6 +2341,7 @@ export const registerRoutes = async (
|
||||
await dailyReminderQueueService.startSecretReminderMigrationJob();
|
||||
await dailyExpiringPkiItemAlert.startSendingAlerts();
|
||||
await pkiSubscriberQueue.startDailyAutoRenewalJob();
|
||||
await certificateV3Queue.init();
|
||||
await kmsService.startService(hsmStatus);
|
||||
await microsoftTeamsService.start();
|
||||
await dynamicSecretQueueService.init();
|
||||
|
||||
@@ -42,7 +42,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
|
||||
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()
|
||||
})
|
||||
@@ -150,7 +150,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
|
||||
.object({
|
||||
id: z.string(),
|
||||
autoRenew: z.boolean(),
|
||||
autoRenewDays: z.number().optional()
|
||||
renewBeforeDays: z.number().optional()
|
||||
})
|
||||
.optional()
|
||||
}).array(),
|
||||
@@ -230,7 +230,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
|
||||
.object({
|
||||
id: z.string(),
|
||||
autoRenew: z.boolean(),
|
||||
autoRenewDays: z.number().optional()
|
||||
renewBeforeDays: z.number().optional()
|
||||
})
|
||||
.optional(),
|
||||
metrics: z
|
||||
@@ -355,7 +355,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
|
||||
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()
|
||||
})
|
||||
|
||||
@@ -323,7 +323,11 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
|
||||
.min(1, "Max Shared Secret view count cannot be lower than 1")
|
||||
.max(1000, "Max Shared Secret view count cannot exceed 1000")
|
||||
.nullable()
|
||||
.optional(),
|
||||
blockDuplicateSecretSyncDestinations: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Block duplicate secret sync destinations across the organization")
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
|
||||
@@ -1200,7 +1200,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
certificates: z.array(CertificatesSchema),
|
||||
certificates: z.array(CertificatesSchema.extend({ hasPrivateKey: z.boolean() })),
|
||||
totalCount: z.number()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
CertKeyUsageType,
|
||||
CertSubjectAlternativeNameType
|
||||
} 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 { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators";
|
||||
|
||||
@@ -84,8 +85,8 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm).optional(),
|
||||
keyAlgorithm: z.nativeEnum(CertKeyAlgorithm).optional()
|
||||
signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm),
|
||||
keyAlgorithm: z.nativeEnum(CertKeyAlgorithm)
|
||||
})
|
||||
.refine(validateTtlAndDateFields, {
|
||||
message:
|
||||
@@ -169,9 +170,7 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
|
||||
.min(1, "TTL cannot be empty")
|
||||
.refine((val) => ms(val) > 0, "TTL must be a positive number"),
|
||||
notBefore: validateCaDateField.optional(),
|
||||
notAfter: validateCaDateField.optional(),
|
||||
signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm).optional(),
|
||||
keyAlgorithm: z.nativeEnum(CertKeyAlgorithm).optional()
|
||||
notAfter: validateCaDateField.optional()
|
||||
})
|
||||
.refine(validateTtlAndDateFields, {
|
||||
message:
|
||||
@@ -192,6 +191,8 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const certificateRequest = extractCertificateRequestFromCSR(req.body.csr);
|
||||
|
||||
const data = await server.services.certificateV3.signCertificateFromProfile({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
@@ -203,9 +204,7 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
|
||||
ttl: req.body.ttl
|
||||
},
|
||||
notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined,
|
||||
notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined,
|
||||
signatureAlgorithm: req.body.signatureAlgorithm,
|
||||
keyAlgorithm: req.body.keyAlgorithm
|
||||
notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
@@ -217,7 +216,7 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
|
||||
certificateProfileId: req.body.profileId,
|
||||
certificateId: data.certificateId,
|
||||
profileName: data.profileName,
|
||||
commonName: ""
|
||||
commonName: certificateRequest.commonName || ""
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -260,8 +259,8 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
|
||||
notBefore: validateCaDateField.optional(),
|
||||
notAfter: validateCaDateField.optional(),
|
||||
commonName: validateTemplateRegexField.optional(),
|
||||
signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm).optional(),
|
||||
keyAlgorithm: z.nativeEnum(CertKeyAlgorithm).optional()
|
||||
signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm),
|
||||
keyAlgorithm: z.nativeEnum(CertKeyAlgorithm)
|
||||
})
|
||||
.refine(validateTtlAndDateFields, {
|
||||
message:
|
||||
@@ -343,4 +342,145 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "POST",
|
||||
url: "/:certificateId/renew",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
hide: false,
|
||||
tags: [ApiDocsTags.PkiCertificates],
|
||||
params: z.object({
|
||||
certificateId: z.string().uuid()
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
certificate: z.string().trim(),
|
||||
issuingCaCertificate: z.string().trim(),
|
||||
certificateChain: z.string().trim(),
|
||||
privateKey: z.string().trim().optional(),
|
||||
serialNumber: z.string().trim(),
|
||||
certificateId: z.string()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
const data = await server.services.certificateV3.renewCertificate({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
certificateId: req.params.certificateId
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: data.projectId,
|
||||
event: {
|
||||
type: EventType.RENEW_CERTIFICATE,
|
||||
metadata: {
|
||||
originalCertificateId: req.params.certificateId,
|
||||
newCertificateId: data.certificateId,
|
||||
profileName: data.profileName,
|
||||
commonName: data.commonName
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
||||
});
|
||||
|
||||
server.route({
|
||||
method: "PATCH",
|
||||
url: "/:certificateId/config",
|
||||
config: {
|
||||
rateLimit: writeLimit
|
||||
},
|
||||
schema: {
|
||||
hide: false,
|
||||
tags: [ApiDocsTags.PkiCertificates],
|
||||
params: z.object({
|
||||
certificateId: z.string().uuid()
|
||||
}),
|
||||
body: z
|
||||
.object({
|
||||
renewBeforeDays: z.number().int().min(1).max(30).optional(),
|
||||
enableAutoRenewal: z.boolean().optional()
|
||||
})
|
||||
.refine((data) => !(data.renewBeforeDays !== undefined && data.enableAutoRenewal === false), {
|
||||
message: "Cannot specify both renewBeforeDays and enableAutoRenewal=false"
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
message: z.string(),
|
||||
renewBeforeDays: z.number().optional()
|
||||
})
|
||||
}
|
||||
},
|
||||
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
|
||||
handler: async (req) => {
|
||||
if (req.body.enableAutoRenewal === false) {
|
||||
const data = await server.services.certificateV3.disableRenewalConfig({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
certificateId: req.params.certificateId
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: data.projectId,
|
||||
event: {
|
||||
type: EventType.DISABLE_CERTIFICATE_RENEWAL_CONFIG,
|
||||
metadata: {
|
||||
certificateId: req.params.certificateId,
|
||||
commonName: data.commonName
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
message: "Auto-renewal disabled successfully"
|
||||
};
|
||||
}
|
||||
|
||||
if (req.body.renewBeforeDays !== undefined) {
|
||||
const data = await server.services.certificateV3.updateRenewalConfig({
|
||||
actor: req.permission.type,
|
||||
actorId: req.permission.id,
|
||||
actorAuthMethod: req.permission.authMethod,
|
||||
actorOrgId: req.permission.orgId,
|
||||
certificateId: req.params.certificateId,
|
||||
renewBeforeDays: req.body.renewBeforeDays
|
||||
});
|
||||
|
||||
await server.services.auditLog.createAuditLog({
|
||||
...req.auditLogInfo,
|
||||
projectId: data.projectId,
|
||||
event: {
|
||||
type: EventType.UPDATE_CERTIFICATE_RENEWAL_CONFIG,
|
||||
metadata: {
|
||||
certificateId: req.params.certificateId,
|
||||
renewBeforeDays: req.body.renewBeforeDays.toString(),
|
||||
commonName: data.commonName
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
message: "Certificate configuration updated successfully",
|
||||
renewBeforeDays: data.renewBeforeDays
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
message: "No configuration changes requested"
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
183
backend/src/services/certificate-common/certificate-csr-utils.ts
Normal file
183
backend/src/services/certificate-common/certificate-csr-utils.ts
Normal file
@@ -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;
|
||||
};
|
||||
|
||||
163
backend/src/services/certificate-v3/certificate-v3-queue.ts
Normal file
163
backend/src/services/certificate-v3/certificate-v3-queue.ts
Normal file
@@ -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;
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ export const importDataIntoInfisicalFn = async ({
|
||||
|
||||
if (existingEnv) {
|
||||
throw new BadRequestError({
|
||||
message: `Environment with slug '${slug}' already exist`,
|
||||
message: `Environment with slug '${slug}' already exists`,
|
||||
name: "CreateEnvironment"
|
||||
});
|
||||
}
|
||||
@@ -312,7 +312,7 @@ export const importDataIntoInfisicalFn = async ({
|
||||
);
|
||||
if (secretsByKeys.length) {
|
||||
throw new BadRequestError({
|
||||
message: `Secret already exist: ${secretsByKeys.map((el) => el.key).join(",")}`
|
||||
message: `Secret already exists: ${secretsByKeys.map((el) => el.key).join(",")}`
|
||||
});
|
||||
}
|
||||
await fnSecretBulkInsert({
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -27,5 +27,6 @@ export const sanitizedOrganizationSchema = OrganizationsSchema.pick({
|
||||
scannerProductEnabled: true,
|
||||
shareSecretsProductEnabled: true,
|
||||
maxSharedSecretLifetime: true,
|
||||
maxSharedSecretViewLimit: true
|
||||
maxSharedSecretViewLimit: true,
|
||||
blockDuplicateSecretSyncDestinations: true
|
||||
});
|
||||
|
||||
@@ -405,7 +405,8 @@ export const orgServiceFactory = ({
|
||||
scannerProductEnabled,
|
||||
shareSecretsProductEnabled,
|
||||
maxSharedSecretLifetime,
|
||||
maxSharedSecretViewLimit
|
||||
maxSharedSecretViewLimit,
|
||||
blockDuplicateSecretSyncDestinations
|
||||
}
|
||||
}: TUpdateOrgDTO) => {
|
||||
const appCfg = getConfig();
|
||||
@@ -516,7 +517,7 @@ export const orgServiceFactory = ({
|
||||
if (slug) {
|
||||
const existingOrg = await orgDAL.findOne({ slug, rootOrgId: null });
|
||||
if (existingOrg && existingOrg?.id !== orgId)
|
||||
throw new BadRequestError({ message: `Organization with slug ${slug} already exist` });
|
||||
throw new BadRequestError({ message: `Organization with slug ${slug} already exists` });
|
||||
}
|
||||
|
||||
if (googleSsoAuthEnforced) {
|
||||
@@ -589,7 +590,8 @@ export const orgServiceFactory = ({
|
||||
scannerProductEnabled,
|
||||
shareSecretsProductEnabled,
|
||||
maxSharedSecretLifetime,
|
||||
maxSharedSecretViewLimit
|
||||
maxSharedSecretViewLimit,
|
||||
blockDuplicateSecretSyncDestinations
|
||||
});
|
||||
if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` });
|
||||
return org;
|
||||
@@ -1147,7 +1149,7 @@ export const orgServiceFactory = ({
|
||||
const doesIncidentContactExist = await incidentContactDAL.findOne(orgId, { email });
|
||||
if (doesIncidentContactExist) {
|
||||
throw new BadRequestError({
|
||||
message: "Incident contact already exist",
|
||||
message: "Incident contact already exists",
|
||||
name: "Incident contact exist"
|
||||
});
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ export type TUpdateOrgDTO = {
|
||||
shareSecretsProductEnabled: boolean;
|
||||
maxSharedSecretLifetime: number;
|
||||
maxSharedSecretViewLimit: number | null;
|
||||
blockDuplicateSecretSyncDestinations: boolean;
|
||||
}>;
|
||||
} & TOrgPermission;
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ export const projectEnvServiceFactory = ({
|
||||
const existingEnv = envs.find(({ slug: envSlug }) => envSlug === slug);
|
||||
if (existingEnv)
|
||||
throw new BadRequestError({
|
||||
message: "Environment with slug already exist",
|
||||
message: "Environment with slug already exists",
|
||||
name: "CreateEnvironment"
|
||||
});
|
||||
|
||||
@@ -171,7 +171,7 @@ export const projectEnvServiceFactory = ({
|
||||
const existingEnv = await projectEnvDAL.findOne({ slug, projectId });
|
||||
if (existingEnv && existingEnv.id !== id) {
|
||||
throw new BadRequestError({
|
||||
message: "Environment with slug already exist",
|
||||
message: "Environment with slug already exists",
|
||||
name: "UpdateEnvironment"
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -15,6 +15,8 @@ import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors";
|
||||
import { deepEqualSkipFields } from "@app/lib/fn/object";
|
||||
import { OrgServiceActor } from "@app/lib/types";
|
||||
import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service";
|
||||
import { TOrgDALFactory } from "@app/services/org/org-dal";
|
||||
import { TProjectDALFactory } from "@app/services/project/project-dal";
|
||||
import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service";
|
||||
import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
|
||||
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
|
||||
@@ -50,6 +52,8 @@ type TSecretSyncServiceFactoryDep = {
|
||||
secretImportDAL: TSecretImportDALFactory;
|
||||
appConnectionService: Pick<TAppConnectionServiceFactory, "validateAppConnectionUsageById">;
|
||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">;
|
||||
projectDAL: Pick<TProjectDALFactory, "findById">;
|
||||
orgDAL: Pick<TOrgDALFactory, "findById">;
|
||||
projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">;
|
||||
folderDAL: Pick<TSecretFolderDALFactory, "findByProjectId" | "findById" | "findBySecretPath">;
|
||||
keyStore: Pick<TKeyStoreFactory, "getItem">;
|
||||
@@ -68,6 +72,8 @@ export const secretSyncServiceFactory = ({
|
||||
secretImportDAL,
|
||||
permissionService,
|
||||
appConnectionService,
|
||||
projectDAL,
|
||||
orgDAL,
|
||||
projectBotService,
|
||||
secretSyncQueue,
|
||||
keyStore,
|
||||
@@ -225,6 +231,61 @@ export const secretSyncServiceFactory = ({
|
||||
return secretSync as TSecretSync;
|
||||
};
|
||||
|
||||
const checkDuplicateDestination = async (
|
||||
{ destination, destinationConfig, excludeSyncId, projectId }: TCheckDuplicateDestinationDTO,
|
||||
actor: OrgServiceActor
|
||||
) => {
|
||||
const skipFields = SECRET_SYNC_SKIP_FIELDS_MAP[destination];
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorId: actor.id,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorOrgId: actor.orgId,
|
||||
actionProjectType: ActionProjectType.SecretManager,
|
||||
projectId
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionSecretSyncActions.Read,
|
||||
ProjectPermissionSub.SecretSyncs
|
||||
);
|
||||
|
||||
if (!destinationConfig || Object.keys(destinationConfig).length === 0) {
|
||||
return { hasDuplicate: false, duplicateProjectId: undefined };
|
||||
}
|
||||
|
||||
try {
|
||||
const existingSyncs = await secretSyncDAL.findByDestinationAndOrgId(destination, actor.orgId);
|
||||
|
||||
const duplicates = existingSyncs.filter((sync) => {
|
||||
if (sync.id === excludeSyncId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const baseFieldsMatch = deepEqualSkipFields(sync.destinationConfig, destinationConfig, skipFields);
|
||||
if (baseFieldsMatch) {
|
||||
return DESTINATION_DUPLICATE_CHECK_MAP[destination](
|
||||
sync.destinationConfig as Record<string, unknown>,
|
||||
destinationConfig
|
||||
);
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const hasDuplicate = duplicates.length > 0;
|
||||
return {
|
||||
hasDuplicate,
|
||||
duplicateProjectId: hasDuplicate ? duplicates[0].projectId : undefined
|
||||
};
|
||||
} catch (error) {
|
||||
return { hasDuplicate: false, duplicateProjectId: undefined };
|
||||
}
|
||||
};
|
||||
|
||||
const createSecretSync = async (
|
||||
{ projectId, secretPath, environment, ...params }: TCreateSecretSyncDTO,
|
||||
actor: OrgServiceActor
|
||||
@@ -271,6 +332,30 @@ export const secretSyncServiceFactory = ({
|
||||
message: `Could not find folder with path "${secretPath}" in environment "${environment}" for project with ID "${projectId}"`
|
||||
});
|
||||
|
||||
const project = await projectDAL.findById(projectId);
|
||||
if (!project) {
|
||||
throw new NotFoundError({ message: "Project not found" });
|
||||
}
|
||||
|
||||
const organization = await orgDAL.findById(project.orgId);
|
||||
if (organization?.blockDuplicateSecretSyncDestinations) {
|
||||
const duplicateCheck = await checkDuplicateDestination(
|
||||
{
|
||||
destination: params.destination,
|
||||
destinationConfig: params.destinationConfig,
|
||||
projectId
|
||||
},
|
||||
actor
|
||||
);
|
||||
if (duplicateCheck.hasDuplicate) {
|
||||
throw new BadRequestError({
|
||||
message: `A secret sync with this destination already exists${
|
||||
duplicateCheck.duplicateProjectId ? ` in project ${duplicateCheck.duplicateProjectId}` : ""
|
||||
}.`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const destinationApp = SECRET_SYNC_CONNECTION_MAP[params.destination];
|
||||
|
||||
// validates permission to connect and app is valid for sync destination
|
||||
@@ -369,6 +454,33 @@ export const secretSyncServiceFactory = ({
|
||||
|
||||
let { folderId } = secretSync;
|
||||
|
||||
if (params.destinationConfig) {
|
||||
const project = await projectDAL.findById(secretSync.projectId);
|
||||
if (!project) {
|
||||
throw new NotFoundError({ message: "Project not found" });
|
||||
}
|
||||
const organization = await orgDAL.findById(project.orgId);
|
||||
|
||||
if (organization?.blockDuplicateSecretSyncDestinations) {
|
||||
const duplicateCheck = await checkDuplicateDestination(
|
||||
{
|
||||
destination,
|
||||
destinationConfig: params.destinationConfig,
|
||||
projectId: secretSync.projectId,
|
||||
excludeSyncId: secretSync.id
|
||||
},
|
||||
actor
|
||||
);
|
||||
if (duplicateCheck.hasDuplicate) {
|
||||
throw new BadRequestError({
|
||||
message: `A secret sync with this destination already exists${
|
||||
duplicateCheck.duplicateProjectId ? ` in project ${duplicateCheck.duplicateProjectId}` : ""
|
||||
}.`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (params.connectionId) {
|
||||
const destinationApp = SECRET_SYNC_CONNECTION_MAP[secretSync.destination as SecretSync];
|
||||
|
||||
@@ -703,61 +815,6 @@ export const secretSyncServiceFactory = ({
|
||||
return updatedSecretSync as TSecretSync;
|
||||
};
|
||||
|
||||
const checkDuplicateDestination = async (
|
||||
{ destination, destinationConfig, excludeSyncId, projectId }: TCheckDuplicateDestinationDTO,
|
||||
actor: OrgServiceActor
|
||||
) => {
|
||||
const skipFields = SECRET_SYNC_SKIP_FIELDS_MAP[destination];
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
actor: actor.type,
|
||||
actorId: actor.id,
|
||||
actorAuthMethod: actor.authMethod,
|
||||
actorOrgId: actor.orgId,
|
||||
actionProjectType: ActionProjectType.SecretManager,
|
||||
projectId
|
||||
});
|
||||
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionSecretSyncActions.Read,
|
||||
ProjectPermissionSub.SecretSyncs
|
||||
);
|
||||
|
||||
if (!destinationConfig || Object.keys(destinationConfig).length === 0) {
|
||||
return { hasDuplicate: false, duplicateProjectId: undefined };
|
||||
}
|
||||
|
||||
try {
|
||||
const existingSyncs = await secretSyncDAL.findByDestinationAndOrgId(destination, actor.orgId);
|
||||
|
||||
const duplicates = existingSyncs.filter((sync) => {
|
||||
if (sync.id === excludeSyncId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const baseFieldsMatch = deepEqualSkipFields(sync.destinationConfig, destinationConfig, skipFields);
|
||||
if (baseFieldsMatch) {
|
||||
return DESTINATION_DUPLICATE_CHECK_MAP[destination](
|
||||
sync.destinationConfig as Record<string, unknown>,
|
||||
destinationConfig
|
||||
);
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
const hasDuplicate = duplicates.length > 0;
|
||||
return {
|
||||
hasDuplicate,
|
||||
duplicateProjectId: hasDuplicate ? duplicates[0].projectId : undefined
|
||||
};
|
||||
} catch (error) {
|
||||
return { hasDuplicate: false, duplicateProjectId: undefined };
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
listSecretSyncOptions,
|
||||
listSecretSyncsByProjectId,
|
||||
|
||||
@@ -35,7 +35,7 @@ export const secretTagServiceFactory = ({ secretTagDAL, permissionService }: TSe
|
||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Tags);
|
||||
|
||||
const existingTag = await secretTagDAL.findOne({ slug, projectId });
|
||||
if (existingTag) throw new BadRequestError({ message: "Tag already exist" });
|
||||
if (existingTag) throw new BadRequestError({ message: "Tag already exists" });
|
||||
|
||||
const newTag = await secretTagDAL.create({
|
||||
projectId,
|
||||
@@ -53,7 +53,7 @@ export const secretTagServiceFactory = ({ secretTagDAL, permissionService }: TSe
|
||||
|
||||
if (slug) {
|
||||
const existingTag = await secretTagDAL.findOne({ slug, projectId: tag.projectId });
|
||||
if (existingTag && existingTag.id !== tag.id) throw new BadRequestError({ message: "Tag already exist" });
|
||||
if (existingTag && existingTag.id !== tag.id) throw new BadRequestError({ message: "Tag already exists" });
|
||||
}
|
||||
|
||||
const { permission } = await permissionService.getProjectPermission({
|
||||
|
||||
@@ -282,7 +282,7 @@ export const secretV2BridgeServiceFactory = ({
|
||||
folderId
|
||||
});
|
||||
if (inputSecret.type === SecretType.Shared && doesSecretExist)
|
||||
throw new BadRequestError({ message: "Secret already exist" });
|
||||
throw new BadRequestError({ message: "Secret already exists" });
|
||||
|
||||
// if user creating personal check its shared also exist
|
||||
if (inputSecret.type === SecretType.Personal && !doesSecretExist) {
|
||||
@@ -527,7 +527,7 @@ export const secretV2BridgeServiceFactory = ({
|
||||
type: SecretType.Shared,
|
||||
folderId
|
||||
});
|
||||
if (doesNewNameSecretExist) throw new BadRequestError({ message: "Secret with the new name already exist" });
|
||||
if (doesNewNameSecretExist) throw new BadRequestError({ message: "Secret with the new name already exists" });
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionSecretActions.Edit,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
@@ -1674,7 +1674,7 @@ export const secretV2BridgeServiceFactory = ({
|
||||
}
|
||||
});
|
||||
if (secrets.length)
|
||||
throw new BadRequestError({ message: `Secret already exist: ${secrets.map((el) => el.key).join(",")}` });
|
||||
throw new BadRequestError({ message: `Secret already exists: ${secrets.map((el) => el.key).join(",")}` });
|
||||
|
||||
const project = await projectDAL.findById(projectId);
|
||||
await scanSecretPolicyViolations(projectId, secretPath, inputSecrets, project.secretDetectionIgnoreValues || []);
|
||||
|
||||
@@ -525,7 +525,7 @@ export const fnSecretBlindIndexCheck = async ({
|
||||
);
|
||||
|
||||
if (isNew) {
|
||||
if (secrets.length) throw new BadRequestError({ message: "Secret already exist" });
|
||||
if (secrets.length) throw new BadRequestError({ message: "Secret already exists" });
|
||||
} else {
|
||||
const secretKeysInDB = unique(secrets, (el) => el.secretBlindIndex as string).map(
|
||||
(el) => blindIndex2KeyName[el.secretBlindIndex as string]
|
||||
@@ -819,7 +819,7 @@ export const createManySecretsRawFnFactory = ({
|
||||
);
|
||||
if (secretsStoredInDB.length)
|
||||
throw new BadRequestError({
|
||||
message: `Secret already exist: ${secretsStoredInDB.map((el) => el.key).join(",")}`
|
||||
message: `Secret already exists: ${secretsStoredInDB.map((el) => el.key).join(",")}`
|
||||
});
|
||||
|
||||
const inputSecrets = secrets.map((secret) => {
|
||||
|
||||
@@ -2751,7 +2751,7 @@ export const secretServiceFactory = ({
|
||||
const existingSecretTags = await secretDAL.getSecretTags(secret.id);
|
||||
|
||||
if (existingSecretTags.some((tag) => tagSlugs.includes(tag.slug))) {
|
||||
throw new BadRequestError({ message: "One or more tags already exist on the secret" });
|
||||
throw new BadRequestError({ message: "One or more tags already exists on the secret" });
|
||||
}
|
||||
|
||||
const combinedTags = new Set([...existingSecretTags.map((tag) => tag.id), ...tags.map((el) => el.id)]);
|
||||
|
||||
@@ -12,13 +12,16 @@ export default defineConfig({
|
||||
},
|
||||
environment: "./e2e-test/vitest-environment-knex.ts",
|
||||
include: ["./e2e-test/**/*.spec.ts"],
|
||||
pool: "threads",
|
||||
poolOptions: {
|
||||
threads: {
|
||||
singleThread: true,
|
||||
useAtomics: true,
|
||||
isolate: false
|
||||
minThreads: 1,
|
||||
maxThreads: 1,
|
||||
singleThread: true
|
||||
}
|
||||
},
|
||||
fileParallelism: false,
|
||||
|
||||
alias: {
|
||||
"./license-fns": path.resolve(__dirname, "./src/ee/services/license/__mocks__/license-fns")
|
||||
}
|
||||
@@ -7,4 +7,10 @@ Infisical's Public (REST) API provides users an alternative way to programmatica
|
||||
secrets via HTTPS requests. This can be useful for automating tasks, such as
|
||||
rotating credentials, or for integrating secret management into a larger system.
|
||||
|
||||
With the Public API, you can create, read, update, and delete secrets, as well as manage access control, query audit logs, and more.
|
||||
With the Public API, you can create, read, update, and delete secrets, as well as manage access control, query audit logs, and more.
|
||||
|
||||
## API Versioning
|
||||
|
||||
The API is versioned on a per-resource basis. A resource's version is only incremented for breaking changes, so different endpoints may have different version numbers (e.g., `/api/v4/secrets` vs. `/api/v1/secret-syncs`).
|
||||
|
||||
As a best practice, always use the latest available version for each endpoint to ensure access to the most recent features and improvements.
|
||||
|
||||
@@ -9,11 +9,15 @@ The CLI is designed for a variety of secret management applications ranging from
|
||||
<Tab title="Local development">
|
||||
In the following steps, we explore how to use the Infisical CLI to fetch back environment variables from Infisical
|
||||
and inject them into your local development process.
|
||||
|
||||
|
||||
<Note>
|
||||
If you prefer learning by watching, you can follow along our step-by-step video tutorial [here](https://www.youtube.com/watch?v=EzDQC7nY3YY).
|
||||
</Note>
|
||||
|
||||
<Steps>
|
||||
<Step title="Log in with the CLI">
|
||||
Start by running the `infisical login` command to authenticate with Infisical.
|
||||
|
||||
|
||||
```bash
|
||||
infisical login
|
||||
```
|
||||
@@ -23,7 +27,7 @@ The CLI is designed for a variety of secret management applications ranging from
|
||||
</Step>
|
||||
<Step title="Initialize Infisical for your project">
|
||||
Next, navigate to your project and initialize Infisical.
|
||||
|
||||
|
||||
```bash
|
||||
# navigate to your project
|
||||
cd /path/to/project
|
||||
@@ -123,23 +127,25 @@ The CLI is designed for a variety of secret management applications ranging from
|
||||
<Note>
|
||||
Starting with CLI version v0.4.0, you can now choose to log in via Infisical Cloud (US/EU) or your own self-hosted instance by simply running `infisical login` and following the on-screen instructions — no need to manually set the `INFISICAL_API_URL` environment variable.
|
||||
|
||||
For versions prior to v0.4.0, the CLI defaults to the US Cloud. To connect to the EU Cloud or a self-hosted instance, set the `INFISICAL_API_URL` environment variable to `https://eu.infisical.com` or your custom URL.
|
||||
For versions prior to v0.4.0, the CLI defaults to the US Cloud. To connect to the EU Cloud or a self-hosted instance, set the `INFISICAL_API_URL` environment variable to `https://eu.infisical.com` or your custom URL.
|
||||
|
||||
</Note>
|
||||
|
||||
<Tip>
|
||||
## Custom Request Headers
|
||||
|
||||
The Infisical CLI supports custom HTTP headers for requests to servers protected by authentication services such as Cloudflare Access. Configure these headers using the `INFISICAL_CUSTOM_HEADERS` environment variable:
|
||||
The Infisical CLI supports custom HTTP headers for requests to servers protected by authentication services such as Cloudflare Access. Configure these headers using the `INFISICAL_CUSTOM_HEADERS` environment variable:
|
||||
|
||||
```bash
|
||||
# Syntax: headername1=headervalue1 headername2=headervalue2
|
||||
export INFISICAL_CUSTOM_HEADERS="Access-Client-Id=your-client-id Access-Client-Secret=your-client-secret"
|
||||
```bash
|
||||
# Syntax: headername1=headervalue1 headername2=headervalue2
|
||||
export INFISICAL_CUSTOM_HEADERS="Access-Client-Id=your-client-id Access-Client-Secret=your-client-secret"
|
||||
|
||||
# Execute Infisical commands after setting the environment variable
|
||||
infisical secrets
|
||||
```
|
||||
# Execute Infisical commands after setting the environment variable
|
||||
infisical secrets
|
||||
```
|
||||
|
||||
This functionality enables secure interaction with Infisical instances that require specific authentication headers.
|
||||
|
||||
This functionality enables secure interaction with Infisical instances that require specific authentication headers.
|
||||
</Tip>
|
||||
|
||||
## History
|
||||
|
||||
1027
frontend/package-lock.json
generated
1027
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -147,10 +147,10 @@
|
||||
"tailwindcss": "^4.1.14",
|
||||
"typescript": "~5.6.2",
|
||||
"typescript-eslint": "^8.15.0",
|
||||
"vite": "^5.4.18",
|
||||
"vite-plugin-node-polyfills": "^0.22.0",
|
||||
"vite": "^6.2.0",
|
||||
"vite-plugin-node-polyfills": "^0.23.0",
|
||||
"vite-plugin-top-level-await": "^1.4.4",
|
||||
"vite-plugin-wasm": "^3.3.0",
|
||||
"vite-plugin-wasm": "^3.4.0",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,7 +69,10 @@ export const PkiSyncSelect = ({ onSelect }: Props) => {
|
||||
type="button"
|
||||
onClick={() =>
|
||||
enterprise && !subscription.enterpriseCertificateSyncs
|
||||
? handlePopUpOpen("upgradePlan")
|
||||
? handlePopUpOpen("upgradePlan", {
|
||||
isEnterpriseFeature: true,
|
||||
text: "You can use every Certificate Sync if you switch to Infisical's Enterprise plan."
|
||||
})
|
||||
: onSelect(destination)
|
||||
}
|
||||
className="group relative flex h-28 cursor-pointer flex-col items-center justify-center overflow-hidden rounded-md border border-mineshaft-600 bg-mineshaft-700 p-4 duration-200 hover:bg-mineshaft-600"
|
||||
@@ -148,6 +151,7 @@ export const PkiSyncSelect = ({ onSelect }: Props) => {
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature}
|
||||
text="You can use every Certificate Sync if you switch to Infisical's Enterprise plan."
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -67,7 +67,9 @@ export const SecretSyncSelect = ({ onSelect }: Props) => {
|
||||
type="button"
|
||||
onClick={() =>
|
||||
enterprise && !subscription.enterpriseSecretSyncs
|
||||
? handlePopUpOpen("upgradePlan")
|
||||
? handlePopUpOpen("upgradePlan", {
|
||||
isEnterpriseFeature: true
|
||||
})
|
||||
: onSelect(destination)
|
||||
}
|
||||
className="group relative flex h-28 cursor-pointer flex-col items-center justify-center overflow-hidden rounded-md border border-mineshaft-600 bg-mineshaft-700 p-4 duration-200 hover:bg-mineshaft-600"
|
||||
@@ -145,6 +147,7 @@ export const SecretSyncSelect = ({ onSelect }: Props) => {
|
||||
)}
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text="You can use every Secret Sync if you switch to Infisical's Enterprise plan."
|
||||
/>
|
||||
|
||||
@@ -8,13 +8,14 @@ import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FormControl, Switch } from "@app/components/v2";
|
||||
import { useProject } from "@app/context";
|
||||
import { useOrganization, useProject } from "@app/context";
|
||||
import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs";
|
||||
import {
|
||||
SecretSync,
|
||||
SecretSyncInitialSyncBehavior,
|
||||
TSecretSync,
|
||||
useCreateSecretSync,
|
||||
useDuplicateDestinationCheck,
|
||||
useSecretSyncOption
|
||||
} from "@app/hooks/api/secretSyncs";
|
||||
|
||||
@@ -48,6 +49,7 @@ export const CreateSecretSyncForm = ({
|
||||
}: Props) => {
|
||||
const createSecretSync = useCreateSecretSync();
|
||||
const { currentProject } = useProject();
|
||||
const { currentOrg } = useOrganization();
|
||||
const { name: destinationName } = SECRET_SYNC_MAP[destination];
|
||||
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
@@ -106,11 +108,20 @@ export const CreateSecretSyncForm = ({
|
||||
setSelectedTabIndex((prev) => prev - 1);
|
||||
};
|
||||
|
||||
const { handleSubmit, trigger, control } = formMethods;
|
||||
const { handleSubmit, trigger, control, watch } = formMethods;
|
||||
|
||||
const { hasDuplicate } = useDuplicateDestinationCheck({
|
||||
destination,
|
||||
projectId: currentProject?.id || "",
|
||||
enabled: true,
|
||||
destinationConfig: watch("destinationConfig")
|
||||
});
|
||||
|
||||
const isStepValid = async (index: number) => trigger(FORM_TABS[index].fields);
|
||||
|
||||
const isFinalStep = selectedTabIndex === FORM_TABS.length - 1;
|
||||
const isCreateButtonDisabled =
|
||||
isFinalStep && hasDuplicate && currentOrg?.blockDuplicateSecretSyncDestinations;
|
||||
|
||||
const handleNext = async () => {
|
||||
if (isFinalStep) {
|
||||
@@ -245,7 +256,7 @@ export const CreateSecretSyncForm = ({
|
||||
</FormProvider>
|
||||
|
||||
<div className="flex w-full flex-row-reverse justify-between gap-4 pt-4">
|
||||
<Button onClick={handleNext} colorSchema="secondary">
|
||||
<Button onClick={handleNext} colorSchema="secondary" isDisabled={isCreateButtonDisabled}>
|
||||
{isFinalStep ? "Create Sync" : "Next"}
|
||||
</Button>
|
||||
{selectedTabIndex > 0 && (
|
||||
|
||||
@@ -6,6 +6,7 @@ type Props = {
|
||||
onConfirm: () => void;
|
||||
isLoading?: boolean;
|
||||
duplicateProjectId?: string;
|
||||
isDisabled?: boolean;
|
||||
};
|
||||
|
||||
export const DuplicateDestinationConfirmationModal = ({
|
||||
@@ -13,7 +14,8 @@ export const DuplicateDestinationConfirmationModal = ({
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
isLoading,
|
||||
duplicateProjectId
|
||||
duplicateProjectId,
|
||||
isDisabled
|
||||
}: Props) => {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
@@ -21,7 +23,12 @@ export const DuplicateDestinationConfirmationModal = ({
|
||||
<div className="mb-4 text-sm">
|
||||
<p>
|
||||
Another secret sync in your organization is already configured with the same
|
||||
destination. Proceeding may cause conflicts or overwrite existing data.
|
||||
destination.{" "}
|
||||
<span className={isDisabled ? "text-red-400" : ""}>
|
||||
{isDisabled
|
||||
? "Your organization does not allow duplicate destination configurations."
|
||||
: "Proceeding may cause conflicts or overwrite existing data."}
|
||||
</span>
|
||||
</p>
|
||||
{duplicateProjectId && (
|
||||
<p className="mt-2 text-xs text-mineshaft-400">
|
||||
@@ -31,26 +38,28 @@ export const DuplicateDestinationConfirmationModal = ({
|
||||
</code>
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2">Are you sure you want to continue?</p>
|
||||
{!isDisabled && <p className="mt-2">Are you sure you want to continue?</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 pt-4">
|
||||
<ModalClose asChild>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
colorSchema="danger"
|
||||
isLoading={isLoading}
|
||||
isDisabled={isLoading}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</ModalClose>
|
||||
<ModalClose asChild>
|
||||
<Button colorSchema="secondary" variant="plain" isDisabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
{!isDisabled && (
|
||||
<div className="flex items-center gap-4 pt-4">
|
||||
<ModalClose asChild>
|
||||
<Button
|
||||
onClick={onConfirm}
|
||||
colorSchema="danger"
|
||||
isLoading={isLoading}
|
||||
isDisabled={isLoading}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</ModalClose>
|
||||
<ModalClose asChild>
|
||||
<Button colorSchema="secondary" variant="plain" isDisabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { SecretSyncEditFields } from "@app/components/secret-syncs/types";
|
||||
import { Button, ModalClose } from "@app/components/v2";
|
||||
import { useOrganization } from "@app/context";
|
||||
import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs";
|
||||
import {
|
||||
TSecretSync,
|
||||
@@ -30,6 +31,7 @@ export const EditSecretSyncForm = ({ secretSync, fields, onComplete }: Props) =>
|
||||
const { name: destinationName } = SECRET_SYNC_MAP[secretSync.destination];
|
||||
const [showDuplicateConfirmation, setShowDuplicateConfirmation] = useState(false);
|
||||
const [pendingFormData, setPendingFormData] = useState<TSecretSyncForm | null>(null);
|
||||
const { currentOrg } = useOrganization();
|
||||
|
||||
const formMethods = useForm<TSecretSyncForm>({
|
||||
resolver: zodResolver(UpdateSecretSyncFormSchema),
|
||||
@@ -209,6 +211,7 @@ export const EditSecretSyncForm = ({ secretSync, fields, onComplete }: Props) =>
|
||||
onConfirm={handleConfirmDuplicate}
|
||||
isLoading={updateSecretSync.isPending}
|
||||
duplicateProjectId={storedDuplicateProjectId}
|
||||
isDisabled={currentOrg?.blockDuplicateSecretSyncDestinations}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { GenericFieldLabel } from "@app/components/secret-syncs";
|
||||
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
|
||||
import { Badge } from "@app/components/v3";
|
||||
import { useProject } from "@app/context";
|
||||
import { useOrganization, useProject } from "@app/context";
|
||||
import { SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP, SECRET_SYNC_MAP } from "@app/helpers/secretSyncs";
|
||||
import { SecretSync, useDuplicateDestinationCheck } from "@app/hooks/api/secretSyncs";
|
||||
|
||||
@@ -51,6 +51,7 @@ import { ZabbixSyncReviewFields } from "./ZabbixSyncReviewFields";
|
||||
export const SecretSyncReviewFields = () => {
|
||||
const { watch } = useFormContext<TSecretSyncForm>();
|
||||
const { currentProject } = useProject();
|
||||
const { currentOrg } = useOrganization();
|
||||
|
||||
let DestinationFieldsComponent: ReactNode;
|
||||
let AdditionalSyncOptionsFieldsComponent: ReactNode;
|
||||
@@ -193,18 +194,50 @@ export const SecretSyncReviewFields = () => {
|
||||
{isChecking && <span className="text-xs text-mineshaft-400">Checking...</span>}
|
||||
</div>
|
||||
{hasDuplicate && (
|
||||
<div className="mb-2 flex items-start rounded-md border border-yellow-600 bg-yellow-900/20 px-3 py-2">
|
||||
<div className="flex text-sm text-yellow-100">
|
||||
<FontAwesomeIcon icon={faWarning} className="mt-1 mr-2 text-yellow-600" />
|
||||
<div
|
||||
className={`mb-2 flex items-start rounded-md border px-3 py-2 ${
|
||||
currentOrg?.blockDuplicateSecretSyncDestinations
|
||||
? "border-red-600 bg-red-900/20"
|
||||
: "border-yellow-600 bg-yellow-900/20"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex text-sm ${
|
||||
currentOrg?.blockDuplicateSecretSyncDestinations
|
||||
? "text-red-100"
|
||||
: "text-yellow-100"
|
||||
}`}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={faWarning}
|
||||
className={`mt-1 mr-2 ${
|
||||
currentOrg?.blockDuplicateSecretSyncDestinations
|
||||
? "text-red-600"
|
||||
: "text-yellow-600"
|
||||
}`}
|
||||
/>
|
||||
<div>
|
||||
<p>
|
||||
Another secret sync in your organization is already configured with the same
|
||||
destination. This may lead to conflicts or unexpected behavior.
|
||||
{currentOrg?.blockDuplicateSecretSyncDestinations
|
||||
? "Another secret sync in your organization is already configured with the same destination. Your organization does not allow duplicate destination configurations."
|
||||
: "Another secret sync in your organization is already configured with the same destination. This may lead to conflicts or unexpected behavior."}
|
||||
</p>
|
||||
{duplicateProjectId && (
|
||||
<p className="mt-1 text-xs text-yellow-200">
|
||||
<p
|
||||
className={`mt-1 text-xs ${
|
||||
currentOrg?.blockDuplicateSecretSyncDestinations
|
||||
? "text-red-200"
|
||||
: "text-yellow-200"
|
||||
}`}
|
||||
>
|
||||
Duplicate found in project ID:{" "}
|
||||
<code className="rounded-sm bg-yellow-800/50 px-1 py-0.5">
|
||||
<code
|
||||
className={`rounded-sm px-1 py-0.5 ${
|
||||
currentOrg?.blockDuplicateSecretSyncDestinations
|
||||
? "bg-red-800/50"
|
||||
: "bg-yellow-800/50"
|
||||
}`}
|
||||
>
|
||||
{duplicateProjectId}
|
||||
</code>
|
||||
</p>
|
||||
|
||||
@@ -35,7 +35,7 @@ export type TCertificateProfileWithDetails = TCertificateProfile & {
|
||||
apiConfig?: {
|
||||
id: string;
|
||||
autoRenew: boolean;
|
||||
autoRenewDays?: number;
|
||||
renewBeforeDays?: number;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -53,7 +53,7 @@ export type TCreateCertificateProfileDTO = {
|
||||
};
|
||||
apiConfig?: {
|
||||
autoRenew?: boolean;
|
||||
autoRenewDays?: number;
|
||||
renewBeforeDays?: number;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -68,7 +68,7 @@ export type TUpdateCertificateProfileDTO = {
|
||||
};
|
||||
apiConfig?: {
|
||||
autoRenew?: boolean;
|
||||
autoRenewDays?: number;
|
||||
renewBeforeDays?: number;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,2 +1,8 @@
|
||||
export { useDeleteCert, useImportCertificate, useRevokeCert } from "./mutations";
|
||||
export {
|
||||
useDeleteCert,
|
||||
useImportCertificate,
|
||||
useRenewCertificate,
|
||||
useRevokeCert,
|
||||
useUpdateRenewalConfig
|
||||
} from "./mutations";
|
||||
export { useGetCert, useGetCertBody } from "./queries";
|
||||
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
TDeleteCertDTO,
|
||||
TImportCertificateDTO,
|
||||
TImportCertificateResponse,
|
||||
TRevokeCertDTO
|
||||
TRenewCertificateDTO,
|
||||
TRenewCertificateResponse,
|
||||
TRevokeCertDTO,
|
||||
TUpdateRenewalConfigDTO
|
||||
} from "./types";
|
||||
|
||||
export const useDeleteCert = () => {
|
||||
@@ -77,3 +80,57 @@ export const useImportCertificate = () => {
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useRenewCertificate = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<TRenewCertificateResponse, object, TRenewCertificateDTO>({
|
||||
mutationFn: async ({ certificateId }) => {
|
||||
const { data } = await apiRequest.post<TRenewCertificateResponse>(
|
||||
`/api/v3/certificates/${certificateId}/renew`,
|
||||
{}
|
||||
);
|
||||
return data;
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["certificate-profiles", "list"]
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: pkiSubscriberKeys.allPkiSubscriberCertificates()
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: projectKeys.allProjectCertificates()
|
||||
});
|
||||
if (data.projectId) {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: projectKeys.forProjectCertificates(data.projectId)
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateRenewalConfig = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<
|
||||
{ message: string; renewBeforeDays?: number },
|
||||
object,
|
||||
TUpdateRenewalConfigDTO
|
||||
>({
|
||||
mutationFn: async ({ certificateId, renewBeforeDays, enableAutoRenewal }) => {
|
||||
const { data } = await apiRequest.patch<{ message: string; renewBeforeDays?: number }>(
|
||||
`/api/v3/certificates/${certificateId}/config`,
|
||||
{ renewBeforeDays, enableAutoRenewal }
|
||||
);
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_, { projectSlug }) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: projectKeys.forProjectCertificates(projectSlug)
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: projectKeys.allProjectCertificates()
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ export type TCertificate = {
|
||||
id: string;
|
||||
caId: string;
|
||||
certificateTemplateId?: string;
|
||||
profileId?: string;
|
||||
status: CertStatus;
|
||||
friendlyName: string;
|
||||
commonName: string;
|
||||
@@ -13,6 +14,12 @@ export type TCertificate = {
|
||||
notAfter: string;
|
||||
keyUsages: CertKeyUsage[];
|
||||
extendedKeyUsages: CertExtendedKeyUsage[];
|
||||
renewBeforeDays?: number;
|
||||
renewedBy?: string;
|
||||
renewedFromCertificateId?: string;
|
||||
renewedByCertificateId?: string;
|
||||
renewalError?: string;
|
||||
hasPrivateKey?: boolean;
|
||||
};
|
||||
|
||||
export type TDeleteCertDTO = {
|
||||
@@ -43,3 +50,24 @@ export type TImportCertificateResponse = {
|
||||
privateKey: string;
|
||||
serialNumber: string;
|
||||
};
|
||||
|
||||
export type TRenewCertificateDTO = {
|
||||
certificateId: string;
|
||||
};
|
||||
|
||||
export type TRenewCertificateResponse = {
|
||||
certificate: string;
|
||||
issuingCaCertificate: string;
|
||||
certificateChain: string;
|
||||
privateKey?: string;
|
||||
serialNumber: string;
|
||||
certificateId: string;
|
||||
projectId: string;
|
||||
};
|
||||
|
||||
export type TUpdateRenewalConfigDTO = {
|
||||
certificateId: string;
|
||||
renewBeforeDays?: number;
|
||||
enableAutoRenewal?: boolean;
|
||||
projectSlug: string;
|
||||
};
|
||||
|
||||
@@ -125,7 +125,8 @@ export const useUpdateOrg = () => {
|
||||
scannerProductEnabled,
|
||||
shareSecretsProductEnabled,
|
||||
maxSharedSecretLifetime,
|
||||
maxSharedSecretViewLimit
|
||||
maxSharedSecretViewLimit,
|
||||
blockDuplicateSecretSyncDestinations
|
||||
}) => {
|
||||
return apiRequest.patch(`/api/v1/organization/${orgId}`, {
|
||||
name,
|
||||
@@ -146,7 +147,8 @@ export const useUpdateOrg = () => {
|
||||
scannerProductEnabled,
|
||||
shareSecretsProductEnabled,
|
||||
maxSharedSecretLifetime,
|
||||
maxSharedSecretViewLimit
|
||||
maxSharedSecretViewLimit,
|
||||
blockDuplicateSecretSyncDestinations
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
|
||||
@@ -29,6 +29,7 @@ export type Organization = {
|
||||
shareSecretsProductEnabled: boolean;
|
||||
maxSharedSecretLifetime: number;
|
||||
maxSharedSecretViewLimit: number | null;
|
||||
blockDuplicateSecretSyncDestinations: boolean;
|
||||
};
|
||||
|
||||
export type UpdateOrgDTO = {
|
||||
@@ -52,6 +53,7 @@ export type UpdateOrgDTO = {
|
||||
shareSecretsProductEnabled?: boolean;
|
||||
maxSharedSecretViewLimit?: number | null;
|
||||
maxSharedSecretLifetime?: number;
|
||||
blockDuplicateSecretSyncDestinations?: boolean;
|
||||
};
|
||||
|
||||
export type BillingDetails = {
|
||||
|
||||
@@ -18,7 +18,10 @@ export const PamLayout = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (subscription && !subscription.pam) {
|
||||
handlePopUpOpen("upgradePlan");
|
||||
handlePopUpOpen("upgradePlan", {
|
||||
description: "You can use PAM if you switch to Infisical's Enterprise plan.",
|
||||
isEnterpriseFeature: true
|
||||
});
|
||||
}
|
||||
}, [subscription]);
|
||||
|
||||
@@ -111,7 +114,8 @@ export const PamLayout = () => {
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("upgradePlan", isOpen);
|
||||
}}
|
||||
text="You can use PAM if you switch to a paid Infisical plan."
|
||||
text={popUp.upgradePlan.data?.description}
|
||||
isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -54,6 +54,7 @@ export const EncryptionPageForm = () => {
|
||||
|
||||
if (!subscription.hsm) {
|
||||
handlePopUpOpen("upgradePlan", {
|
||||
isEnterpriseFeature: true,
|
||||
description: "Hardware Security Module's (HSM's), are only available on Enterprise plans."
|
||||
});
|
||||
return;
|
||||
@@ -144,6 +145,7 @@ export const EncryptionPageForm = () => {
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text={(popUp.upgradePlan?.data as { description: string })?.description}
|
||||
isEnterpriseFeature={popUp.upgradePlan?.data?.isEnterpriseFeature}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2";
|
||||
import { useProject } from "@app/context";
|
||||
import { useUpdateRenewalConfig } from "@app/hooks/api";
|
||||
import { useGetCertificateProfileById } from "@app/hooks/api/certificateProfiles";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
const DEFAULT_RENEWAL_BEFORE_DAYS = 20;
|
||||
const MIN_RENEWAL_BEFORE_DAYS = 1;
|
||||
const MAX_RENEWAL_BEFORE_DAYS = 30;
|
||||
|
||||
const createFormSchema = (ttlDays: number, notAfter: string) =>
|
||||
z.object({
|
||||
renewBeforeDays: z
|
||||
.number()
|
||||
.min(MIN_RENEWAL_BEFORE_DAYS, `Renewal days must be at least ${MIN_RENEWAL_BEFORE_DAYS}`)
|
||||
.max(MAX_RENEWAL_BEFORE_DAYS, `Renewal days cannot exceed ${MAX_RENEWAL_BEFORE_DAYS}`)
|
||||
.refine(
|
||||
(value) => value < ttlDays,
|
||||
(value) => ({
|
||||
message: `Renewal days (${value}) must be less than certificate TTL (${ttlDays} days)`
|
||||
})
|
||||
)
|
||||
.refine(
|
||||
(value) => {
|
||||
const expiryDate = new Date(notAfter);
|
||||
const renewalDate = new Date(expiryDate.getTime() - value * 24 * 60 * 60 * 1000);
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(0, 0, 0, 0);
|
||||
return renewalDate >= tomorrow;
|
||||
},
|
||||
() => ({
|
||||
message: "Renewals can only be scheduled from tomorrow onwards."
|
||||
})
|
||||
)
|
||||
});
|
||||
|
||||
type FormData = z.infer<ReturnType<typeof createFormSchema>>;
|
||||
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["manageRenewal"]>;
|
||||
handlePopUpToggle: (popUpName: keyof UsePopUpState<["manageRenewal"]>, state?: boolean) => void;
|
||||
};
|
||||
|
||||
const RenewalConfigForm = ({
|
||||
control,
|
||||
errors,
|
||||
onSubmit,
|
||||
isLoading,
|
||||
buttonText,
|
||||
onCancel
|
||||
}: {
|
||||
control: any;
|
||||
errors: { renewBeforeDays?: { message?: string } };
|
||||
onSubmit: (e?: React.BaseSyntheticEvent) => Promise<void>;
|
||||
isLoading: boolean;
|
||||
buttonText: string;
|
||||
onCancel: () => void;
|
||||
}) => (
|
||||
<form onSubmit={onSubmit}>
|
||||
<FormControl
|
||||
label="Auto-renew days before expiry"
|
||||
isError={Boolean(errors.renewBeforeDays)}
|
||||
errorText={errors.renewBeforeDays?.message}
|
||||
className="mb-6"
|
||||
>
|
||||
<Controller
|
||||
control={control}
|
||||
name="renewBeforeDays"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={MIN_RENEWAL_BEFORE_DAYS}
|
||||
max={MAX_RENEWAL_BEFORE_DAYS}
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value, 10);
|
||||
field.onChange(value);
|
||||
}}
|
||||
placeholder="Enter days before expiration"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button type="button" colorSchema="secondary" variant="plain" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" colorSchema="primary" isLoading={isLoading} isDisabled={isLoading}>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
export const CertificateManageRenewalModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
const { currentProject } = useProject();
|
||||
const { mutateAsync: updateRenewalConfig, isPending: isUpdatingConfig } =
|
||||
useUpdateRenewalConfig();
|
||||
|
||||
const certificateData = popUp.manageRenewal.data as {
|
||||
certificateId: string;
|
||||
commonName: string;
|
||||
profileId: string;
|
||||
renewBeforeDays?: number;
|
||||
ttlDays?: number;
|
||||
notAfter: string;
|
||||
renewalError?: string;
|
||||
renewedFromCertificateId?: string;
|
||||
renewedByCertificateId?: string;
|
||||
};
|
||||
|
||||
const { data: profileData } = useGetCertificateProfileById({
|
||||
profileId: certificateData?.profileId || ""
|
||||
});
|
||||
|
||||
const defaultRenewalDays = useMemo(() => {
|
||||
if (certificateData?.renewBeforeDays) {
|
||||
return certificateData.renewBeforeDays;
|
||||
}
|
||||
if (profileData?.apiConfig?.renewBeforeDays) {
|
||||
return profileData.apiConfig.renewBeforeDays;
|
||||
}
|
||||
return DEFAULT_RENEWAL_BEFORE_DAYS;
|
||||
}, [certificateData?.renewBeforeDays, profileData?.apiConfig?.renewBeforeDays]);
|
||||
|
||||
const isAutoRenewalEnabled = Boolean(
|
||||
certificateData?.renewBeforeDays && certificateData.renewBeforeDays > 0
|
||||
);
|
||||
|
||||
const hasRenewalError = Boolean(certificateData?.renewalError);
|
||||
|
||||
const formSchema = createFormSchema(
|
||||
certificateData?.ttlDays || 365,
|
||||
certificateData?.notAfter || ""
|
||||
);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
reset
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
renewBeforeDays: defaultRenewalDays
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (popUp.manageRenewal.isOpen) {
|
||||
reset({
|
||||
renewBeforeDays: defaultRenewalDays
|
||||
});
|
||||
}
|
||||
}, [popUp.manageRenewal.isOpen, defaultRenewalDays, reset]);
|
||||
|
||||
const onUpdateRenewal = async (data: FormData) => {
|
||||
try {
|
||||
if (!currentProject?.slug) {
|
||||
createNotification({
|
||||
text: "Unable to update auto-renewal: Project not found. Please refresh the page and try again.",
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await updateRenewalConfig({
|
||||
certificateId: certificateData.certificateId,
|
||||
renewBeforeDays: data.renewBeforeDays,
|
||||
projectSlug: currentProject.slug
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: isAutoRenewalEnabled
|
||||
? "Auto-renewal configuration updated successfully"
|
||||
: "Auto-renewal enabled successfully",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
handlePopUpToggle("manageRenewal", false);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: isAutoRenewalEnabled
|
||||
? "Failed to update auto-renewal configuration. Please check your inputs and try again."
|
||||
: "Failed to enable auto-renewal. Please check your inputs and try again.",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getModalTitle = () => {
|
||||
if (hasRenewalError) {
|
||||
return `Fix Auto-Renewal: ${certificateData?.commonName || ""}`;
|
||||
}
|
||||
if (isAutoRenewalEnabled) {
|
||||
return `Manage Auto-Renewal for ${certificateData?.commonName || ""}`;
|
||||
}
|
||||
return `Enable Auto-Renewal for ${certificateData?.commonName || ""}`;
|
||||
};
|
||||
|
||||
if (!certificateData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.manageRenewal?.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("manageRenewal", isOpen);
|
||||
}}
|
||||
>
|
||||
<ModalContent title={getModalTitle()}>
|
||||
{hasRenewalError && (
|
||||
<div className="mb-6 rounded-md border border-red-600 bg-red-900/20 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-1 flex h-5 w-5 items-center justify-center rounded-full bg-red-600">
|
||||
<span className="text-xs font-bold text-white">!</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium text-red-400">Automatic Renewal Failed</h3>
|
||||
<p className="mt-1 text-sm text-red-300">
|
||||
The last automatic renewal attempt failed: {certificateData.renewalError}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-red-300">
|
||||
You can reconfigure auto-renewal below or disable it completely.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!isAutoRenewalEnabled || hasRenewalError) && (
|
||||
<RenewalConfigForm
|
||||
control={control}
|
||||
errors={errors}
|
||||
onSubmit={handleSubmit(onUpdateRenewal)}
|
||||
isLoading={isUpdatingConfig}
|
||||
buttonText={isAutoRenewalEnabled ? "Update Configuration" : "Enable Auto-Renewal"}
|
||||
onCancel={() => handlePopUpToggle("manageRenewal", false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isAutoRenewalEnabled && !hasRenewalError && (
|
||||
<RenewalConfigForm
|
||||
control={control}
|
||||
errors={errors}
|
||||
onSubmit={handleSubmit(onUpdateRenewal)}
|
||||
isLoading={isUpdatingConfig}
|
||||
buttonText="Update Configuration"
|
||||
onCancel={() => handlePopUpToggle("manageRenewal", false)}
|
||||
/>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,178 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2";
|
||||
import { useProject } from "@app/context";
|
||||
import { useUpdateRenewalConfig } from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
const createFormSchema = (ttlDays: number) =>
|
||||
z.object({
|
||||
renewBeforeDays: z
|
||||
.number()
|
||||
.min(1, "Renewal days must be at least 1")
|
||||
.max(365, "Renewal days cannot exceed 365")
|
||||
.refine(
|
||||
(value) => value < ttlDays,
|
||||
(value) => ({
|
||||
message: `Renewal days (${value}) must be less than certificate TTL (${ttlDays} days)`
|
||||
})
|
||||
)
|
||||
});
|
||||
|
||||
type FormData = z.infer<ReturnType<typeof createFormSchema>>;
|
||||
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["configureRenewal"]>;
|
||||
handlePopUpToggle: (
|
||||
popUpName: keyof UsePopUpState<["configureRenewal"]>,
|
||||
state?: boolean
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const CertificateRenewalConfigModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
const { currentProject } = useProject();
|
||||
const { mutateAsync: updateRenewalConfig, isPending: isSubmitting } = useUpdateRenewalConfig();
|
||||
|
||||
const certificateData = popUp.configureRenewal.data as {
|
||||
certificateId: string;
|
||||
commonName: string;
|
||||
profileId: string;
|
||||
renewBeforeDays?: number;
|
||||
ttlDays: number;
|
||||
};
|
||||
|
||||
const formSchema = createFormSchema(certificateData.ttlDays);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
watch
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
renewBeforeDays: certificateData?.renewBeforeDays || 1
|
||||
}
|
||||
});
|
||||
|
||||
const renewBeforeDays = watch("renewBeforeDays");
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
try {
|
||||
if (!currentProject?.slug) {
|
||||
createNotification({
|
||||
text: "Project not found",
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await updateRenewalConfig({
|
||||
certificateId: certificateData.certificateId,
|
||||
renewBeforeDays: data.renewBeforeDays,
|
||||
projectSlug: currentProject.slug
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully updated auto-renewal configuration",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
handlePopUpToggle("configureRenewal", false);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to update auto-renewal configuration",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.configureRenewal?.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("configureRenewal", isOpen);
|
||||
}}
|
||||
>
|
||||
<ModalContent title={`Configure Auto-Renewal: ${certificateData?.commonName || ""}`}>
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="mb-4">
|
||||
<p className="mb-4 text-sm text-mineshaft-300">
|
||||
Configure when this certificate should be automatically renewed. The certificate will
|
||||
be renewed when it has the specified number of days remaining before expiration.
|
||||
</p>
|
||||
|
||||
<div className="mb-4 rounded border bg-mineshaft-800 p-3">
|
||||
<p className="text-sm text-mineshaft-300">
|
||||
<strong>Certificate TTL:</strong> {certificateData?.ttlDays} days
|
||||
</p>
|
||||
<p className="text-sm text-mineshaft-300">
|
||||
<strong>Current Setting:</strong>{" "}
|
||||
{certificateData?.renewBeforeDays
|
||||
? `${certificateData.renewBeforeDays} days before expiration`
|
||||
: "Disabled"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Controller
|
||||
control={control}
|
||||
name="renewBeforeDays"
|
||||
render={({ field }) => (
|
||||
<FormControl
|
||||
label="Renew Before Days"
|
||||
isError={Boolean(errors.renewBeforeDays)}
|
||||
errorText={errors.renewBeforeDays?.message}
|
||||
>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={1}
|
||||
max={certificateData?.ttlDays ? certificateData.ttlDays - 1 : undefined}
|
||||
placeholder="Enter days before expiration"
|
||||
onChange={(e) => {
|
||||
const value = parseInt(e.target.value, 10);
|
||||
field.onChange(Number.isNaN(value) ? 0 : value);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
{renewBeforeDays && certificateData?.ttlDays && (
|
||||
<div className="mt-2 rounded bg-primary-900/20 p-2">
|
||||
<p className="text-sm text-primary-300">
|
||||
{renewBeforeDays >= certificateData.ttlDays
|
||||
? "⚠️ Renewal days must be less than certificate TTL"
|
||||
: `✓ Certificate will be renewed ${renewBeforeDays} days before expiration`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting || renewBeforeDays >= (certificateData?.ttlDays || 0)}
|
||||
>
|
||||
Update Configuration
|
||||
</Button>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
variant="plain"
|
||||
onClick={() => handlePopUpToggle("configureRenewal", false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, Modal, ModalContent } from "@app/components/v2";
|
||||
import { useProject } from "@app/context";
|
||||
import { useUpdateRenewalConfig } from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["disableRenewal"]>;
|
||||
handlePopUpToggle: (popUpName: keyof UsePopUpState<["disableRenewal"]>, state?: boolean) => void;
|
||||
};
|
||||
|
||||
export const CertificateRenewalDisableModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
const { currentProject } = useProject();
|
||||
const { mutateAsync: updateRenewalConfig, isPending: isSubmitting } = useUpdateRenewalConfig();
|
||||
|
||||
const certificateData = popUp.disableRenewal.data as {
|
||||
certificateId: string;
|
||||
commonName: string;
|
||||
};
|
||||
|
||||
const onDisableConfirm = async () => {
|
||||
try {
|
||||
if (!currentProject?.slug) {
|
||||
createNotification({
|
||||
text: "Project not found",
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await updateRenewalConfig({
|
||||
certificateId: certificateData.certificateId,
|
||||
projectSlug: currentProject.slug,
|
||||
enableAutoRenewal: false
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Successfully disabled auto-renewal",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
handlePopUpToggle("disableRenewal", false);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to disable auto-renewal",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.disableRenewal?.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("disableRenewal", isOpen);
|
||||
}}
|
||||
>
|
||||
<ModalContent title={`Disable Auto-Renewal: ${certificateData?.commonName || ""}`}>
|
||||
<div className="mb-4">
|
||||
<p className="mb-3 text-sm text-mineshaft-300">
|
||||
Are you sure you want to disable auto-renewal for this certificate?
|
||||
</p>
|
||||
<div className="rounded border border-yellow-700/50 bg-yellow-900/20 p-3">
|
||||
<p className="text-sm text-yellow-300">
|
||||
<strong>Warning:</strong> Once disabled, this certificate will not be automatically
|
||||
renewed and may expire without notice. You can re-enable auto-renewal at any time.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
className="mr-4"
|
||||
size="sm"
|
||||
colorSchema="danger"
|
||||
onClick={onDisableConfirm}
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
Disable Auto-Renewal
|
||||
</Button>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
variant="plain"
|
||||
onClick={() => handlePopUpToggle("disableRenewal", false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import { faRedo } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { Button, Modal, ModalContent } from "@app/components/v2";
|
||||
import { useRenewCertificate } from "@app/hooks/api";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
type Props = {
|
||||
popUp: UsePopUpState<["renewCertificate"]>;
|
||||
handlePopUpToggle: (
|
||||
popUpName: keyof UsePopUpState<["renewCertificate"]>,
|
||||
state?: boolean
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const CertificateRenewalModal = ({ popUp, handlePopUpToggle }: Props) => {
|
||||
const { mutateAsync: renewCertificate, isPending: isRenewing } = useRenewCertificate();
|
||||
|
||||
const onRenewConfirm = async () => {
|
||||
try {
|
||||
const { certificateId } = popUp.renewCertificate.data as { certificateId: string };
|
||||
|
||||
await renewCertificate({
|
||||
certificateId
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: "Certificate renewed successfully",
|
||||
type: "success"
|
||||
});
|
||||
|
||||
handlePopUpToggle("renewCertificate", false);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const certificateData = popUp.renewCertificate.data as {
|
||||
certificateId: string;
|
||||
commonName: string;
|
||||
profileId: string;
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={popUp?.renewCertificate?.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("renewCertificate", isOpen);
|
||||
}}
|
||||
>
|
||||
<ModalContent title={`Renew Certificate: ${certificateData?.commonName || ""}`}>
|
||||
<div className="mb-6">
|
||||
<p className="mb-4 text-sm text-mineshaft-300">
|
||||
Are you sure you want to renew this certificate now?
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
onClick={onRenewConfirm}
|
||||
colorSchema="primary"
|
||||
isLoading={isRenewing}
|
||||
isDisabled={isRenewing}
|
||||
>
|
||||
<FontAwesomeIcon icon={faRedo} className="mr-2" />
|
||||
Renew Now
|
||||
</Button>
|
||||
<Button
|
||||
colorSchema="secondary"
|
||||
variant="plain"
|
||||
onClick={() => handlePopUpToggle("renewCertificate", false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -104,6 +104,7 @@ export const CertificateTemplatesSection = ({ caId }: Props) => {
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature}
|
||||
text="Managing template enrollment options for EST is only available on Infisical's Enterprise plan."
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -36,6 +36,7 @@ type Props = {
|
||||
data?: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
isEnterpriseFeature?: boolean;
|
||||
}
|
||||
) => void;
|
||||
};
|
||||
@@ -90,7 +91,9 @@ export const CertificateTemplatesTable = ({ handlePopUpOpen, caId }: Props) => {
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (!subscription?.pkiEst) {
|
||||
handlePopUpOpen("upgradePlan");
|
||||
handlePopUpOpen("upgradePlan", {
|
||||
isEnterpriseFeature: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,9 @@ import { usePopUp } from "@app/hooks/usePopUp";
|
||||
import { CertificateCertModal } from "./CertificateCertModal";
|
||||
import { CertificateImportModal } from "./CertificateImportModal";
|
||||
import { CertificateIssuanceModal } from "./CertificateIssuanceModal";
|
||||
import { CertificateManageRenewalModal } from "./CertificateManageRenewalModal";
|
||||
import { CertificateModal } from "./CertificateModal";
|
||||
import { CertificateRenewalModal } from "./CertificateRenewalModal";
|
||||
import { CertificateRevocationModal } from "./CertificateRevocationModal";
|
||||
import { CertificatesTable } from "./CertificatesTable";
|
||||
|
||||
@@ -33,7 +35,9 @@ export const CertificatesSection = () => {
|
||||
"certificateImport",
|
||||
"certificateCert",
|
||||
"deleteCertificate",
|
||||
"revokeCertificate"
|
||||
"revokeCertificate",
|
||||
"manageRenewal",
|
||||
"renewCertificate"
|
||||
] as const);
|
||||
|
||||
const onRemoveCertificateSubmit = async (serialNumber: string) => {
|
||||
@@ -98,6 +102,8 @@ export const CertificatesSection = () => {
|
||||
)}
|
||||
<CertificateImportModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<CertificateCertModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<CertificateManageRenewalModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<CertificateRenewalModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<CertificateRevocationModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteCertificate.isOpen}
|
||||
|
||||
@@ -5,12 +5,15 @@ import {
|
||||
faEllipsis,
|
||||
faEye,
|
||||
faFileExport,
|
||||
faQuestionCircle,
|
||||
faRedo,
|
||||
faTrash
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { format } from "date-fns";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -33,25 +36,136 @@ import { Badge } from "@app/components/v3";
|
||||
import {
|
||||
ProjectPermissionCertificateActions,
|
||||
ProjectPermissionSub,
|
||||
useProject
|
||||
useProject,
|
||||
useSubscription
|
||||
} from "@app/context";
|
||||
import { useListWorkspaceCertificates } from "@app/hooks/api";
|
||||
import { useListWorkspaceCertificates, useUpdateRenewalConfig } from "@app/hooks/api";
|
||||
import { caSupportsCapability } from "@app/hooks/api/ca/constants";
|
||||
import { CaCapability, CaType } from "@app/hooks/api/ca/enums";
|
||||
import { useListCasByProjectId } from "@app/hooks/api/ca/queries";
|
||||
import { CertStatus } from "@app/hooks/api/certificates/enums";
|
||||
import { TCertificate } from "@app/hooks/api/certificates/types";
|
||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||
|
||||
import { getCertValidUntilBadgeDetails } from "./CertificatesTable.utils";
|
||||
|
||||
const isExpiringWithinOneDay = (notAfter: string): boolean => {
|
||||
const expiryDate = new Date(notAfter);
|
||||
const now = new Date();
|
||||
const oneDayFromNow = new Date(now.getTime() + 24 * 60 * 60 * 1000);
|
||||
return expiryDate <= oneDayFromNow;
|
||||
};
|
||||
|
||||
const getAutoRenewalInfo = (certificate: TCertificate) => {
|
||||
if (certificate.renewedByCertificateId) {
|
||||
return { text: "Renewed", variant: "instance" as const };
|
||||
}
|
||||
|
||||
const isRevoked = certificate.status === CertStatus.REVOKED;
|
||||
const isExpired = new Date(certificate.notAfter) < new Date();
|
||||
const hasNoProfile = !certificate.profileId;
|
||||
const isExpiringWithinDay = isExpiringWithinOneDay(certificate.notAfter);
|
||||
|
||||
if (isRevoked) {
|
||||
return {
|
||||
text: "Not Available",
|
||||
variant: "instance" as const,
|
||||
tooltip: "Renewal is not available for revoked certificates"
|
||||
};
|
||||
}
|
||||
|
||||
if (isExpired) {
|
||||
return {
|
||||
text: "Not Available",
|
||||
variant: "instance" as const,
|
||||
tooltip: "Renewal is not available for expired certificates"
|
||||
};
|
||||
}
|
||||
|
||||
if (hasNoProfile) {
|
||||
return {
|
||||
text: "Not Available",
|
||||
variant: "instance" as const,
|
||||
tooltip: "Renewal requires a certificate profile"
|
||||
};
|
||||
}
|
||||
|
||||
if (certificate.hasPrivateKey === false) {
|
||||
return {
|
||||
text: "Not Available",
|
||||
variant: "instance" as const,
|
||||
tooltip: "Renewal is not available for certificates with externally generated private keys"
|
||||
};
|
||||
}
|
||||
|
||||
if (isExpiringWithinDay) {
|
||||
return {
|
||||
text: "Not Available",
|
||||
variant: "instance" as const,
|
||||
tooltip: "Auto-renewal is not available for certificates expiring within 24 hours"
|
||||
};
|
||||
}
|
||||
|
||||
if (certificate.renewalError) {
|
||||
return {
|
||||
text: "Failed",
|
||||
variant: "danger" as const,
|
||||
tooltip: certificate.renewalError
|
||||
};
|
||||
}
|
||||
|
||||
if (!certificate.renewBeforeDays) {
|
||||
return { text: "Auto-Renewal Disabled", variant: "primary" as const };
|
||||
}
|
||||
|
||||
const notAfterDate = new Date(certificate.notAfter);
|
||||
const renewalDate = new Date(
|
||||
notAfterDate.getTime() - certificate.renewBeforeDays * 24 * 60 * 60 * 1000
|
||||
);
|
||||
const now = new Date();
|
||||
|
||||
if (renewalDate <= now) {
|
||||
return { text: "Due Now", variant: "danger" as const };
|
||||
}
|
||||
|
||||
const daysUntilRenewal = Math.floor(
|
||||
(renewalDate.getTime() - now.getTime()) / (24 * 60 * 60 * 1000)
|
||||
);
|
||||
|
||||
if (daysUntilRenewal === 0) {
|
||||
return { text: "Renews today", variant: "primary" as const };
|
||||
}
|
||||
|
||||
if (daysUntilRenewal <= 7) {
|
||||
return { text: `Renews in ${daysUntilRenewal}d`, variant: "primary" as const };
|
||||
}
|
||||
|
||||
return { text: `Renews in ${daysUntilRenewal}d`, variant: "success" as const };
|
||||
};
|
||||
|
||||
type Props = {
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<
|
||||
["certificate", "deleteCertificate", "revokeCertificate", "certificateCert"]
|
||||
[
|
||||
"certificate",
|
||||
"deleteCertificate",
|
||||
"revokeCertificate",
|
||||
"certificateCert",
|
||||
"manageRenewal",
|
||||
"renewCertificate"
|
||||
]
|
||||
>,
|
||||
data?: {
|
||||
serialNumber?: string;
|
||||
commonName?: string;
|
||||
certificateId?: string;
|
||||
profileId?: string;
|
||||
renewBeforeDays?: number;
|
||||
ttlDays?: number;
|
||||
notAfter?: string;
|
||||
renewalError?: string;
|
||||
renewedFromCertificateId?: string;
|
||||
renewedByCertificateId?: string;
|
||||
}
|
||||
) => void;
|
||||
};
|
||||
@@ -61,6 +175,7 @@ const PER_PAGE_INIT = 25;
|
||||
export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
|
||||
const [page, setPage] = useState(1);
|
||||
const [perPage, setPerPage] = useState(PER_PAGE_INIT);
|
||||
const { subscription } = useSubscription();
|
||||
|
||||
const { currentProject } = useProject();
|
||||
const { data, isPending } = useListWorkspaceCertificates({
|
||||
@@ -69,10 +184,11 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
|
||||
limit: perPage
|
||||
});
|
||||
|
||||
// Fetch CA data to determine capabilities
|
||||
const { mutateAsync: updateRenewalConfig } = useUpdateRenewalConfig();
|
||||
const isLegacyTemplatesEnabled = subscription.pkiLegacyTemplates;
|
||||
|
||||
const { data: caData } = useListCasByProjectId(currentProject?.id ?? "");
|
||||
|
||||
// Create mapping from caId to CA type for capability checking
|
||||
const caCapabilityMap = useMemo(() => {
|
||||
if (!caData) return {};
|
||||
|
||||
@@ -83,6 +199,35 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
|
||||
return map;
|
||||
}, [caData]);
|
||||
|
||||
const handleDisableAutoRenewal = async (certificateId: string, commonName: string) => {
|
||||
try {
|
||||
if (!currentProject?.slug) {
|
||||
createNotification({
|
||||
text: "Unable to disable auto-renewal: Project not found. Please refresh the page and try again.",
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await updateRenewalConfig({
|
||||
certificateId,
|
||||
projectSlug: currentProject.slug,
|
||||
enableAutoRenewal: false
|
||||
});
|
||||
|
||||
createNotification({
|
||||
text: `Auto-renewal disabled for ${commonName}`,
|
||||
type: "success"
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to disable auto-renewal. Please try again or contact support if the issue persists.",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TableContainer>
|
||||
<Table>
|
||||
@@ -92,14 +237,24 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
|
||||
<Th>Status</Th>
|
||||
<Th>Not Before</Th>
|
||||
<Th>Not After</Th>
|
||||
<Th>Renewal Status</Th>
|
||||
<Th />
|
||||
</Tr>
|
||||
</THead>
|
||||
<TBody>
|
||||
{isPending && <TableSkeleton columns={3} innerKey="project-cas" />}
|
||||
{isPending && <TableSkeleton columns={5} innerKey="project-cas" />}
|
||||
{!isPending &&
|
||||
data?.certificates.map((certificate) => {
|
||||
const { variant, label } = getCertValidUntilBadgeDetails(certificate.notAfter);
|
||||
const autoRenewalInfo = getAutoRenewalInfo(certificate);
|
||||
|
||||
const isRevoked = certificate.status === CertStatus.REVOKED;
|
||||
const isExpired = new Date(certificate.notAfter) < new Date();
|
||||
const isExpiringWithinDay = isExpiringWithinOneDay(certificate.notAfter);
|
||||
const hasFailed = Boolean(certificate.renewalError);
|
||||
const isAutoRenewalEnabled = Boolean(
|
||||
certificate.renewBeforeDays && certificate.renewBeforeDays > 0
|
||||
);
|
||||
return (
|
||||
<Tr className="h-10" key={`certificate-${certificate.id}`}>
|
||||
<Td>{certificate.commonName}</Td>
|
||||
@@ -120,6 +275,25 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
|
||||
? format(new Date(certificate.notAfter), "yyyy-MM-dd")
|
||||
: "-"}
|
||||
</Td>
|
||||
<Td>
|
||||
{autoRenewalInfo &&
|
||||
(autoRenewalInfo.tooltip ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={autoRenewalInfo.variant}>
|
||||
{autoRenewalInfo.text}
|
||||
<Tooltip content={autoRenewalInfo.tooltip}>
|
||||
<FontAwesomeIcon
|
||||
icon={faQuestionCircle}
|
||||
className="ml-1 cursor-help text-red-400 hover:text-red-300"
|
||||
size="sm"
|
||||
/>
|
||||
</Tooltip>
|
||||
</Badge>
|
||||
</div>
|
||||
) : (
|
||||
<Badge variant={autoRenewalInfo.variant}>{autoRenewalInfo.text}</Badge>
|
||||
))}
|
||||
</Td>
|
||||
<Td className="flex justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild className="rounded-lg">
|
||||
@@ -151,31 +325,172 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionCertificateActions.Read}
|
||||
a={ProjectPermissionSub.Certificates}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={async () =>
|
||||
handlePopUpOpen("certificate", {
|
||||
serialNumber: certificate.serialNumber
|
||||
})
|
||||
}
|
||||
disabled={!isAllowed}
|
||||
icon={<FontAwesomeIcon icon={faEye} />}
|
||||
{isLegacyTemplatesEnabled && (
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionCertificateActions.Read}
|
||||
a={ProjectPermissionSub.Certificates}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={async () =>
|
||||
handlePopUpOpen("certificate", {
|
||||
serialNumber: certificate.serialNumber
|
||||
})
|
||||
}
|
||||
disabled={!isAllowed}
|
||||
icon={<FontAwesomeIcon icon={faEye} />}
|
||||
>
|
||||
View Details
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
)}
|
||||
{/* Manage auto renewal option - not shown for failed renewals */}
|
||||
{(() => {
|
||||
const canManageRenewal =
|
||||
certificate.profileId &&
|
||||
certificate.hasPrivateKey !== false &&
|
||||
!certificate.renewedByCertificateId &&
|
||||
!isRevoked &&
|
||||
!isExpired &&
|
||||
!hasFailed &&
|
||||
!isExpiringWithinDay;
|
||||
|
||||
if (!canManageRenewal) return null;
|
||||
|
||||
return (
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionCertificateActions.Edit}
|
||||
a={ProjectPermissionSub.Certificates}
|
||||
>
|
||||
View Details
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
{(isAllowed) => {
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
!isAllowed &&
|
||||
"pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={async () => {
|
||||
const notAfterDate = new Date(certificate.notAfter);
|
||||
const notBeforeDate = certificate.notBefore
|
||||
? new Date(certificate.notBefore)
|
||||
: new Date(
|
||||
notAfterDate.getTime() - 365 * 24 * 60 * 60 * 1000
|
||||
);
|
||||
const ttlDays = Math.max(
|
||||
1,
|
||||
Math.ceil(
|
||||
(notAfterDate.getTime() - notBeforeDate.getTime()) /
|
||||
(24 * 60 * 60 * 1000)
|
||||
)
|
||||
);
|
||||
handlePopUpOpen("manageRenewal", {
|
||||
certificateId: certificate.id,
|
||||
commonName: certificate.commonName,
|
||||
profileId: certificate.profileId,
|
||||
renewBeforeDays: certificate.renewBeforeDays,
|
||||
ttlDays,
|
||||
notAfter: certificate.notAfter,
|
||||
renewalError: certificate.renewalError,
|
||||
renewedFromCertificateId:
|
||||
certificate.renewedFromCertificateId,
|
||||
renewedByCertificateId: certificate.renewedByCertificateId
|
||||
});
|
||||
}}
|
||||
disabled={!isAllowed}
|
||||
icon={<FontAwesomeIcon icon={faRedo} />}
|
||||
>
|
||||
{isAutoRenewalEnabled
|
||||
? "Manage auto renewal"
|
||||
: "Enable auto renewal"}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}}
|
||||
</ProjectPermissionCan>
|
||||
);
|
||||
})()}
|
||||
{/* Disable auto renewal option - only shown when auto renewal is active */}
|
||||
{(() => {
|
||||
const canDisableRenewal =
|
||||
certificate.profileId &&
|
||||
certificate.hasPrivateKey !== false &&
|
||||
!certificate.renewedByCertificateId &&
|
||||
!isRevoked &&
|
||||
!isExpired &&
|
||||
!isExpiringWithinDay &&
|
||||
isAutoRenewalEnabled;
|
||||
|
||||
if (!canDisableRenewal) return null;
|
||||
|
||||
return (
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionCertificateActions.Edit}
|
||||
a={ProjectPermissionSub.Certificates}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
!isAllowed &&
|
||||
"pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={async () => {
|
||||
await handleDisableAutoRenewal(
|
||||
certificate.id,
|
||||
certificate.commonName
|
||||
);
|
||||
}}
|
||||
disabled={!isAllowed}
|
||||
icon={<FontAwesomeIcon icon={faBan} />}
|
||||
>
|
||||
Disable auto renewal
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
);
|
||||
})()}
|
||||
{/* Manual renewal action for profile-issued certificates that are not revoked/expired (including failed ones) */}
|
||||
{(() => {
|
||||
const canRenew =
|
||||
certificate.profileId &&
|
||||
certificate.hasPrivateKey !== false &&
|
||||
!certificate.renewedByCertificateId &&
|
||||
!isRevoked &&
|
||||
!isExpired;
|
||||
|
||||
if (!canRenew) return null;
|
||||
|
||||
return (
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionCertificateActions.Edit}
|
||||
a={ProjectPermissionSub.Certificates}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem
|
||||
className={twMerge(
|
||||
!isAllowed &&
|
||||
"pointer-events-none cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={async () => {
|
||||
handlePopUpOpen("renewCertificate", {
|
||||
certificateId: certificate.id,
|
||||
commonName: certificate.commonName
|
||||
});
|
||||
}}
|
||||
disabled={!isAllowed}
|
||||
icon={<FontAwesomeIcon icon={faRedo} />}
|
||||
>
|
||||
Renew Now
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
);
|
||||
})()}
|
||||
{/* Only show revoke button if CA supports revocation */}
|
||||
{(() => {
|
||||
const caType = caCapabilityMap[certificate.caId];
|
||||
// If caId not found in map, assume CA supports revocation to avoid hiding revoke option
|
||||
const supportsRevocation =
|
||||
!caType ||
|
||||
caSupportsCapability(caType, CaCapability.REVOKE_CERTIFICATES);
|
||||
|
||||
@@ -11,6 +11,26 @@ import {
|
||||
mapTemplateSignatureAlgorithmToApi
|
||||
} from "@app/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/certificate-constants";
|
||||
|
||||
const convertTemplateTtlToCertificateTtl = (templateTtl: string): string => {
|
||||
const match = templateTtl.match(/^(\d+)([dmyh])$/);
|
||||
if (!match) return templateTtl;
|
||||
|
||||
const [, value, unit] = match;
|
||||
const numValue = parseInt(value, 10);
|
||||
|
||||
switch (unit) {
|
||||
case "m":
|
||||
return `${numValue * 30}d`;
|
||||
case "y":
|
||||
return `${numValue * 365}d`;
|
||||
case "d":
|
||||
case "h":
|
||||
return templateTtl;
|
||||
default:
|
||||
return templateTtl;
|
||||
}
|
||||
};
|
||||
|
||||
export type TemplateConstraints = {
|
||||
allowedKeyUsages: string[];
|
||||
allowedExtendedKeyUsages: string[];
|
||||
@@ -118,7 +138,7 @@ export const useCertificateTemplate = (
|
||||
|
||||
// Set TTL if available
|
||||
if (templateData.validity?.max) {
|
||||
setValue("ttl", templateData.validity.max);
|
||||
setValue("ttl", convertTemplateTtlToCertificateTtl(templateData.validity.max));
|
||||
}
|
||||
|
||||
// Handle SAN types
|
||||
|
||||
@@ -203,7 +203,9 @@ export const PkiTemplateListPage = () => {
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!subscription.pkiEst) {
|
||||
handlePopUpOpen("estUpgradePlan");
|
||||
handlePopUpOpen("estUpgradePlan", {
|
||||
isEnterpriseFeature: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
handlePopUpOpen("enrollmentOptions", {
|
||||
@@ -296,6 +298,7 @@ export const PkiTemplateListPage = () => {
|
||||
isOpen={popUp.estUpgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("estUpgradePlan", isOpen)}
|
||||
text="You can only configure template enrollment methods if you switch to Infisical's Enterprise plan."
|
||||
isEnterpriseFeature={popUp.estUpgradePlan.data?.isEnterpriseFeature}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { faQuestionCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -13,7 +15,8 @@ import {
|
||||
ModalContent,
|
||||
Select,
|
||||
SelectItem,
|
||||
TextArea
|
||||
TextArea,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { useProject } from "@app/context";
|
||||
import { useListCasByProjectId } from "@app/hooks/api/ca/queries";
|
||||
@@ -67,7 +70,7 @@ const createSchema = z
|
||||
apiConfig: z
|
||||
.object({
|
||||
autoRenew: z.boolean().optional(),
|
||||
autoRenewDays: z.number().min(1).max(365).optional()
|
||||
renewBeforeDays: z.number().min(1).max(365).optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
@@ -115,7 +118,7 @@ const editSchema = z
|
||||
apiConfig: z
|
||||
.object({
|
||||
autoRenew: z.boolean().optional(),
|
||||
autoRenewDays: z.number().min(1).max(365).optional()
|
||||
renewBeforeDays: z.number().min(1).max(365).optional()
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
@@ -183,7 +186,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
|
||||
profile.enrollmentType === "api"
|
||||
? {
|
||||
autoRenew: profile.apiConfig?.autoRenew || false,
|
||||
autoRenewDays: profile.apiConfig?.autoRenewDays || 30
|
||||
renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
@@ -195,7 +198,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
|
||||
certificateTemplateId: "",
|
||||
apiConfig: {
|
||||
autoRenew: false,
|
||||
autoRenewDays: 30
|
||||
renewBeforeDays: 30
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -225,7 +228,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
|
||||
profile.enrollmentType === "api"
|
||||
? {
|
||||
autoRenew: profile.apiConfig?.autoRenew || false,
|
||||
autoRenewDays: profile.apiConfig?.autoRenewDays || 30
|
||||
renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30
|
||||
}
|
||||
: undefined
|
||||
});
|
||||
@@ -389,7 +392,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
|
||||
} else {
|
||||
setValue("apiConfig", {
|
||||
autoRenew: false,
|
||||
autoRenewDays: 30
|
||||
renewBeforeDays: 30
|
||||
});
|
||||
setValue("estConfig", undefined);
|
||||
}
|
||||
@@ -433,7 +436,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
|
||||
setValue("estConfig", undefined);
|
||||
setValue("apiConfig", {
|
||||
autoRenew: false,
|
||||
autoRenewDays: 30
|
||||
renewBeforeDays: 30
|
||||
});
|
||||
}
|
||||
onChange(value);
|
||||
@@ -535,9 +538,18 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
|
||||
name="apiConfig.autoRenew"
|
||||
render={({ field: { value, onChange }, fieldState: { error } }) => (
|
||||
<FormControl isError={Boolean(error)} errorText={error?.message}>
|
||||
<Checkbox id="autoRenew" isChecked={value} onCheckedChange={onChange}>
|
||||
Enable Auto-Renewal
|
||||
</Checkbox>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox id="autoRenew" isChecked={value} onCheckedChange={onChange}>
|
||||
Enable Auto-Renewal By Default
|
||||
</Checkbox>
|
||||
<Tooltip content="If enabled, certificates issued against this profile will auto-renew at specified days before expiration.">
|
||||
<FontAwesomeIcon
|
||||
icon={faQuestionCircle}
|
||||
className="cursor-help text-mineshaft-400 hover:text-mineshaft-300"
|
||||
size="sm"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
@@ -548,10 +560,10 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
|
||||
<div className="mb-4 space-y-4">
|
||||
<Controller
|
||||
control={control}
|
||||
name="apiConfig.autoRenewDays"
|
||||
name="apiConfig.renewBeforeDays"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl
|
||||
label="Auto-Renewal Days"
|
||||
label="Auto-Renewal Days Before Expiration"
|
||||
isError={Boolean(error)}
|
||||
errorText={error?.message}
|
||||
>
|
||||
|
||||
@@ -170,7 +170,9 @@ export const KmipClientTable = () => {
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => {
|
||||
if (subscription && !subscription.kmip) {
|
||||
handlePopUpOpen("upgradePlan");
|
||||
handlePopUpOpen("upgradePlan", {
|
||||
isEnterpriseFeature: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -343,6 +345,7 @@ export const KmipClientTable = () => {
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text="KMIP requires an enterprise plan."
|
||||
isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -28,7 +28,8 @@ export const OrgGroupsSection = () => {
|
||||
if (!subscription?.groups) {
|
||||
handlePopUpOpen("upgradePlan", {
|
||||
description:
|
||||
"You can manage users more efficiently with groups if you upgrade your Infisical plan to an Enterprise license."
|
||||
"You can manage users more efficiently with groups if you upgrade your Infisical plan to an Enterprise license.",
|
||||
isEnterpriseFeature: true
|
||||
});
|
||||
} else {
|
||||
handlePopUpOpen("group");
|
||||
@@ -96,6 +97,7 @@ export const OrgGroupsSection = () => {
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature}
|
||||
text={(popUp.upgradePlan?.data as { description: string })?.description}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -292,7 +292,8 @@ export const IdentityAuthMethodModalContent = ({
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp?.upgradePlan?.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text="You can use IP allowlisting if you switch to Infisical's Pro plan."
|
||||
text={`You can use ${popUp.upgradePlan.data?.featureName ?? "IP allowlisting"} if you switch to Infisical's ${popUp.upgradePlan.data?.isEnterpriseFeature ? "Enterprise" : "Pro"} plan.`}
|
||||
isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -147,7 +147,10 @@ const schema = z
|
||||
export type FormData = z.infer<typeof schema>;
|
||||
|
||||
type Props = {
|
||||
handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void;
|
||||
handlePopUpOpen: (
|
||||
popUpName: keyof UsePopUpState<["upgradePlan"]>,
|
||||
data?: { isEnterpriseFeature?: boolean; featureName?: string }
|
||||
) => void;
|
||||
handlePopUpToggle: (
|
||||
popUpName: keyof UsePopUpState<["identityAuthMethod"]>,
|
||||
state?: boolean
|
||||
@@ -304,7 +307,10 @@ export const IdentityLdapAuthForm = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (!subscription?.ldap) {
|
||||
handlePopUpOpen("upgradePlan");
|
||||
handlePopUpOpen("upgradePlan", {
|
||||
isEnterpriseFeature: true,
|
||||
featureName: "LDAP authentication"
|
||||
});
|
||||
handlePopUpToggle("identityAuthMethod", false);
|
||||
}
|
||||
}, [subscription, handlePopUpOpen, handlePopUpToggle]);
|
||||
|
||||
@@ -146,7 +146,7 @@ export const IdentitySection = withPermission(
|
||||
if (!isMoreIdentitiesAllowed && !isEnterprise) {
|
||||
handlePopUpOpen("upgradePlan", {
|
||||
description:
|
||||
"You can add more identities if you upgrade your Infisical plan."
|
||||
"You can add more identities if you upgrade your Infisical Pro plan."
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -179,7 +179,11 @@ export const IdentitySection = withPermission(
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => {
|
||||
if (subscription && !subscription.machineIdentityAuthTemplates) {
|
||||
handlePopUpOpen("upgradePlan");
|
||||
handlePopUpOpen("upgradePlan", {
|
||||
isEnterpriseFeature: true,
|
||||
description:
|
||||
"You can use Identity Auth Templates if you switch to Infisical's Enterprise plan."
|
||||
});
|
||||
return;
|
||||
}
|
||||
handlePopUpOpen("createTemplate");
|
||||
@@ -249,7 +253,8 @@ export const IdentitySection = withPermission(
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text="You can use Identity Auth Templates if you switch to Infisical's Enterprise plan."
|
||||
text={popUp.upgradePlan.data?.description}
|
||||
isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -76,7 +76,7 @@ export const OrgMembersSection = () => {
|
||||
|
||||
if (!isMoreIdentitiesAllowed && !isEnterprise) {
|
||||
handlePopUpOpen("upgradePlan", {
|
||||
description: "You can add more members if you upgrade your Infisical plan."
|
||||
description: "You can add more members if you switch to Infisical's Pro plan."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -133,7 +133,8 @@ export const OrgMembersTable = ({
|
||||
|
||||
if (isCustomRole && subscription && !subscription?.rbac) {
|
||||
handlePopUpOpen("upgradePlan", {
|
||||
description: "You can assign custom roles to members if you upgrade your Infisical plan."
|
||||
description:
|
||||
"You can assign custom roles to members if you switch to Infisical's Pro plan."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -105,7 +105,7 @@ export const OrgRoleTable = () => {
|
||||
if (isCustomRole && subscription && !subscription?.rbac) {
|
||||
handlePopUpOpen("upgradePlan", {
|
||||
description:
|
||||
"You can set the default org role to a custom role if you upgrade your Infisical plan."
|
||||
"You can set the default org role to a custom role if you switch to Infisical's Pro plan."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -75,7 +75,9 @@ export const AppConnectionsSelect = ({ onSelect, projectType }: Props) => {
|
||||
type="button"
|
||||
onClick={() =>
|
||||
enterprise && !subscription.enterpriseAppConnections
|
||||
? handlePopUpOpen("upgradePlan")
|
||||
? handlePopUpOpen("upgradePlan", {
|
||||
isEnterpriseFeature: true
|
||||
})
|
||||
: onSelect(option.app)
|
||||
}
|
||||
className="group relative flex h-28 cursor-pointer flex-col items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-700 p-4 duration-200 hover:bg-mineshaft-600"
|
||||
@@ -167,6 +169,7 @@ export const AppConnectionsSelect = ({ onSelect, projectType }: Props) => {
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text="You can use every App Connection if you switch to Infisical's Enterprise plan."
|
||||
isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -123,7 +123,7 @@ const LogsSectionComponent = ({
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("upgradePlan", isOpen);
|
||||
}}
|
||||
text="You can use audit logs if you switch to a paid Infisical plan."
|
||||
text="You can use audit logs if you switch to Infisical's Pro plan."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -167,7 +167,7 @@ const LogsSectionComponent = ({
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("upgradePlan", isOpen);
|
||||
}}
|
||||
text="You can use audit logs if you switch to a paid Infisical plan."
|
||||
text="You can use audit logs if you switch to Infisical's Pro plan."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -121,6 +121,7 @@ const Page = () => {
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text={(popUp.upgradePlan?.data as { description: string })?.description}
|
||||
isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature}
|
||||
/>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteIdentity.isOpen}
|
||||
|
||||
@@ -92,7 +92,7 @@ export const ProjectsPage = () => {
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text="You have exceeded the number of projects allowed on the free plan."
|
||||
text="You have exceeded the number of projects allowed on the free plan. You can upgrade to Infisical's Pro plan to add more projects."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -29,7 +29,9 @@ export const AuditLogStreamsTab = withPermission(
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (subscription && !subscription?.auditLogStreams) {
|
||||
handlePopUpOpen("upgradePlan");
|
||||
handlePopUpOpen("upgradePlan", {
|
||||
isEnterpriseFeature: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
handlePopUpOpen("auditLogStreamForm");
|
||||
@@ -56,6 +58,7 @@ export const AuditLogStreamsTab = withPermission(
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text="You can add audit log streams if you switch to Infisical's Enterprise plan."
|
||||
isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -70,7 +70,9 @@ export const LogStreamProviderSelect = ({ onSelect }: Props) => {
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (option.provider === LogProvider.QRadar) {
|
||||
handlePopUpOpen("upgradePlan");
|
||||
handlePopUpOpen("upgradePlan", {
|
||||
isEnterpriseFeature: true
|
||||
});
|
||||
} else {
|
||||
onSelect(option.provider);
|
||||
}
|
||||
@@ -110,6 +112,7 @@ export const LogStreamProviderSelect = ({ onSelect }: Props) => {
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text="This audit log stream provider requires an enterprise license."
|
||||
isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -180,7 +180,9 @@ const OrgConfigSection = ({
|
||||
className="mt-2"
|
||||
onClick={() => {
|
||||
if (subscription && !subscription.kmip) {
|
||||
handlePopUpOpen("upgradePlan");
|
||||
handlePopUpOpen("upgradePlan", {
|
||||
isEnterpriseFeature: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -251,6 +253,7 @@ const OrgConfigSection = ({
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text="KMIP requires an enterprise plan."
|
||||
isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user