merge main

This commit is contained in:
Scott Wilson
2025-10-28 09:59:24 -07:00
165 changed files with 5524 additions and 3409 deletions
@@ -24,6 +24,8 @@ jobs:
- name: Set up chart-testing - name: Set up chart-testing
uses: helm/[email protected] uses: helm/[email protected]
with:
yamale_version: "6.0.0"
- name: Run chart-testing (lint) - name: Run chart-testing (lint)
run: ct lint --config ct.yaml --charts helm-charts/infisical-gateway run: ct lint --config ct.yaml --charts helm-charts/infisical-gateway
@@ -27,6 +27,8 @@ jobs:
- name: Set up chart-testing - name: Set up chart-testing
uses: helm/[email protected] uses: helm/[email protected]
with:
yamale_version: "6.0.0"
- name: Run chart-testing (lint) - name: Run chart-testing (lint)
run: ct lint --config ct.yaml --charts helm-charts/infisical-gateway run: ct lint --config ct.yaml --charts helm-charts/infisical-gateway
+1032 -2040
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -40,10 +40,10 @@
"type:check": "node --max-old-space-size=8192 ./node_modules/.bin/tsc --noEmit", "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: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'", "lint": "node --max-old-space-size=8192 ./node_modules/.bin/eslint 'src/**/*.ts'",
"test:unit": "vitest run -c vitest.unit.config.ts", "test:unit": "vitest run -c vitest.unit.config.mts",
"test:e2e": "vitest run -c vitest.e2e.config.ts --bail=1", "test:e2e": "vitest run -c vitest.e2e.config.mts --bail=1",
"test:e2e-watch": "vitest -c vitest.e2e.config.ts --bail=1", "test:e2e-watch": "vitest -c vitest.e2e.config.mts --bail=1",
"test:e2e-coverage": "vitest run --coverage -c vitest.e2e.config.ts", "test:e2e-coverage": "vitest run --coverage -c vitest.e2e.config.mts",
"generate:component": "tsx ./scripts/create-backend-file.ts", "generate:component": "tsx ./scripts/create-backend-file.ts",
"generate:schema": "tsx ./scripts/generate-schema-types.ts && eslint --fix --ext ts ./src/db/schemas", "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", "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/jsrp": "^0.2.6",
"@types/libsodium-wrappers": "^0.7.13", "@types/libsodium-wrappers": "^0.7.13",
"@types/lodash.isequal": "^4.5.8", "@types/lodash.isequal": "^4.5.8",
"@types/node": "^20.17.30", "@types/node": "^20.19.0",
"@types/nodemailer": "^6.4.14", "@types/nodemailer": "^6.4.14",
"@types/passport-google-oauth20": "^2.0.14", "@types/passport-google-oauth20": "^2.0.14",
"@types/pg": "^8.10.9", "@types/pg": "^8.10.9",
@@ -130,10 +130,10 @@
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsc-alias": "^1.8.8", "tsc-alias": "^1.8.8",
"tsconfig-paths": "^4.2.0", "tsconfig-paths": "^4.2.0",
"tsup": "^8.0.1", "tsup": "^8.5.0",
"tsx": "^4.4.0", "tsx": "^4.4.0",
"typescript": "^5.3.2", "typescript": "^5.3.2",
"vitest": "^1.2.2" "vitest": "^3.0.6"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-elasticache": "^3.637.0", "@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");
});
}
}
+7 -1
View File
@@ -27,7 +27,13 @@ export const CertificatesSchema = z.object({
extendedKeyUsages: z.string().array().nullable().optional(), extendedKeyUsages: z.string().array().nullable().optional(),
projectId: z.string(), projectId: z.string(),
pkiSubscriberId: z.string().uuid().nullable().optional(), 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>; export type TCertificates = z.infer<typeof CertificatesSchema>;
+2 -1
View File
@@ -40,7 +40,8 @@ export const OrganizationsSchema = z.object({
googleSsoAuthEnforced: z.boolean().default(false), googleSsoAuthEnforced: z.boolean().default(false),
googleSsoAuthLastUsed: z.date().nullable().optional(), googleSsoAuthLastUsed: z.date().nullable().optional(),
parentOrgId: z.string().uuid().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>; export type TOrganizations = z.infer<typeof OrganizationsSchema>;
@@ -10,7 +10,7 @@ import { TImmutableDBKeys } from "./models";
export const PkiApiEnrollmentConfigsSchema = z.object({ export const PkiApiEnrollmentConfigsSchema = z.object({
id: z.string().uuid(), id: z.string().uuid(),
autoRenew: z.boolean().default(false).nullable().optional(), autoRenew: z.boolean().default(false).nullable().optional(),
autoRenewDays: z.number().nullable().optional(), renewBeforeDays: z.number().nullable().optional(),
createdAt: z.date(), createdAt: z.date(),
updatedAt: z.date() updatedAt: z.date()
}); });
@@ -2,7 +2,6 @@ import { z } from "zod";
import { DynamicSecretLeasesSchema } from "@app/db/schemas"; import { DynamicSecretLeasesSchema } from "@app/db/schemas";
import { ApiDocsTags, DYNAMIC_SECRET_LEASES } from "@app/lib/api-docs"; import { ApiDocsTags, DYNAMIC_SECRET_LEASES } from "@app/lib/api-docs";
import { daysToMillisecond } from "@app/lib/dates";
import { removeTrailingSlash } from "@app/lib/fn"; import { removeTrailingSlash } from "@app/lib/fn";
import { ms } from "@app/lib/ms"; import { ms } from "@app/lib/ms";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
@@ -32,8 +31,8 @@ export const registerDynamicSecretLeaseRouter = async (server: FastifyZodProvide
const valMs = ms(val); const valMs = ms(val);
if (valMs < 60 * 1000) if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
if (valMs > daysToMillisecond(1)) if (valMs > ms("10y"))
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); 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), path: z.string().trim().default("/").transform(removeTrailingSlash).describe(DYNAMIC_SECRET_LEASES.CREATE.path),
environmentSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.CREATE.environmentSlug), 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); const valMs = ms(val);
if (valMs < 60 * 1000) if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
if (valMs > daysToMillisecond(1)) if (valMs > ms("10y"))
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); 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), projectSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.RENEW.projectSlug),
path: z path: z
@@ -2,7 +2,6 @@ import { z } from "zod";
import { DynamicSecretLeasesSchema } from "@app/db/schemas"; import { DynamicSecretLeasesSchema } from "@app/db/schemas";
import { ApiDocsTags, DYNAMIC_SECRET_LEASES } from "@app/lib/api-docs"; import { ApiDocsTags, DYNAMIC_SECRET_LEASES } from "@app/lib/api-docs";
import { daysToMillisecond } from "@app/lib/dates";
import { removeTrailingSlash } from "@app/lib/fn"; import { removeTrailingSlash } from "@app/lib/fn";
import { ms } from "@app/lib/ms"; import { ms } from "@app/lib/ms";
import { writeLimit } from "@app/server/config/rateLimiter"; import { writeLimit } from "@app/server/config/rateLimiter";
@@ -32,8 +31,8 @@ export const registerKubernetesDynamicSecretLeaseRouter = async (server: Fastify
const valMs = ms(val); const valMs = ms(val);
if (valMs < 60 * 1000) if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be greater than 1min" }); ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be greater than 1min" });
if (valMs > daysToMillisecond(1)) if (valMs > ms("10y"))
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); 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), path: z.string().trim().default("/").transform(removeTrailingSlash).describe(DYNAMIC_SECRET_LEASES.CREATE.path),
environmentSlug: z.string().min(1).describe(DYNAMIC_SECRET_LEASES.CREATE.environmentSlug), 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 { DynamicSecretLeasesSchema } from "@app/db/schemas";
import { DynamicSecretProviderSchema } from "@app/ee/services/dynamic-secret/providers/models"; import { DynamicSecretProviderSchema } from "@app/ee/services/dynamic-secret/providers/models";
import { ApiDocsTags, DYNAMIC_SECRETS } from "@app/lib/api-docs"; import { ApiDocsTags, DYNAMIC_SECRETS } from "@app/lib/api-docs";
import { daysToMillisecond } from "@app/lib/dates";
import { removeTrailingSlash } from "@app/lib/fn"; import { removeTrailingSlash } from "@app/lib/fn";
import { ms } from "@app/lib/ms"; import { ms } from "@app/lib/ms";
import { isValidHandleBarTemplate } from "@app/lib/template/validate-handlebars"; import { isValidHandleBarTemplate } from "@app/lib/template/validate-handlebars";
@@ -60,8 +59,8 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) =>
const valMs = ms(val); const valMs = ms(val);
if (valMs < 60 * 1000) if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
if (valMs > daysToMillisecond(1)) if (valMs > ms("10y"))
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than 10 years" });
}), }),
maxTTL: z maxTTL: z
.string() .string()
@@ -72,8 +71,8 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) =>
const valMs = ms(val); const valMs = ms(val);
if (valMs < 60 * 1000) if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
if (valMs > daysToMillisecond(1)) if (valMs > ms("10y"))
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than 10 years" });
}) })
.nullable(), .nullable(),
path: z.string().describe(DYNAMIC_SECRETS.CREATE.path).trim().default("/").transform(removeTrailingSlash), 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); const valMs = ms(val);
if (valMs < 60 * 1000) if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
if (valMs > daysToMillisecond(1)) if (valMs > ms("10y"))
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than 10 years" });
}), }),
maxTTL: z maxTTL: z
.string() .string()
@@ -142,8 +141,8 @@ export const registerDynamicSecretRouter = async (server: FastifyZodProvider) =>
const valMs = ms(val); const valMs = ms(val);
if (valMs < 60 * 1000) if (valMs < 60 * 1000)
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" }); ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be a greater than 1min" });
if (valMs > daysToMillisecond(1)) if (valMs > ms("10y"))
ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than a day" }); ctx.addIssue({ code: z.ZodIssueCode.custom, message: "TTL must be less than 10 years" });
}) })
.nullable(), .nullable(),
newName: z.string().describe(DYNAMIC_SECRETS.UPDATE.newName).optional(), newName: z.string().describe(DYNAMIC_SECRETS.UPDATE.newName).optional(),
@@ -340,6 +340,8 @@ export enum EventType {
ISSUE_PKI_SUBSCRIBER_CERT = "issue-pki-subscriber-cert", ISSUE_PKI_SUBSCRIBER_CERT = "issue-pki-subscriber-cert",
SIGN_PKI_SUBSCRIBER_CERT = "sign-pki-subscriber-cert", SIGN_PKI_SUBSCRIBER_CERT = "sign-pki-subscriber-cert",
AUTOMATED_RENEW_SUBSCRIBER_CERT = "automated-renew-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", LIST_PKI_SUBSCRIBER_CERTS = "list-pki-subscriber-certs",
GET_SUBSCRIBER_ACTIVE_CERT_BUNDLE = "get-subscriber-active-cert-bundle", GET_SUBSCRIBER_ACTIVE_CERT_BUNDLE = "get-subscriber-active-cert-bundle",
CREATE_KMS = "create-kms", CREATE_KMS = "create-kms",
@@ -367,6 +369,9 @@ export enum EventType {
ISSUE_CERTIFICATE_FROM_PROFILE = "issue-certificate-from-profile", ISSUE_CERTIFICATE_FROM_PROFILE = "issue-certificate-from-profile",
SIGN_CERTIFICATE_FROM_PROFILE = "sign-certificate-from-profile", SIGN_CERTIFICATE_FROM_PROFILE = "sign-certificate-from-profile",
ORDER_CERTIFICATE_FROM_PROFILE = "order-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_CREATE_SLACK_INTEGRATION = "attempt-create-slack-integration",
ATTEMPT_REINSTALL_SLACK_INTEGRATION = "attempt-reinstall-slack-integration", ATTEMPT_REINSTALL_SLACK_INTEGRATION = "attempt-reinstall-slack-integration",
GET_PROJECT_SLACK_CONFIG = "get-project-slack-config", 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 { interface SignPkiSubscriberCert {
type: EventType.SIGN_PKI_SUBSCRIBER_CERT; type: EventType.SIGN_PKI_SUBSCRIBER_CERT;
metadata: { metadata: {
@@ -2720,6 +2748,16 @@ interface OrderCertificateFromProfile {
}; };
} }
interface RenewCertificate {
type: EventType.RENEW_CERTIFICATE;
metadata: {
originalCertificateId: string;
newCertificateId: string;
profileName: string;
commonName: string;
};
}
interface AttemptCreateSlackIntegration { interface AttemptCreateSlackIntegration {
type: EventType.ATTEMPT_CREATE_SLACK_INTEGRATION; type: EventType.ATTEMPT_CREATE_SLACK_INTEGRATION;
metadata: { 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 = export type Event =
| CreateSubOrganizationEvent | CreateSubOrganizationEvent
| UpdateSubOrganizationEvent | UpdateSubOrganizationEvent
@@ -4216,6 +4271,7 @@ export type Event =
| IssueCertificateFromProfile | IssueCertificateFromProfile
| SignCertificateFromProfile | SignCertificateFromProfile
| OrderCertificateFromProfile | OrderCertificateFromProfile
| RenewCertificate
| GetAzureAdCsTemplatesEvent | GetAzureAdCsTemplatesEvent
| AttemptCreateSlackIntegration | AttemptCreateSlackIntegration
| AttemptReinstallSlackIntegration | AttemptReinstallSlackIntegration
@@ -4373,4 +4429,8 @@ export type Event =
| PamResourceGetEvent | PamResourceGetEvent
| PamResourceCreateEvent | PamResourceCreateEvent
| PamResourceUpdateEvent | PamResourceUpdateEvent
| PamResourceDeleteEvent; | PamResourceDeleteEvent
| UpdateCertificateRenewalConfigEvent
| DisableCertificateRenewalConfigEvent
| AutomatedRenewCertificate
| AutomatedRenewCertificateFailed;
@@ -112,7 +112,7 @@ export const dynamicSecretServiceFactory = ({
const existingDynamicSecret = await dynamicSecretDAL.findOne({ name, folderId: folder.id }); const existingDynamicSecret = await dynamicSecretDAL.findOne({ name, folderId: folder.id });
if (existingDynamicSecret) 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 selectedProvider = dynamicSecretProviders[provider.type];
const inputs = await selectedProvider.validateProviderInputs(provider.inputs, { projectId }); const inputs = await selectedProvider.validateProviderInputs(provider.inputs, { projectId });
@@ -265,7 +265,7 @@ export const dynamicSecretServiceFactory = ({
if (newName) { if (newName) {
const existingDynamicSecret = await dynamicSecretDAL.findOne({ name: newName, folderId: folder.id }); const existingDynamicSecret = await dynamicSecretDAL.findOne({ name: newName, folderId: folder.id });
if (existingDynamicSecret) 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 } = const { encryptor: secretManagerEncryptor, decryptor: secretManagerDecryptor } =
await kmsService.createCipherPairWithDataKey({ await kmsService.createCipherPairWithDataKey({
@@ -1517,7 +1517,7 @@ export const secretApprovalRequestServiceFactory = ({
})) }))
); );
if (secrets.length) 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( commits.push(
...createdSecrets.map((createdSecret) => ({ ...createdSecrets.map((createdSecret) => ({
+5 -1
View File
@@ -7,6 +7,7 @@ import https from "https";
import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic-secret-fns"; import { verifyHostInputValidity } from "@app/ee/services/dynamic-secret/dynamic-secret-fns";
import { splitPemChain } from "@app/services/certificate/certificate-fns"; import { splitPemChain } from "@app/services/certificate/certificate-fns";
import { getConfig } from "../config/env";
import { BadRequestError } from "../errors"; import { BadRequestError } from "../errors";
import { GatewayProxyProtocol } from "../gateway/types"; import { GatewayProxyProtocol } from "../gateway/types";
import { logger } from "../logger"; import { logger } from "../logger";
@@ -80,6 +81,8 @@ const createGatewayConnection = async (
gateway: { clientCertificate: string; clientPrivateKey: string; serverCertificateChain: string }, gateway: { clientCertificate: string; clientPrivateKey: string; serverCertificateChain: string },
protocol: GatewayProxyProtocol protocol: GatewayProxyProtocol
): Promise<net.Socket> => { ): Promise<net.Socket> => {
const appCfg = getConfig();
const protocolToAlpn = { const protocolToAlpn = {
[GatewayProxyProtocol.Http]: "infisical-http-proxy", [GatewayProxyProtocol.Http]: "infisical-http-proxy",
[GatewayProxyProtocol.Tcp]: "infisical-tcp-proxy", [GatewayProxyProtocol.Tcp]: "infisical-tcp-proxy",
@@ -94,7 +97,8 @@ const createGatewayConnection = async (
minVersion: "TLSv1.2", minVersion: "TLSv1.2",
maxVersion: "TLSv1.3", maxVersion: "TLSv1.3",
rejectUnauthorized: true, rejectUnauthorized: true,
ALPNProtocols: [protocolToAlpn[protocol]] ALPNProtocols: [protocolToAlpn[protocol]],
checkServerIdentity: appCfg.isDevelopmentMode ? () => undefined : tls.checkServerIdentity
}; };
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
+6
View File
@@ -78,6 +78,7 @@ export enum QueueName {
SecretReminderMigration = "secret-reminder-migration", SecretReminderMigration = "secret-reminder-migration",
UserNotification = "user-notification", UserNotification = "user-notification",
HealthAlert = "health-alert", HealthAlert = "health-alert",
CertificateV3AutoRenewal = "certificate-v3-auto-renewal",
PamAccountRotation = "pam-account-rotation" PamAccountRotation = "pam-account-rotation"
} }
@@ -128,6 +129,7 @@ export enum QueueJobs {
SecretReminderMigration = "secret-reminder-migration", SecretReminderMigration = "secret-reminder-migration",
UserNotification = "user-notification-job", UserNotification = "user-notification-job",
HealthAlert = "health-alert", HealthAlert = "health-alert",
CertificateV3DailyAutoRenewal = "certificate-v3-daily-auto-renewal",
PamAccountRotation = "pam-account-rotation" PamAccountRotation = "pam-account-rotation"
} }
@@ -359,6 +361,10 @@ export type TQueueJobTypes = {
name: QueueJobs.HealthAlert; name: QueueJobs.HealthAlert;
payload: undefined; payload: undefined;
}; };
[QueueName.CertificateV3AutoRenewal]: {
name: QueueJobs.CertificateV3DailyAutoRenewal;
payload: undefined;
};
[QueueName.PamAccountRotation]: { [QueueName.PamAccountRotation]: {
name: QueueJobs.PamAccountRotation; name: QueueJobs.PamAccountRotation;
payload: undefined; payload: undefined;
@@ -138,6 +138,11 @@ export const injectIdentity = fp(
return; 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. // Authentication is handled on a route-level here.
if (req.url.includes("/api/v1/workflow-integrations/microsoft-teams/message-endpoint")) { if (req.url.includes("/api/v1/workflow-integrations/microsoft-teams/message-endpoint")) {
return; return;
+12
View File
@@ -177,6 +177,7 @@ import { certificateTemplateEstConfigDALFactory } from "@app/services/certificat
import { certificateTemplateServiceFactory } from "@app/services/certificate-template/certificate-template-service"; import { certificateTemplateServiceFactory } from "@app/services/certificate-template/certificate-template-service";
import { certificateTemplateV2DALFactory } from "@app/services/certificate-template-v2/certificate-template-v2-dal"; 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 { 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 { certificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service";
import { cmekServiceFactory } from "@app/services/cmek/cmek-service"; import { cmekServiceFactory } from "@app/services/cmek/cmek-service";
import { convertorServiceFactory } from "@app/services/convertor/convertor-service"; import { convertorServiceFactory } from "@app/services/convertor/convertor-service";
@@ -1957,6 +1958,8 @@ export const registerRoutes = async (
secretImportDAL, secretImportDAL,
permissionService, permissionService,
appConnectionService, appConnectionService,
projectDAL,
orgDAL,
folderDAL, folderDAL,
secretSyncQueue, secretSyncQueue,
projectBotService, projectBotService,
@@ -2136,6 +2139,7 @@ export const registerRoutes = async (
const certificateV3Service = certificateV3ServiceFactory({ const certificateV3Service = certificateV3ServiceFactory({
certificateDAL, certificateDAL,
certificateSecretDAL,
certificateAuthorityDAL, certificateAuthorityDAL,
certificateProfileDAL, certificateProfileDAL,
certificateTemplateV2Service, certificateTemplateV2Service,
@@ -2143,6 +2147,13 @@ export const registerRoutes = async (
permissionService permissionService
}); });
const certificateV3Queue = certificateV3QueueServiceFactory({
queueService,
certificateDAL,
certificateV3Service,
auditLogService
});
const certificateEstV3Service = certificateEstV3ServiceFactory({ const certificateEstV3Service = certificateEstV3ServiceFactory({
internalCertificateAuthorityService, internalCertificateAuthorityService,
certificateTemplateV2Service, certificateTemplateV2Service,
@@ -2330,6 +2341,7 @@ export const registerRoutes = async (
await dailyReminderQueueService.startSecretReminderMigrationJob(); await dailyReminderQueueService.startSecretReminderMigrationJob();
await dailyExpiringPkiItemAlert.startSendingAlerts(); await dailyExpiringPkiItemAlert.startSendingAlerts();
await pkiSubscriberQueue.startDailyAutoRenewalJob(); await pkiSubscriberQueue.startDailyAutoRenewalJob();
await certificateV3Queue.init();
await kmsService.startService(hsmStatus); await kmsService.startService(hsmStatus);
await microsoftTeamsService.start(); await microsoftTeamsService.start();
await dynamicSecretQueueService.init(); await dynamicSecretQueueService.init();
@@ -42,7 +42,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
apiConfig: z apiConfig: z
.object({ .object({
autoRenew: z.boolean().default(false), autoRenew: z.boolean().default(false),
autoRenewDays: z.number().min(1).max(365).optional() renewBeforeDays: z.number().min(1).max(30).optional()
}) })
.optional() .optional()
}) })
@@ -150,7 +150,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
.object({ .object({
id: z.string(), id: z.string(),
autoRenew: z.boolean(), autoRenew: z.boolean(),
autoRenewDays: z.number().optional() renewBeforeDays: z.number().optional()
}) })
.optional() .optional()
}).array(), }).array(),
@@ -230,7 +230,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
.object({ .object({
id: z.string(), id: z.string(),
autoRenew: z.boolean(), autoRenew: z.boolean(),
autoRenewDays: z.number().optional() renewBeforeDays: z.number().optional()
}) })
.optional(), .optional(),
metrics: z metrics: z
@@ -355,7 +355,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid
apiConfig: z apiConfig: z
.object({ .object({
autoRenew: z.boolean().default(false), autoRenew: z.boolean().default(false),
autoRenewDays: z.number().min(1).max(365).optional() renewBeforeDays: z.number().min(1).max(30).optional()
}) })
.optional() .optional()
}) })
@@ -323,7 +323,11 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => {
.min(1, "Max Shared Secret view count cannot be lower than 1") .min(1, "Max Shared Secret view count cannot be lower than 1")
.max(1000, "Max Shared Secret view count cannot exceed 1000") .max(1000, "Max Shared Secret view count cannot exceed 1000")
.nullable() .nullable()
.optional(),
blockDuplicateSecretSyncDestinations: z
.boolean()
.optional() .optional()
.describe("Block duplicate secret sync destinations across the organization")
}), }),
response: { response: {
200: z.object({ 200: z.object({
@@ -1200,7 +1200,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
}), }),
response: { response: {
200: z.object({ 200: z.object({
certificates: z.array(CertificatesSchema), certificates: z.array(CertificatesSchema.extend({ hasPrivateKey: z.boolean() })),
totalCount: z.number() totalCount: z.number()
}) })
} }
@@ -18,6 +18,7 @@ import {
CertKeyUsageType, CertKeyUsageType,
CertSubjectAlternativeNameType CertSubjectAlternativeNameType
} from "@app/services/certificate-common/certificate-constants"; } 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 { mapEnumsForValidation } from "@app/services/certificate-common/certificate-utils";
import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators"; import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators";
@@ -84,8 +85,8 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
}) })
) )
.optional(), .optional(),
signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm).optional(), signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm),
keyAlgorithm: z.nativeEnum(CertKeyAlgorithm).optional() keyAlgorithm: z.nativeEnum(CertKeyAlgorithm)
}) })
.refine(validateTtlAndDateFields, { .refine(validateTtlAndDateFields, {
message: message:
@@ -169,9 +170,7 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
.min(1, "TTL cannot be empty") .min(1, "TTL cannot be empty")
.refine((val) => ms(val) > 0, "TTL must be a positive number"), .refine((val) => ms(val) > 0, "TTL must be a positive number"),
notBefore: validateCaDateField.optional(), notBefore: validateCaDateField.optional(),
notAfter: validateCaDateField.optional(), notAfter: validateCaDateField.optional()
signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm).optional(),
keyAlgorithm: z.nativeEnum(CertKeyAlgorithm).optional()
}) })
.refine(validateTtlAndDateFields, { .refine(validateTtlAndDateFields, {
message: message:
@@ -192,6 +191,8 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
}, },
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => { handler: async (req) => {
const certificateRequest = extractCertificateRequestFromCSR(req.body.csr);
const data = await server.services.certificateV3.signCertificateFromProfile({ const data = await server.services.certificateV3.signCertificateFromProfile({
actor: req.permission.type, actor: req.permission.type,
actorId: req.permission.id, actorId: req.permission.id,
@@ -203,9 +204,7 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
ttl: req.body.ttl ttl: req.body.ttl
}, },
notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined, notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined,
notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined, notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined
signatureAlgorithm: req.body.signatureAlgorithm,
keyAlgorithm: req.body.keyAlgorithm
}); });
await server.services.auditLog.createAuditLog({ await server.services.auditLog.createAuditLog({
@@ -217,7 +216,7 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
certificateProfileId: req.body.profileId, certificateProfileId: req.body.profileId,
certificateId: data.certificateId, certificateId: data.certificateId,
profileName: data.profileName, profileName: data.profileName,
commonName: "" commonName: certificateRequest.commonName || ""
} }
} }
}); });
@@ -260,8 +259,8 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
notBefore: validateCaDateField.optional(), notBefore: validateCaDateField.optional(),
notAfter: validateCaDateField.optional(), notAfter: validateCaDateField.optional(),
commonName: validateTemplateRegexField.optional(), commonName: validateTemplateRegexField.optional(),
signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm).optional(), signatureAlgorithm: z.nativeEnum(CertSignatureAlgorithm),
keyAlgorithm: z.nativeEnum(CertKeyAlgorithm).optional() keyAlgorithm: z.nativeEnum(CertKeyAlgorithm)
}) })
.refine(validateTtlAndDateFields, { .refine(validateTtlAndDateFields, {
message: message:
@@ -343,4 +342,145 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) =>
return data; 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 { ForbiddenError, subject } from "@casl/ability";
import * as x509 from "@peculiar/x509"; import * as x509 from "@peculiar/x509";
import slugify from "@sindresorhus/slugify"; import slugify from "@sindresorhus/slugify";
import { Knex } from "knex";
import { ActionProjectType, TableName, TCertificateAuthorities, TCertificateTemplates } from "@app/db/schemas"; import { ActionProjectType, TableName, TCertificateAuthorities, TCertificateTemplates } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { import {
ProjectPermissionActions, ProjectPermissionActions,
ProjectPermissionCertificateActions, ProjectPermissionCertificateActions,
ProjectPermissionCertificateProfileActions,
ProjectPermissionPkiTemplateActions, ProjectPermissionPkiTemplateActions,
ProjectPermissionSub ProjectPermissionSub
} from "@app/ee/services/permission/project-permission"; } from "@app/ee/services/permission/project-permission";
@@ -1180,7 +1182,9 @@ export const internalCertificateAuthorityServiceFactory = ({
extendedKeyUsages, extendedKeyUsages,
signatureAlgorithm, signatureAlgorithm,
keyAlgorithm, keyAlgorithm,
isFromProfile isFromProfile,
internal = false,
tx
}: TIssueCertFromCaDTO) => { }: TIssueCertFromCaDTO) => {
let ca: TCertificateAuthorityWithAssociatedCa | undefined; let ca: TCertificateAuthorityWithAssociatedCa | undefined;
let certificateTemplate: TCertificateTemplates | undefined; let certificateTemplate: TCertificateTemplates | undefined;
@@ -1210,19 +1214,28 @@ export const internalCertificateAuthorityServiceFactory = ({
throw new NotFoundError({ message: `Internal CA with ID '${caId}' not found` }); throw new NotFoundError({ message: `Internal CA with ID '${caId}' not found` });
} }
const { permission } = await permissionService.getProjectPermission({ if (!internal) {
actor, const { permission } = await permissionService.getProjectPermission({
actorId, actor,
projectId: ca.projectId, actorId,
actorAuthMethod, projectId: ca.projectId,
actorOrgId, actorAuthMethod,
actionProjectType: ActionProjectType.CertificateManager actorOrgId,
}); actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan( if (isFromProfile) {
ProjectPermissionCertificateActions.Create, ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionSub.Certificates 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.status !== CaStatus.ACTIVE) throw new BadRequestError({ message: "CA is not active" });
if (!ca.internalCa.activeCaCertId) if (!ca.internalCa.activeCaCertId)
@@ -1473,7 +1486,7 @@ export const internalCertificateAuthorityServiceFactory = ({
plainText: Buffer.from(certificateChainPem) plainText: Buffer.from(certificateChainPem)
}); });
await certificateDAL.transaction(async (tx) => { const executeIssueCertOperations = async (transaction: Knex) => {
const cert = await certificateDAL.create( const cert = await certificateDAL.create(
{ {
caId: (ca as TCertificateAuthorities).id, caId: (ca as TCertificateAuthorities).id,
@@ -1488,9 +1501,11 @@ export const internalCertificateAuthorityServiceFactory = ({
notAfter: notAfterDate, notAfter: notAfterDate,
keyUsages: selectedKeyUsages, keyUsages: selectedKeyUsages,
extendedKeyUsages: selectedExtendedKeyUsages, extendedKeyUsages: selectedExtendedKeyUsages,
projectId: ca!.projectId projectId: ca!.projectId,
keyAlgorithm: effectiveKeyAlgorithm,
signatureAlgorithm: signatureAlgorithm || ca!.internalCa!.keyAlgorithm
}, },
tx transaction
); );
await certificateBodyDAL.create( await certificateBodyDAL.create(
@@ -1499,7 +1514,7 @@ export const internalCertificateAuthorityServiceFactory = ({
encryptedCertificate, encryptedCertificate,
encryptedCertificateChain encryptedCertificateChain
}, },
tx transaction
); );
await certificateSecretDAL.create( await certificateSecretDAL.create(
@@ -1507,7 +1522,7 @@ export const internalCertificateAuthorityServiceFactory = ({
certId: cert.id, certId: cert.id,
encryptedPrivateKey encryptedPrivateKey
}, },
tx transaction
); );
if (collectionId) { if (collectionId) {
@@ -1516,12 +1531,18 @@ export const internalCertificateAuthorityServiceFactory = ({
pkiCollectionId: collectionId, pkiCollectionId: collectionId,
certId: cert.id certId: cert.id
}, },
tx transaction
); );
} }
return cert; return cert;
}); };
if (tx) {
await executeIssueCertOperations(tx);
} else {
await certificateDAL.transaction(executeIssueCertOperations);
}
return { return {
certificate: leafCert.toString("pem"), certificate: leafCert.toString("pem"),
@@ -1593,10 +1614,17 @@ export const internalCertificateAuthorityServiceFactory = ({
actionProjectType: ActionProjectType.CertificateManager actionProjectType: ActionProjectType.CertificateManager
}); });
ForbiddenError.from(permission).throwUnlessCan( if (dto.isFromProfile && dto.profileId) {
ProjectPermissionCertificateActions.Create, ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionSub.Certificates 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.status !== CaStatus.ACTIVE) throw new BadRequestError({ message: "CA is not active" });
@@ -1700,7 +1728,8 @@ export const internalCertificateAuthorityServiceFactory = ({
certificateAuthorityDAL, certificateAuthorityDAL,
certificateAuthoritySecretDAL, certificateAuthoritySecretDAL,
projectDAL, projectDAL,
kmsService kmsService,
signatureAlgorithm: alg
}); });
const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id }); const caCrl = await certificateAuthorityCrlDAL.findOne({ caSecretId: caSecret.id });
@@ -1917,7 +1946,9 @@ export const internalCertificateAuthorityServiceFactory = ({
notAfter: notAfterDate, notAfter: notAfterDate,
keyUsages: selectedKeyUsages, keyUsages: selectedKeyUsages,
extendedKeyUsages: selectedExtendedKeyUsages, extendedKeyUsages: selectedExtendedKeyUsages,
projectId: ca!.projectId projectId: ca!.projectId,
keyAlgorithm: keyAlgorithm || ca!.internalCa!.keyAlgorithm,
signatureAlgorithm: signatureAlgorithm || ca!.internalCa!.keyAlgorithm
}, },
tx tx
); );
@@ -1,3 +1,4 @@
import { Knex } from "knex";
import { z } from "zod"; import { z } from "zod";
import { TCertificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal"; import { TCertificateAuthorityCrlDALFactory } from "@app/ee/services/certificate-authority-crl/certificate-authority-crl-dal";
@@ -139,6 +140,9 @@ export type TIssueCertFromCaDTO = {
signatureAlgorithm?: CertSignatureAlgorithm; signatureAlgorithm?: CertSignatureAlgorithm;
keyAlgorithm?: CertKeyAlgorithm; keyAlgorithm?: CertKeyAlgorithm;
isFromProfile?: boolean; isFromProfile?: boolean;
profileId?: string;
internal?: boolean;
tx?: Knex;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TSignCertFromCaDTO = export type TSignCertFromCaDTO =
@@ -159,6 +163,7 @@ export type TSignCertFromCaDTO =
signatureAlgorithm?: string; signatureAlgorithm?: string;
keyAlgorithm?: string; keyAlgorithm?: string;
isFromProfile?: boolean; isFromProfile?: boolean;
profileId?: string;
} }
| ({ | ({
isInternal: false; isInternal: false;
@@ -177,6 +182,7 @@ export type TSignCertFromCaDTO =
signatureAlgorithm?: string; signatureAlgorithm?: string;
keyAlgorithm?: string; keyAlgorithm?: string;
isFromProfile?: boolean; isFromProfile?: boolean;
profileId?: string;
} & Omit<TProjectPermission, "projectId">); } & Omit<TProjectPermission, "projectId">);
export type TGetCaCertificateTemplatesDTO = { export type TGetCaCertificateTemplatesDTO = {
@@ -175,6 +175,26 @@ export enum CertSignatureAlgorithm {
ECDSA_SHA512 = "ECDSA-SHA512" 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 SAN_TYPE_OPTIONS = Object.values(CertSubjectAlternativeNameType);
export const KEY_USAGE_OPTIONS = Object.values(CertKeyUsageType); export const KEY_USAGE_OPTIONS = Object.values(CertKeyUsageType);
export const EXTENDED_KEY_USAGE_OPTIONS = Object.values(CertExtendedKeyUsageType); export const EXTENDED_KEY_USAGE_OPTIONS = Object.values(CertExtendedKeyUsageType);
@@ -0,0 +1,183 @@
import * as x509 from "@peculiar/x509";
import { BadRequestError } from "@app/lib/errors";
import {
CertExtendedKeyUsageOIDToName,
CertKeyAlgorithm,
CertKeyUsage,
CertSignatureAlgorithm,
mapLegacyAltNameType,
TAltNameMapping,
TAltNameType
} from "../certificate/certificate-types";
import { parseDistinguishedName } from "../certificate-authority/certificate-authority-fns";
import { validateAndMapAltNameType } from "../certificate-authority/certificate-authority-validators";
import { TCertificateRequest } from "../certificate-template-v2/certificate-template-v2-types";
import { mapLegacyExtendedKeyUsageToStandard, mapLegacyKeyUsageToStandard } from "./certificate-constants";
/**
* Extracts certificate request data from a CSR string
* @param csr - The CSR in PEM format
* @returns TCertificateRequest object with parsed CSR data
*/
export const extractCertificateRequestFromCSR = (csr: string): TCertificateRequest => {
const csrObj = new x509.Pkcs10CertificateRequest(csr);
const subject = parseDistinguishedName(csrObj.subject);
const certificateRequest: TCertificateRequest = {
commonName: subject.commonName,
organization: subject.organization,
organizationUnit: subject.ou,
locality: subject.locality,
state: subject.province,
country: subject.country
};
const csrKeyUsageExtension = csrObj.getExtension("2.5.29.15") as x509.KeyUsagesExtension;
if (csrKeyUsageExtension) {
const csrKeyUsages = Object.values(CertKeyUsage).filter(
// eslint-disable-next-line no-bitwise
(keyUsage) => (x509.KeyUsageFlags[keyUsage] & csrKeyUsageExtension.usages) !== 0
);
certificateRequest.keyUsages = csrKeyUsages.map(mapLegacyKeyUsageToStandard);
}
const csrExtendedKeyUsageExtension = csrObj.getExtension("2.5.29.37") as x509.ExtendedKeyUsageExtension;
if (csrExtendedKeyUsageExtension) {
const csrExtendedKeyUsages = csrExtendedKeyUsageExtension.usages.map(
(ekuOid) => CertExtendedKeyUsageOIDToName[ekuOid as string]
);
certificateRequest.extendedKeyUsages = csrExtendedKeyUsages.map(mapLegacyExtendedKeyUsageToStandard);
}
const sanExtension = csrObj.extensions.find((ext) => ext.type === "2.5.29.17");
if (sanExtension) {
const sanNames = new x509.GeneralNames(sanExtension.value);
const altNamesArray: TAltNameMapping[] = sanNames.items
.filter(
(value) =>
value.type === TAltNameType.EMAIL ||
value.type === TAltNameType.DNS ||
value.type === TAltNameType.IP ||
value.type === TAltNameType.URL
)
.map((name): TAltNameMapping => {
const altNameType = validateAndMapAltNameType(name.value);
if (!altNameType) {
throw new BadRequestError({ message: `Invalid altName from CSR: ${name.value}` });
}
return altNameType;
});
certificateRequest.subjectAlternativeNames = altNamesArray.map((altName) => ({
type: mapLegacyAltNameType(altName.type),
value: altName.value
}));
}
return certificateRequest;
};
/**
* Extracts the key algorithm and signature algorithm from a CSR
* @param csr - The CSR in PEM format
* @returns Object containing keyAlgorithm and signatureAlgorithm
*/
export const extractAlgorithmsFromCSR = (csr: string) => {
const csrObj = new x509.Pkcs10CertificateRequest(csr);
// Extract key algorithm from public key
const { publicKey } = csrObj;
let keyAlgorithm: CertKeyAlgorithm;
if (publicKey.algorithm.name === "RSASSA-PKCS1-v1_5") {
const rsaPublicKey = publicKey as unknown as { algorithm: { modulusLength: number } };
const keySize = rsaPublicKey.algorithm.modulusLength;
switch (keySize) {
case 2048:
keyAlgorithm = CertKeyAlgorithm.RSA_2048;
break;
case 3072:
keyAlgorithm = CertKeyAlgorithm.RSA_3072;
break;
case 4096:
keyAlgorithm = CertKeyAlgorithm.RSA_4096;
break;
default:
throw new BadRequestError({
message: `Unsupported RSA key size in CSR: ${keySize}. Supported: 2048, 3072, 4096`
});
}
} else if (publicKey.algorithm.name === "ECDSA") {
const ecPublicKey = publicKey as unknown as { algorithm: { namedCurve: string } };
const { namedCurve } = ecPublicKey.algorithm;
switch (namedCurve) {
case "P-256":
keyAlgorithm = CertKeyAlgorithm.ECDSA_P256;
break;
case "P-384":
keyAlgorithm = CertKeyAlgorithm.ECDSA_P384;
break;
case "P-521":
keyAlgorithm = CertKeyAlgorithm.ECDSA_P521;
break;
default:
throw new BadRequestError({
message: `Unsupported ECDSA curve in CSR: ${namedCurve}. Supported: P-256, P-384, P-521`
});
}
} else {
throw new BadRequestError({
message: `Unsupported key algorithm in CSR: ${publicKey.algorithm.name}. Supported: RSASSA-PKCS1-v1_5, ECDSA`
});
}
const signatureAlgorithm = csrObj.signatureAlgorithm.name;
const hashName = (csrObj.signatureAlgorithm as unknown as { hash?: { name: string } }).hash?.name;
let normalizedSignatureAlg: CertSignatureAlgorithm;
if (signatureAlgorithm === "RSASSA-PKCS1-v1_5") {
switch (hashName) {
case "SHA-256":
normalizedSignatureAlg = CertSignatureAlgorithm.RSA_SHA256;
break;
case "SHA-384":
normalizedSignatureAlg = CertSignatureAlgorithm.RSA_SHA384;
break;
case "SHA-512":
normalizedSignatureAlg = CertSignatureAlgorithm.RSA_SHA512;
break;
default:
throw new BadRequestError({
message: `Unsupported RSA hash algorithm in CSR: ${hashName}. Supported: SHA-256, SHA-384, SHA-512`
});
}
} else if (signatureAlgorithm === "ECDSA") {
switch (hashName) {
case "SHA-256":
normalizedSignatureAlg = CertSignatureAlgorithm.ECDSA_SHA256;
break;
case "SHA-384":
normalizedSignatureAlg = CertSignatureAlgorithm.ECDSA_SHA384;
break;
case "SHA-512":
normalizedSignatureAlg = CertSignatureAlgorithm.ECDSA_SHA512;
break;
default:
throw new BadRequestError({
message: `Unsupported ECDSA hash algorithm in CSR: ${hashName}. Supported: SHA-256, SHA-384, SHA-512`
});
}
} else {
throw new BadRequestError({
message: `Unsupported signature algorithm in CSR: ${signatureAlgorithm}. Supported: RSASSA-PKCS1-v1_5, ECDSA`
});
}
return {
keyAlgorithm,
signatureAlgorithm: normalizedSignatureAlg
};
};
@@ -3,31 +3,15 @@ import * as x509 from "@peculiar/x509";
import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate"; import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate";
import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors";
import { isCertChainValid } from "@app/services/certificate/certificate-fns"; 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 { TCertificateAuthorityCertDALFactory } from "@app/services/certificate-authority/certificate-authority-cert-dal";
import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal"; import { TCertificateAuthorityDALFactory } from "@app/services/certificate-authority/certificate-authority-dal";
import { import { getCaCertChain, getCaCertChains } from "@app/services/certificate-authority/certificate-authority-fns";
getCaCertChain,
getCaCertChains,
parseDistinguishedName
} from "@app/services/certificate-authority/certificate-authority-fns";
import { validateAndMapAltNameType } from "@app/services/certificate-authority/certificate-authority-validators";
import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service";
import { import { extractCertificateRequestFromCSR } from "@app/services/certificate-common/certificate-csr-utils";
mapLegacyExtendedKeyUsageToStandard,
mapLegacyKeyUsageToStandard
} from "@app/services/certificate-common/certificate-constants";
import { mapEnumsForValidation } from "@app/services/certificate-common/certificate-utils"; import { mapEnumsForValidation } from "@app/services/certificate-common/certificate-utils";
import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal";
import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types";
import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; 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 { TEstEnrollmentConfigDALFactory } from "@app/services/enrollment-config/est-enrollment-config-dal";
import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { TKmsServiceFactory } from "@app/services/kms/kms-service";
import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal";
@@ -61,63 +45,6 @@ export const certificateEstV3ServiceFactory = ({
certificateProfileDAL, certificateProfileDAL,
estEnrollmentConfigDAL estEnrollmentConfigDAL
}: TCertificateEstV3ServiceFactoryDep) => { }: 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 ({ const simpleEnrollByProfile = async ({
csr, csr,
profileId, profileId,
@@ -109,7 +109,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estConfigEncryptedCaChain"), db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estConfigEncryptedCaChain"),
db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigId"), db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigId"),
db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigAutoRenew"), 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) .where(`${TableName.PkiCertificateProfile}.id`, id)
.first(); .first();
@@ -132,7 +132,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
? ({ ? ({
id: result.apiConfigId, id: result.apiConfigId,
autoRenew: !!result.apiConfigAutoRenew, autoRenew: !!result.apiConfigAutoRenew,
autoRenewDays: result.apiConfigAutoRenewDays || undefined renewBeforeDays: result.apiConfigRenewBeforeDays || undefined
} as TCertificateProfileWithConfigs["apiConfig"]) } as TCertificateProfileWithConfigs["apiConfig"])
: undefined; : undefined;
@@ -264,7 +264,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estEncryptedCaChain"), db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estEncryptedCaChain"),
db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiId"), db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiId"),
db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenew"), 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) { if (includeMetrics) {
@@ -290,7 +290,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => {
db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estEncryptedCaChain"), db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estEncryptedCaChain"),
db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiId"), db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiId"),
db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenew"), 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(certificates.id) as total_certificates"),
db.raw( db.raw(
'COUNT(CASE WHEN certificates."revokedAt" IS NULL AND certificates."notAfter" > ? THEN 1 END) as active_certificates', '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, id: result.apiId as string,
autoRenew: !!result.apiAutoRenew, autoRenew: !!result.apiAutoRenew,
autoRenewDays: (result.apiAutoRenewDays as number) || undefined renewBeforeDays: (result.apiRenewBeforeDays as number) || undefined
} }
: undefined; : undefined;
@@ -25,7 +25,7 @@ export const createCertificateProfileSchema = z
apiConfig: z apiConfig: z
.object({ .object({
autoRenew: z.boolean().default(false), autoRenew: z.boolean().default(false),
autoRenewDays: z.number().min(1).max(365).optional() renewBeforeDays: z.number().min(1).max(30).optional()
}) })
.optional() .optional()
}) })
@@ -75,7 +75,7 @@ export const updateCertificateProfileSchema = z
apiConfig: z apiConfig: z
.object({ .object({
autoRenew: z.boolean().default(false), autoRenew: z.boolean().default(false),
autoRenewDays: z.number().min(1).max(365).optional() renewBeforeDays: z.number().min(1).max(30).optional()
}) })
.optional() .optional()
}) })
@@ -110,7 +110,7 @@ describe("CertificateProfileService", () => {
apiConfig: { apiConfig: {
id: "api-config-123", id: "api-config-123",
autoRenew: true, autoRenew: true,
autoRenewDays: 30 renewBeforeDays: 30
} }
}; };
@@ -202,7 +202,7 @@ describe("CertificateProfileService", () => {
certificateTemplateId: "template-123", certificateTemplateId: "template-123",
apiConfig: { apiConfig: {
autoRenew: true, autoRenew: true,
autoRenewDays: 30 renewBeforeDays: 30
} }
}; };
@@ -323,7 +323,7 @@ describe("CertificateProfileService", () => {
certificateTemplateId: "template-123", certificateTemplateId: "template-123",
apiConfig: { apiConfig: {
autoRenew: true, autoRenew: true,
autoRenewDays: 30 renewBeforeDays: 30
} }
}; };
@@ -761,7 +761,7 @@ describe("CertificateProfileService", () => {
certificateTemplateId: "template-123", certificateTemplateId: "template-123",
apiConfig: { apiConfig: {
autoRenew: true, autoRenew: true,
autoRenewDays: 30 renewBeforeDays: 30
} }
}; };
@@ -786,7 +786,7 @@ describe("CertificateProfileService", () => {
certificateTemplateId: "template-123", certificateTemplateId: "template-123",
apiConfig: { apiConfig: {
autoRenew: true, autoRenew: true,
autoRenewDays: 7 renewBeforeDays: 7
} }
}; };
@@ -808,7 +808,7 @@ describe("CertificateProfileService", () => {
expect(mockApiEnrollmentConfigDAL.create).toHaveBeenCalledWith( expect(mockApiEnrollmentConfigDAL.create).toHaveBeenCalledWith(
{ {
autoRenew: true, autoRenew: true,
autoRenewDays: 7 renewBeforeDays: 7
}, },
undefined undefined
); );
@@ -225,7 +225,7 @@ export const certificateProfileServiceFactory = ({
const apiConfig = await apiEnrollmentConfigDAL.create( const apiConfig = await apiEnrollmentConfigDAL.create(
{ {
autoRenew: data.apiConfig.autoRenew, autoRenew: data.apiConfig.autoRenew,
autoRenewDays: data.apiConfig.autoRenewDays renewBeforeDays: data.apiConfig.renewBeforeDays
}, },
tx tx
); );
@@ -343,7 +343,7 @@ export const certificateProfileServiceFactory = ({
existingProfile.apiConfigId, existingProfile.apiConfigId,
{ {
autoRenew: apiConfig.autoRenew, autoRenew: apiConfig.autoRenew,
autoRenewDays: apiConfig.autoRenewDays renewBeforeDays: apiConfig.renewBeforeDays
}, },
tx tx
); );
@@ -26,7 +26,7 @@ export type TCertificateProfileUpdate = Omit<TPkiCertificateProfilesUpdate, "enr
}; };
apiConfig?: { apiConfig?: {
autoRenew?: boolean; autoRenew?: boolean;
autoRenewDays?: number; renewBeforeDays?: number;
}; };
}; };
@@ -52,7 +52,7 @@ export type TCertificateProfileWithConfigs = TCertificateProfile & {
apiConfig?: { apiConfig?: {
id: string; id: string;
autoRenew: boolean; autoRenew: boolean;
autoRenewDays?: number; renewBeforeDays?: number;
}; };
metrics?: TCertificateProfileMetrics; metrics?: TCertificateProfileMetrics;
}; };
@@ -762,32 +762,36 @@ export const certificateTemplateV2ServiceFactory = ({
actorId, actorId,
actorAuthMethod, actorAuthMethod,
actorOrgId, actorOrgId,
templateId templateId,
internal = false
}: { }: {
actor: ActorType; actor: ActorType;
actorId: string; actorId: string;
actorAuthMethod: ActorAuthMethod; actorAuthMethod: ActorAuthMethod;
actorOrgId: string; actorOrgId: string;
templateId: string; templateId: string;
internal?: boolean;
}): Promise<TCertificateTemplateV2> => { }): Promise<TCertificateTemplateV2> => {
const template = await certificateTemplateV2DAL.findById(templateId); const template = await certificateTemplateV2DAL.findById(templateId);
if (!template) { if (!template) {
throw new NotFoundError({ message: "Certificate template not found" }); throw new NotFoundError({ message: "Certificate template not found" });
} }
const { permission } = await permissionService.getProjectPermission({ if (!internal) {
actor, const { permission } = await permissionService.getProjectPermission({
actorId, actor,
projectId: template.projectId, actorId,
actorAuthMethod, projectId: template.projectId,
actorOrgId, actorAuthMethod,
actionProjectType: ActionProjectType.CertificateManager actorOrgId,
}); actionProjectType: ActionProjectType.CertificateManager
});
ForbiddenError.from(permission).throwUnlessCan( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionPkiTemplateActions.Read, ProjectPermissionPkiTemplateActions.Read,
ProjectPermissionSub.CertificateTemplates ProjectPermissionSub.CertificateTemplates
); );
}
return template; return template;
}; };
@@ -0,0 +1,163 @@
/* eslint-disable no-await-in-loop */
import { EventType, TAuditLogServiceFactory } from "@app/ee/services/audit-log/audit-log-types";
import { getConfig } from "@app/lib/config/env";
import { logger } from "@app/lib/logger";
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
import { ActorType } from "../auth/auth-type";
import { TCertificateDALFactory } from "../certificate/certificate-dal";
import { CERTIFICATE_RENEWAL_CONFIG } from "../certificate-common/certificate-constants";
import { TCertificateV3ServiceFactory } from "./certificate-v3-service";
type TCertificateV3QueueServiceFactoryDep = {
queueService: TQueueServiceFactory;
certificateDAL: Pick<TCertificateDALFactory, "findCertificatesEligibleForRenewal" | "updateById">;
certificateV3Service: TCertificateV3ServiceFactory;
auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">;
};
export const certificateV3QueueServiceFactory = ({
queueService,
certificateDAL,
certificateV3Service,
auditLogService
}: TCertificateV3QueueServiceFactoryDep) => {
const appCfg = getConfig();
const init = async () => {
if (appCfg.isSecondaryInstance) {
return;
}
await queueService.stopRepeatableJob(
QueueName.CertificateV3AutoRenewal,
QueueJobs.CertificateV3DailyAutoRenewal,
{ pattern: CERTIFICATE_RENEWAL_CONFIG.DAILY_CRON_SCHEDULE, utc: true },
QueueName.CertificateV3AutoRenewal
);
await queueService.startPg<QueueName.CertificateV3AutoRenewal>(
QueueJobs.CertificateV3DailyAutoRenewal,
async () => {
try {
logger.info(`${QueueJobs.CertificateV3DailyAutoRenewal}: queue task started`);
const { QUEUE_BATCH_SIZE } = CERTIFICATE_RENEWAL_CONFIG;
let offset = 0;
let hasMore = true;
let totalCertificatesFound = 0;
let totalCertificatesRenewed = 0;
while (hasMore) {
const certificates = await certificateDAL.findCertificatesEligibleForRenewal({
limit: QUEUE_BATCH_SIZE,
offset
});
if (certificates.length === 0) {
hasMore = false;
break;
}
totalCertificatesFound += certificates.length;
logger.info(
`${QueueJobs.CertificateV3DailyAutoRenewal}: found ${certificates.length} certificates eligible for renewal (batch ${Math.floor(offset / QUEUE_BATCH_SIZE) + 1}, total found so far: ${totalCertificatesFound})`
);
for (const certificate of certificates) {
try {
if (certificate.renewBeforeDays) {
const { MIN_RENEW_BEFORE_DAYS, MAX_RENEW_BEFORE_DAYS } = CERTIFICATE_RENEWAL_CONFIG;
if (
certificate.renewBeforeDays < MIN_RENEW_BEFORE_DAYS ||
certificate.renewBeforeDays > MAX_RENEW_BEFORE_DAYS
) {
// eslint-disable-next-line no-continue
continue;
}
}
await certificateV3Service.renewCertificate({
actor: ActorType.PLATFORM,
actorId: "",
actorAuthMethod: null,
actorOrgId: "",
certificateId: certificate.id,
internal: true
});
totalCertificatesRenewed += 1;
await auditLogService.createAuditLog({
projectId: certificate.projectId,
actor: {
type: ActorType.PLATFORM,
metadata: {}
},
event: {
type: EventType.AUTOMATED_RENEW_CERTIFICATE,
metadata: {
certificateId: certificate.id,
commonName: certificate.commonName || "",
profileId: certificate.profileId!,
renewBeforeDays: certificate.renewBeforeDays?.toString() || "",
profileName: certificate.profileName || ""
}
}
});
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(error, `Failed to renew certificate ${certificate.id}: ${errorMessage}`);
await auditLogService.createAuditLog({
projectId: certificate.projectId,
actor: {
type: ActorType.PLATFORM,
metadata: {}
},
event: {
type: EventType.AUTOMATED_RENEW_CERTIFICATE_FAILED,
metadata: {
certificateId: certificate.id,
commonName: certificate.commonName || "",
profileId: certificate.profileId || "",
renewBeforeDays: certificate.renewBeforeDays?.toString() || "",
profileName: certificate.profileName || "",
error: errorMessage
}
}
});
}
}
offset += QUEUE_BATCH_SIZE;
}
logger.info(
`${QueueJobs.CertificateV3DailyAutoRenewal}: queue task completed. Renewed ${totalCertificatesRenewed} certificates out of ${totalCertificatesFound}`
);
} catch (error) {
logger.error(error, `${QueueJobs.CertificateV3DailyAutoRenewal}: certificate renewal failed`);
throw error;
}
},
{
batchSize: 1,
workerCount: 1,
pollingIntervalSeconds: 60
}
);
await queueService.schedulePg(
QueueJobs.CertificateV3DailyAutoRenewal,
CERTIFICATE_RENEWAL_CONFIG.DAILY_CRON_SCHEDULE,
undefined,
{ tz: "UTC" }
);
};
return {
init
};
};
export type TCertificateV3QueueServiceFactory = ReturnType<typeof certificateV3QueueServiceFactory>;
@@ -7,10 +7,12 @@ import { ForbiddenError } from "@casl/ability";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; 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 { 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 { 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 { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service";
import { import {
CertExtendedKeyUsageType, 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 { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service";
import { ActorType, AuthMethod } from "../auth/auth-type"; import { ActorType, AuthMethod } from "../auth/auth-type";
import {
extractAlgorithmsFromCSR,
extractCertificateRequestFromCSR
} from "../certificate-common/certificate-csr-utils";
import { certificateV3ServiceFactory, TCertificateV3ServiceFactory } from "./certificate-v3-service"; import { certificateV3ServiceFactory, TCertificateV3ServiceFactory } from "./certificate-v3-service";
vi.mock("../certificate-common/certificate-csr-utils", () => ({
extractCertificateRequestFromCSR: vi.fn(),
extractAlgorithmsFromCSR: vi.fn()
}));
describe("CertificateV3Service", () => { describe("CertificateV3Service", () => {
let service: TCertificateV3ServiceFactory; let service: TCertificateV3ServiceFactory;
const mockCertificateDAL: Pick<TCertificateDALFactory, "findOne" | "updateById"> = { const mockCertificateDAL: Pick<TCertificateDALFactory, "findOne" | "findById" | "updateById" | "transaction"> = {
findOne: vi.fn(), 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"> = { const mockCertificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa"> = {
@@ -76,7 +96,7 @@ describe("CertificateV3Service", () => {
beforeEach(() => { beforeEach(() => {
// Reset all mocks before each test // Reset all mocks before each test
vi.clearAllMocks(); vi.resetAllMocks();
// Mock ForbiddenError.from static method // Mock ForbiddenError.from static method
vi.spyOn(ForbiddenError, "from").mockReturnValue({ 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({ service = certificateV3ServiceFactory({
certificateDAL: mockCertificateDAL, certificateDAL: mockCertificateDAL,
certificateSecretDAL: mockCertificateSecretDAL,
certificateAuthorityDAL: mockCertificateAuthorityDAL, certificateAuthorityDAL: mockCertificateAuthorityDAL,
certificateProfileDAL: mockCertificateProfileDAL, certificateProfileDAL: mockCertificateProfileDAL,
certificateTemplateV2Service: mockCertificateTemplateV2Service, certificateTemplateV2Service: mockCertificateTemplateV2Service,
@@ -641,6 +673,11 @@ describe("CertificateV3Service", () => {
vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile); vi.mocked(mockCertificateProfileDAL.findByIdWithConfigs).mockResolvedValue(mockProfile);
vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA); vi.mocked(mockCertificateAuthorityDAL.findByIdWithAssociatedCa).mockResolvedValue(mockCA);
vi.mocked(mockCertificateTemplateV2Service.getTemplateV2ById).mockResolvedValue(mockTemplate); 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(mockInternalCaService.signCertFromCa).mockResolvedValue(mockSignResult as any);
vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(mockCertRecord); vi.mocked(mockCertificateDAL.findOne).mockResolvedValue(mockCertRecord);
vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCertRecord); vi.mocked(mockCertificateDAL.updateById).mockResolvedValue(mockCertRecord);
@@ -1460,4 +1497,714 @@ describe("CertificateV3Service", () => {
).resolves.toBeDefined(); ).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 { ForbiddenError } from "@casl/ability";
import { randomUUID } from "crypto"; import { randomUUID } from "crypto";
import RE2 from "re2";
import { ActionProjectType } from "@app/db/schemas"; import { ActionProjectType } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { import {
ProjectPermissionCertificateActions,
ProjectPermissionCertificateProfileActions, ProjectPermissionCertificateProfileActions,
ProjectPermissionSub ProjectPermissionSub
} from "@app/ee/services/permission/project-permission"; } from "@app/ee/services/permission/project-permission";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type";
import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal";
import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal";
import { import {
CertExtendedKeyUsage,
CertificateOrderStatus, CertificateOrderStatus,
CertKeyAlgorithm, CertKeyAlgorithm,
CertSignatureAlgorithm CertKeyType,
CertKeyUsage,
CertSignatureAlgorithm,
CertStatus
} from "@app/services/certificate/certificate-types"; } from "@app/services/certificate/certificate-types";
import { import {
TCertificateAuthorityDALFactory, TCertificateAuthorityDALFactory,
TCertificateAuthorityWithAssociatedCa TCertificateAuthorityWithAssociatedCa
} from "@app/services/certificate-authority/certificate-authority-dal"; } 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 { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service";
import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal";
import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types";
import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service";
import { CertSubjectAlternativeNameType } from "../certificate-common/certificate-constants"; import { CertSubjectAlternativeNameType } from "../certificate-common/certificate-constants";
import {
extractAlgorithmsFromCSR,
extractCertificateRequestFromCSR
} from "../certificate-common/certificate-csr-utils";
import { import {
bufferToString, bufferToString,
buildCertificateSubjectFromTemplate, buildCertificateSubjectFromTemplate,
buildSubjectAlternativeNamesFromTemplate, buildSubjectAlternativeNamesFromTemplate,
convertExtendedKeyUsageArrayFromLegacy,
convertExtendedKeyUsageArrayToLegacy, convertExtendedKeyUsageArrayToLegacy,
convertKeyUsageArrayFromLegacy,
convertKeyUsageArrayToLegacy, convertKeyUsageArrayToLegacy,
mapEnumsForValidation, mapEnumsForValidation,
normalizeDateForApi normalizeDateForApi
@@ -38,13 +51,19 @@ import {
import { import {
TCertificateFromProfileResponse, TCertificateFromProfileResponse,
TCertificateOrderResponse, TCertificateOrderResponse,
TDisableRenewalConfigDTO,
TDisableRenewalResponse,
TIssueCertificateFromProfileDTO, TIssueCertificateFromProfileDTO,
TOrderCertificateFromProfileDTO, TOrderCertificateFromProfileDTO,
TSignCertificateFromProfileDTO TRenewalConfigResponse,
TRenewCertificateDTO,
TSignCertificateFromProfileDTO,
TUpdateRenewalConfigDTO
} from "./certificate-v3-types"; } from "./certificate-v3-types";
type TCertificateV3ServiceFactoryDep = { type TCertificateV3ServiceFactoryDep = {
certificateDAL: Pick<TCertificateDALFactory, "findOne" | "updateById">; certificateDAL: Pick<TCertificateDALFactory, "findOne" | "findById" | "updateById" | "transaction">;
certificateSecretDAL: Pick<TCertificateSecretDALFactory, "findOne">;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa">; certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "findByIdWithAssociatedCa">;
certificateProfileDAL: Pick<TCertificateProfileDALFactory, "findByIdWithConfigs">; certificateProfileDAL: Pick<TCertificateProfileDALFactory, "findByIdWithConfigs">;
certificateTemplateV2Service: Pick< certificateTemplateV2Service: Pick<
@@ -95,6 +114,77 @@ const validateProfileAndPermissions = async (
return profile; 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 validateCaSupport = (ca: TCertificateAuthorityWithAssociatedCa, operation: string) => {
const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL; const caType = (ca.externalCa?.type as CaType) ?? CaType.INTERNAL;
if (caType !== CaType.INTERNAL) { if (caType !== CaType.INTERNAL) {
@@ -129,11 +219,11 @@ const validateAlgorithmCompatibility = (
const keyType = parts[parts.length - 1]; const keyType = parts[parts.length - 1];
if (caKeyAlgorithm.startsWith("RSA")) { if (caKeyAlgorithm.startsWith("RSA")) {
return keyType === "RSA"; return keyType === CertKeyType.RSA;
} }
if (caKeyAlgorithm.startsWith("EC")) { if (caKeyAlgorithm.startsWith("EC")) {
return keyType === "ECDSA"; return keyType === CertKeyType.ECDSA;
} }
return false; return false;
@@ -155,8 +245,85 @@ const extractCertificateFromBuffer = (certData: Buffer | { rawData: Buffer } | s
return bufferToString(certData as unknown as Buffer); 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 = ({ export const certificateV3ServiceFactory = ({
certificateDAL, certificateDAL,
certificateSecretDAL,
certificateAuthorityDAL, certificateAuthorityDAL,
certificateProfileDAL, certificateProfileDAL,
certificateTemplateV2Service, certificateTemplateV2Service,
@@ -198,7 +365,8 @@ export const certificateV3ServiceFactory = ({
actorId, actorId,
actorAuthMethod, actorAuthMethod,
actorOrgId, actorOrgId,
templateId: profile.certificateTemplateId templateId: profile.certificateTemplateId,
internal: true
}); });
if (!template) { if (!template) {
throw new NotFoundError({ message: "Certificate template not found for this profile" }); throw new NotFoundError({ message: "Certificate template not found for this profile" });
@@ -222,10 +390,6 @@ export const certificateV3ServiceFactory = ({
validateCaSupport(ca, "direct certificate issuance"); validateCaSupport(ca, "direct certificate issuance");
if (!actorAuthMethod) {
throw new BadRequestError({ message: "Authentication method is required for certificate issuance" });
}
validateAlgorithmCompatibility(ca, template); validateAlgorithmCompatibility(ca, template);
const effectiveSignatureAlgorithm = certificateRequest.signatureAlgorithm as CertSignatureAlgorithm | undefined; 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" }); 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 { return {
certificate: bufferToString(certificate), certificate: bufferToString(certificate),
@@ -284,7 +457,8 @@ export const certificateV3ServiceFactory = ({
serialNumber, serialNumber,
certificateId: cert.id, certificateId: cert.id,
projectId: profile.projectId, projectId: profile.projectId,
profileName: profile.slug profileName: profile.slug,
commonName: cert.commonName || ""
}; };
}; };
@@ -294,8 +468,6 @@ export const certificateV3ServiceFactory = ({
validity, validity,
notBefore, notBefore,
notAfter, notAfter,
signatureAlgorithm,
keyAlgorithm,
actor, actor,
actorId, actorId,
actorAuthMethod, actorAuthMethod,
@@ -319,38 +491,40 @@ export const certificateV3ServiceFactory = ({
validateCaSupport(ca, "CSR signing"); validateCaSupport(ca, "CSR signing");
if (!actorAuthMethod) {
throw new BadRequestError({ message: "Authentication method is required for certificate signing" });
}
const template = await certificateTemplateV2Service.getTemplateV2ById({ const template = await certificateTemplateV2Service.getTemplateV2ById({
actor, actor,
actorId, actorId,
actorAuthMethod, actorAuthMethod,
actorOrgId, actorOrgId,
templateId: profile.certificateTemplateId templateId: profile.certificateTemplateId,
internal: true
}); });
if (!template) { if (!template) {
throw new NotFoundError({ message: "Certificate template not found for this profile" }); 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); validateAlgorithmCompatibility(ca, template);
const effectiveSignatureAlgorithm = signatureAlgorithm; const effectiveSignatureAlgorithm = extractedSignatureAlgorithm;
const effectiveKeyAlgorithm = keyAlgorithm; const effectiveKeyAlgorithm = extractedKeyAlgorithm;
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 { certificate, certificateChain, issuingCaCertificate, serialNumber } = const { certificate, certificateChain, issuingCaCertificate, serialNumber } =
await internalCaService.signCertFromCa({ await internalCaService.signCertFromCa({
@@ -371,7 +545,12 @@ export const certificateV3ServiceFactory = ({
throw new NotFoundError({ message: "Certificate was signed but could not be found in database" }); 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 certificateString = extractCertificateFromBuffer(certificate as unknown as Buffer);
const certificateChainString = extractCertificateFromBuffer(certificateChain as unknown as Buffer); const certificateChainString = extractCertificateFromBuffer(certificateChain as unknown as Buffer);
@@ -383,7 +562,8 @@ export const certificateV3ServiceFactory = ({
serialNumber, serialNumber,
certificateId: cert.id, certificateId: cert.id,
projectId: profile.projectId, 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 { return {
issueCertificateFromProfile, issueCertificateFromProfile,
signCertificateFromProfile, signCertificateFromProfile,
orderCertificateFromProfile orderCertificateFromProfile,
renewCertificate,
updateRenewalConfig,
disableRenewalConfig
}; };
}; };
@@ -35,8 +35,6 @@ export type TSignCertificateFromProfileDTO = {
}; };
notBefore?: Date; notBefore?: Date;
notAfter?: Date; notAfter?: Date;
signatureAlgorithm?: string;
keyAlgorithm?: string;
} & Omit<TProjectPermission, "projectId">; } & Omit<TProjectPermission, "projectId">;
export type TOrderCertificateFromProfileDTO = { export type TOrderCertificateFromProfileDTO = {
@@ -68,6 +66,7 @@ export type TCertificateFromProfileResponse = {
certificateId: string; certificateId: string;
projectId: string; projectId: string;
profileName: string; profileName: string;
commonName: string;
}; };
export type TCertificateOrderResponse = { export type TCertificateOrderResponse = {
@@ -97,3 +96,27 @@ export type TCertificateOrderResponse = {
projectId: string; projectId: string;
profileName: 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 { TDbClient } from "@app/db";
import { TableName, TCertificates } from "@app/db/schemas"; import { TableName, TCertificates } from "@app/db/schemas";
import { DatabaseError } from "@app/lib/errors"; import { DatabaseError } from "@app/lib/errors";
import { ormify } from "@app/lib/knex"; import { ormify, selectAllTableCols } from "@app/lib/knex";
import { CertStatus } from "./certificate-types"; 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 { return {
...certificateOrm, ...certificateOrm,
countCertificatesInProject, countCertificatesInProject,
countCertificatesForPkiSubscriber, countCertificatesForPkiSubscriber,
findLatestActiveCertForSubscriber, findLatestActiveCertForSubscriber,
findAllActiveCertsForSubscriber, findAllActiveCertsForSubscriber,
findExpiredSyncedCertificates findExpiredSyncedCertificates,
findCertificatesEligibleForRenewal,
findWithPrivateKeyInfo
}; };
}; };
@@ -21,6 +21,11 @@ export enum CertKeyAlgorithm {
ECDSA_P521 = "EC_secp521r1" ECDSA_P521 = "EC_secp521r1"
} }
export enum CertKeyType {
RSA = "RSA",
ECDSA = "ECDSA"
}
export enum CertSignatureAlgorithm { export enum CertSignatureAlgorithm {
RSA_SHA256 = "RSA-SHA256", RSA_SHA256 = "RSA-SHA256",
RSA_SHA384 = "RSA-SHA384", RSA_SHA384 = "RSA-SHA384",
@@ -69,15 +69,15 @@ export const apiEnrollmentConfigDALFactory = (db: TDbClient) => {
const profiles = await query const profiles = await query
.where((qb) => { .where((qb) => {
void qb void qb
.whereNull(`${TableName.PkiApiEnrollmentConfig}.autoRenewDays`) .whereNull(`${TableName.PkiApiEnrollmentConfig}.renewBeforeDays`)
.orWhere(`${TableName.PkiApiEnrollmentConfig}.autoRenewDays`, "<=", renewalThresholdDays); .orWhere(`${TableName.PkiApiEnrollmentConfig}.renewBeforeDays`, "<=", renewalThresholdDays);
}) })
.select((tx || db).ref("id").withSchema(TableName.PkiCertificateProfile)) .select((tx || db).ref("id").withSchema(TableName.PkiCertificateProfile))
.select((tx || db).ref("name").withSchema(TableName.PkiCertificateProfile)) .select((tx || db).ref("name").withSchema(TableName.PkiCertificateProfile))
.select((tx || db).ref("projectId").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) { } catch (error) {
throw new DatabaseError({ error, name: "Find profiles for auto renewal" }); throw new DatabaseError({ error, name: "Find profiles for auto renewal" });
} }
@@ -25,5 +25,5 @@ export interface TEstConfigData {
export interface TApiConfigData { export interface TApiConfigData {
autoRenew: boolean; autoRenew: boolean;
autoRenewDays?: number; renewBeforeDays?: number;
} }
@@ -82,7 +82,7 @@ export const importDataIntoInfisicalFn = async ({
if (existingEnv) { if (existingEnv) {
throw new BadRequestError({ throw new BadRequestError({
message: `Environment with slug '${slug}' already exist`, message: `Environment with slug '${slug}' already exists`,
name: "CreateEnvironment" name: "CreateEnvironment"
}); });
} }
@@ -312,7 +312,7 @@ export const importDataIntoInfisicalFn = async ({
); );
if (secretsByKeys.length) { if (secretsByKeys.length) {
throw new BadRequestError({ 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({ 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 gatewayIdValue = isGatewayV1 ? gatewayId : null;
const gatewayV2IdValue = isGatewayV1 ? null : gatewayId; const gatewayV2IdValue = isGatewayV1 ? null : gatewayId;
+2 -1
View File
@@ -27,5 +27,6 @@ export const sanitizedOrganizationSchema = OrganizationsSchema.pick({
scannerProductEnabled: true, scannerProductEnabled: true,
shareSecretsProductEnabled: true, shareSecretsProductEnabled: true,
maxSharedSecretLifetime: true, maxSharedSecretLifetime: true,
maxSharedSecretViewLimit: true maxSharedSecretViewLimit: true,
blockDuplicateSecretSyncDestinations: true
}); });
+6 -4
View File
@@ -405,7 +405,8 @@ export const orgServiceFactory = ({
scannerProductEnabled, scannerProductEnabled,
shareSecretsProductEnabled, shareSecretsProductEnabled,
maxSharedSecretLifetime, maxSharedSecretLifetime,
maxSharedSecretViewLimit maxSharedSecretViewLimit,
blockDuplicateSecretSyncDestinations
} }
}: TUpdateOrgDTO) => { }: TUpdateOrgDTO) => {
const appCfg = getConfig(); const appCfg = getConfig();
@@ -516,7 +517,7 @@ export const orgServiceFactory = ({
if (slug) { if (slug) {
const existingOrg = await orgDAL.findOne({ slug, rootOrgId: null }); const existingOrg = await orgDAL.findOne({ slug, rootOrgId: null });
if (existingOrg && existingOrg?.id !== orgId) 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) { if (googleSsoAuthEnforced) {
@@ -589,7 +590,8 @@ export const orgServiceFactory = ({
scannerProductEnabled, scannerProductEnabled,
shareSecretsProductEnabled, shareSecretsProductEnabled,
maxSharedSecretLifetime, maxSharedSecretLifetime,
maxSharedSecretViewLimit maxSharedSecretViewLimit,
blockDuplicateSecretSyncDestinations
}); });
if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` }); if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` });
return org; return org;
@@ -1147,7 +1149,7 @@ export const orgServiceFactory = ({
const doesIncidentContactExist = await incidentContactDAL.findOne(orgId, { email }); const doesIncidentContactExist = await incidentContactDAL.findOne(orgId, { email });
if (doesIncidentContactExist) { if (doesIncidentContactExist) {
throw new BadRequestError({ throw new BadRequestError({
message: "Incident contact already exist", message: "Incident contact already exists",
name: "Incident contact exist" name: "Incident contact exist"
}); });
} }
+1
View File
@@ -90,6 +90,7 @@ export type TUpdateOrgDTO = {
shareSecretsProductEnabled: boolean; shareSecretsProductEnabled: boolean;
maxSharedSecretLifetime: number; maxSharedSecretLifetime: number;
maxSharedSecretViewLimit: number | null; maxSharedSecretViewLimit: number | null;
blockDuplicateSecretSyncDestinations: boolean;
}>; }>;
} & TOrgPermission; } & TOrgPermission;
@@ -76,7 +76,7 @@ export const projectEnvServiceFactory = ({
const existingEnv = envs.find(({ slug: envSlug }) => envSlug === slug); const existingEnv = envs.find(({ slug: envSlug }) => envSlug === slug);
if (existingEnv) if (existingEnv)
throw new BadRequestError({ throw new BadRequestError({
message: "Environment with slug already exist", message: "Environment with slug already exists",
name: "CreateEnvironment" name: "CreateEnvironment"
}); });
@@ -171,7 +171,7 @@ export const projectEnvServiceFactory = ({
const existingEnv = await projectEnvDAL.findOne({ slug, projectId }); const existingEnv = await projectEnvDAL.findOne({ slug, projectId });
if (existingEnv && existingEnv.id !== id) { if (existingEnv && existingEnv.id !== id) {
throw new BadRequestError({ throw new BadRequestError({
message: "Environment with slug already exist", message: "Environment with slug already exists",
name: "UpdateEnvironment" name: "UpdateEnvironment"
}); });
} }
@@ -154,7 +154,7 @@ type TProjectServiceFactoryDep = {
>; >;
pkiSubscriberDAL: Pick<TPkiSubscriberDALFactory, "find">; pkiSubscriberDAL: Pick<TPkiSubscriberDALFactory, "find">;
certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "find" | "findWithAssociatedCa">; certificateAuthorityDAL: Pick<TCertificateAuthorityDALFactory, "find" | "findWithAssociatedCa">;
certificateDAL: Pick<TCertificateDALFactory, "find" | "countCertificatesInProject">; certificateDAL: Pick<TCertificateDALFactory, "find" | "countCertificatesInProject" | "findWithPrivateKeyInfo">;
certificateTemplateDAL: Pick<TCertificateTemplateDALFactory, "getCertTemplatesByProjectId">; certificateTemplateDAL: Pick<TCertificateTemplateDALFactory, "getCertTemplatesByProjectId">;
pkiAlertDAL: Pick<TPkiAlertDALFactory, "find">; pkiAlertDAL: Pick<TPkiAlertDALFactory, "find">;
pkiCollectionDAL: Pick<TPkiCollectionDALFactory, "find">; pkiCollectionDAL: Pick<TPkiCollectionDALFactory, "find">;
@@ -938,13 +938,13 @@ export const projectServiceFactory = ({
ProjectPermissionSub.Certificates ProjectPermissionSub.Certificates
); );
const certificates = await certificateDAL.find( const certificates = await certificateDAL.findWithPrivateKeyInfo(
{ {
projectId, projectId,
...(friendlyName && { friendlyName }), ...(friendlyName && { friendlyName }),
...(commonName && { commonName }) ...(commonName && { commonName })
}, },
{ offset, limit, sort: [["updatedAt", "desc"]] } { offset, limit, sort: [["notAfter", "desc"]] }
); );
const count = await certificateDAL.countCertificatesInProject({ 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 { deepEqualSkipFields } from "@app/lib/fn/object";
import { OrgServiceActor } from "@app/lib/types"; import { OrgServiceActor } from "@app/lib/types";
import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; 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 { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service";
import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; import { SecretSync } from "@app/services/secret-sync/secret-sync-enums";
@@ -50,6 +52,8 @@ type TSecretSyncServiceFactoryDep = {
secretImportDAL: TSecretImportDALFactory; secretImportDAL: TSecretImportDALFactory;
appConnectionService: Pick<TAppConnectionServiceFactory, "validateAppConnectionUsageById">; appConnectionService: Pick<TAppConnectionServiceFactory, "validateAppConnectionUsageById">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">; permissionService: Pick<TPermissionServiceFactory, "getProjectPermission" | "getOrgPermission">;
projectDAL: Pick<TProjectDALFactory, "findById">;
orgDAL: Pick<TOrgDALFactory, "findById">;
projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">; projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">;
folderDAL: Pick<TSecretFolderDALFactory, "findByProjectId" | "findById" | "findBySecretPath">; folderDAL: Pick<TSecretFolderDALFactory, "findByProjectId" | "findById" | "findBySecretPath">;
keyStore: Pick<TKeyStoreFactory, "getItem">; keyStore: Pick<TKeyStoreFactory, "getItem">;
@@ -68,6 +72,8 @@ export const secretSyncServiceFactory = ({
secretImportDAL, secretImportDAL,
permissionService, permissionService,
appConnectionService, appConnectionService,
projectDAL,
orgDAL,
projectBotService, projectBotService,
secretSyncQueue, secretSyncQueue,
keyStore, keyStore,
@@ -225,6 +231,61 @@ export const secretSyncServiceFactory = ({
return secretSync as TSecretSync; 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 ( const createSecretSync = async (
{ projectId, secretPath, environment, ...params }: TCreateSecretSyncDTO, { projectId, secretPath, environment, ...params }: TCreateSecretSyncDTO,
actor: OrgServiceActor 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}"` 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]; const destinationApp = SECRET_SYNC_CONNECTION_MAP[params.destination];
// validates permission to connect and app is valid for sync destination // validates permission to connect and app is valid for sync destination
@@ -369,6 +454,33 @@ export const secretSyncServiceFactory = ({
let { folderId } = secretSync; 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) { if (params.connectionId) {
const destinationApp = SECRET_SYNC_CONNECTION_MAP[secretSync.destination as SecretSync]; const destinationApp = SECRET_SYNC_CONNECTION_MAP[secretSync.destination as SecretSync];
@@ -703,61 +815,6 @@ export const secretSyncServiceFactory = ({
return updatedSecretSync as TSecretSync; 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 { return {
listSecretSyncOptions, listSecretSyncOptions,
listSecretSyncsByProjectId, listSecretSyncsByProjectId,
@@ -35,7 +35,7 @@ export const secretTagServiceFactory = ({ secretTagDAL, permissionService }: TSe
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Tags); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.Tags);
const existingTag = await secretTagDAL.findOne({ slug, projectId }); 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({ const newTag = await secretTagDAL.create({
projectId, projectId,
@@ -53,7 +53,7 @@ export const secretTagServiceFactory = ({ secretTagDAL, permissionService }: TSe
if (slug) { if (slug) {
const existingTag = await secretTagDAL.findOne({ slug, projectId: tag.projectId }); 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({ const { permission } = await permissionService.getProjectPermission({
@@ -282,7 +282,7 @@ export const secretV2BridgeServiceFactory = ({
folderId folderId
}); });
if (inputSecret.type === SecretType.Shared && doesSecretExist) 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 user creating personal check its shared also exist
if (inputSecret.type === SecretType.Personal && !doesSecretExist) { if (inputSecret.type === SecretType.Personal && !doesSecretExist) {
@@ -527,7 +527,7 @@ export const secretV2BridgeServiceFactory = ({
type: SecretType.Shared, type: SecretType.Shared,
folderId 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( ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionSecretActions.Edit, ProjectPermissionSecretActions.Edit,
subject(ProjectPermissionSub.Secrets, { subject(ProjectPermissionSub.Secrets, {
@@ -1674,7 +1674,7 @@ export const secretV2BridgeServiceFactory = ({
} }
}); });
if (secrets.length) 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); const project = await projectDAL.findById(projectId);
await scanSecretPolicyViolations(projectId, secretPath, inputSecrets, project.secretDetectionIgnoreValues || []); await scanSecretPolicyViolations(projectId, secretPath, inputSecrets, project.secretDetectionIgnoreValues || []);
+2 -2
View File
@@ -525,7 +525,7 @@ export const fnSecretBlindIndexCheck = async ({
); );
if (isNew) { if (isNew) {
if (secrets.length) throw new BadRequestError({ message: "Secret already exist" }); if (secrets.length) throw new BadRequestError({ message: "Secret already exists" });
} else { } else {
const secretKeysInDB = unique(secrets, (el) => el.secretBlindIndex as string).map( const secretKeysInDB = unique(secrets, (el) => el.secretBlindIndex as string).map(
(el) => blindIndex2KeyName[el.secretBlindIndex as string] (el) => blindIndex2KeyName[el.secretBlindIndex as string]
@@ -819,7 +819,7 @@ export const createManySecretsRawFnFactory = ({
); );
if (secretsStoredInDB.length) if (secretsStoredInDB.length)
throw new BadRequestError({ 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) => { const inputSecrets = secrets.map((secret) => {
@@ -2751,7 +2751,7 @@ export const secretServiceFactory = ({
const existingSecretTags = await secretDAL.getSecretTags(secret.id); const existingSecretTags = await secretDAL.getSecretTags(secret.id);
if (existingSecretTags.some((tag) => tagSlugs.includes(tag.slug))) { 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)]); 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", environment: "./e2e-test/vitest-environment-knex.ts",
include: ["./e2e-test/**/*.spec.ts"], include: ["./e2e-test/**/*.spec.ts"],
pool: "threads",
poolOptions: { poolOptions: {
threads: { threads: {
singleThread: true, minThreads: 1,
useAtomics: true, maxThreads: 1,
isolate: false singleThread: true
} }
}, },
fileParallelism: false,
alias: { alias: {
"./license-fns": path.resolve(__dirname, "./src/ee/services/license/__mocks__/license-fns") "./license-fns": path.resolve(__dirname, "./src/ee/services/license/__mocks__/license-fns")
} }
@@ -8,3 +8,9 @@ secrets via HTTPS requests. This can be useful for automating tasks, such as
rotating credentials, or for integrating secret management into a larger system. 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.
+15 -9
View File
@@ -10,6 +10,10 @@ The CLI is designed for a variety of secret management applications ranging from
In the following steps, we explore how to use the Infisical CLI to fetch back environment variables from Infisical 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. 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> <Steps>
<Step title="Log in with the CLI"> <Step title="Log in with the CLI">
Start by running the `infisical login` command to authenticate with Infisical. Start by running the `infisical login` command to authenticate with Infisical.
@@ -123,23 +127,25 @@ The CLI is designed for a variety of secret management applications ranging from
<Note> <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. 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> </Note>
<Tip> <Tip>
## Custom Request Headers ## 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 ```bash
# Syntax: headername1=headervalue1 headername2=headervalue2 # Syntax: headername1=headervalue1 headername2=headervalue2
export INFISICAL_CUSTOM_HEADERS="Access-Client-Id=your-client-id Access-Client-Secret=your-client-secret" export INFISICAL_CUSTOM_HEADERS="Access-Client-Id=your-client-id Access-Client-Secret=your-client-secret"
# Execute Infisical commands after setting the environment variable # Execute Infisical commands after setting the environment variable
infisical secrets 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> </Tip>
## History ## History
+355 -672
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -147,10 +147,10 @@
"tailwindcss": "^4.1.14", "tailwindcss": "^4.1.14",
"typescript": "~5.6.2", "typescript": "~5.6.2",
"typescript-eslint": "^8.15.0", "typescript-eslint": "^8.15.0",
"vite": "^5.4.18", "vite": "^6.2.0",
"vite-plugin-node-polyfills": "^0.22.0", "vite-plugin-node-polyfills": "^0.23.0",
"vite-plugin-top-level-await": "^1.4.4", "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" "vite-tsconfig-paths": "^5.1.4"
} }
} }
@@ -69,7 +69,10 @@ export const PkiSyncSelect = ({ onSelect }: Props) => {
type="button" type="button"
onClick={() => onClick={() =>
enterprise && !subscription.enterpriseCertificateSyncs 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) : 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" 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 <UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen} isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("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." text="You can use every Certificate Sync if you switch to Infisical's Enterprise plan."
/> />
</div> </div>
@@ -67,7 +67,9 @@ export const SecretSyncSelect = ({ onSelect }: Props) => {
type="button" type="button"
onClick={() => onClick={() =>
enterprise && !subscription.enterpriseSecretSyncs enterprise && !subscription.enterpriseSecretSyncs
? handlePopUpOpen("upgradePlan") ? handlePopUpOpen("upgradePlan", {
isEnterpriseFeature: true
})
: onSelect(destination) : 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" 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 <UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen} isOpen={popUp.upgradePlan.isOpen}
isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)} onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can use every Secret Sync if you switch to Infisical's Enterprise plan." 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 { createNotification } from "@app/components/notifications";
import { Button, FormControl, Switch } from "@app/components/v2"; 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 { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs";
import { import {
SecretSync, SecretSync,
SecretSyncInitialSyncBehavior, SecretSyncInitialSyncBehavior,
TSecretSync, TSecretSync,
useCreateSecretSync, useCreateSecretSync,
useDuplicateDestinationCheck,
useSecretSyncOption useSecretSyncOption
} from "@app/hooks/api/secretSyncs"; } from "@app/hooks/api/secretSyncs";
@@ -48,6 +49,7 @@ export const CreateSecretSyncForm = ({
}: Props) => { }: Props) => {
const createSecretSync = useCreateSecretSync(); const createSecretSync = useCreateSecretSync();
const { currentProject } = useProject(); const { currentProject } = useProject();
const { currentOrg } = useOrganization();
const { name: destinationName } = SECRET_SYNC_MAP[destination]; const { name: destinationName } = SECRET_SYNC_MAP[destination];
const [showConfirmation, setShowConfirmation] = useState(false); const [showConfirmation, setShowConfirmation] = useState(false);
@@ -106,11 +108,20 @@ export const CreateSecretSyncForm = ({
setSelectedTabIndex((prev) => prev - 1); 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 isStepValid = async (index: number) => trigger(FORM_TABS[index].fields);
const isFinalStep = selectedTabIndex === FORM_TABS.length - 1; const isFinalStep = selectedTabIndex === FORM_TABS.length - 1;
const isCreateButtonDisabled =
isFinalStep && hasDuplicate && currentOrg?.blockDuplicateSecretSyncDestinations;
const handleNext = async () => { const handleNext = async () => {
if (isFinalStep) { if (isFinalStep) {
@@ -245,7 +256,7 @@ export const CreateSecretSyncForm = ({
</FormProvider> </FormProvider>
<div className="flex w-full flex-row-reverse justify-between gap-4 pt-4"> <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"} {isFinalStep ? "Create Sync" : "Next"}
</Button> </Button>
{selectedTabIndex > 0 && ( {selectedTabIndex > 0 && (
@@ -6,6 +6,7 @@ type Props = {
onConfirm: () => void; onConfirm: () => void;
isLoading?: boolean; isLoading?: boolean;
duplicateProjectId?: string; duplicateProjectId?: string;
isDisabled?: boolean;
}; };
export const DuplicateDestinationConfirmationModal = ({ export const DuplicateDestinationConfirmationModal = ({
@@ -13,7 +14,8 @@ export const DuplicateDestinationConfirmationModal = ({
onOpenChange, onOpenChange,
onConfirm, onConfirm,
isLoading, isLoading,
duplicateProjectId duplicateProjectId,
isDisabled
}: Props) => { }: Props) => {
return ( return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}> <Modal isOpen={isOpen} onOpenChange={onOpenChange}>
@@ -21,7 +23,12 @@ export const DuplicateDestinationConfirmationModal = ({
<div className="mb-4 text-sm"> <div className="mb-4 text-sm">
<p> <p>
Another secret sync in your organization is already configured with the same 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> </p>
{duplicateProjectId && ( {duplicateProjectId && (
<p className="mt-2 text-xs text-mineshaft-400"> <p className="mt-2 text-xs text-mineshaft-400">
@@ -31,26 +38,28 @@ export const DuplicateDestinationConfirmationModal = ({
</code> </code>
</p> </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>
<div className="flex items-center gap-4 pt-4"> {!isDisabled && (
<ModalClose asChild> <div className="flex items-center gap-4 pt-4">
<Button <ModalClose asChild>
onClick={onConfirm} <Button
colorSchema="danger" onClick={onConfirm}
isLoading={isLoading} colorSchema="danger"
isDisabled={isLoading} isLoading={isLoading}
> isDisabled={isLoading}
Continue >
</Button> Continue
</ModalClose> </Button>
<ModalClose asChild> </ModalClose>
<Button colorSchema="secondary" variant="plain" isDisabled={isLoading}> <ModalClose asChild>
Cancel <Button colorSchema="secondary" variant="plain" isDisabled={isLoading}>
</Button> Cancel
</ModalClose> </Button>
</div> </ModalClose>
</div>
)}
</ModalContent> </ModalContent>
</Modal> </Modal>
); );
@@ -5,6 +5,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { SecretSyncEditFields } from "@app/components/secret-syncs/types"; import { SecretSyncEditFields } from "@app/components/secret-syncs/types";
import { Button, ModalClose } from "@app/components/v2"; import { Button, ModalClose } from "@app/components/v2";
import { useOrganization } from "@app/context";
import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs"; import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs";
import { import {
TSecretSync, TSecretSync,
@@ -30,6 +31,7 @@ export const EditSecretSyncForm = ({ secretSync, fields, onComplete }: Props) =>
const { name: destinationName } = SECRET_SYNC_MAP[secretSync.destination]; const { name: destinationName } = SECRET_SYNC_MAP[secretSync.destination];
const [showDuplicateConfirmation, setShowDuplicateConfirmation] = useState(false); const [showDuplicateConfirmation, setShowDuplicateConfirmation] = useState(false);
const [pendingFormData, setPendingFormData] = useState<TSecretSyncForm | null>(null); const [pendingFormData, setPendingFormData] = useState<TSecretSyncForm | null>(null);
const { currentOrg } = useOrganization();
const formMethods = useForm<TSecretSyncForm>({ const formMethods = useForm<TSecretSyncForm>({
resolver: zodResolver(UpdateSecretSyncFormSchema), resolver: zodResolver(UpdateSecretSyncFormSchema),
@@ -209,6 +211,7 @@ export const EditSecretSyncForm = ({ secretSync, fields, onComplete }: Props) =>
onConfirm={handleConfirmDuplicate} onConfirm={handleConfirmDuplicate}
isLoading={updateSecretSync.isPending} isLoading={updateSecretSync.isPending}
duplicateProjectId={storedDuplicateProjectId} duplicateProjectId={storedDuplicateProjectId}
isDisabled={currentOrg?.blockDuplicateSecretSyncDestinations}
/> />
</> </>
); );
@@ -6,7 +6,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { GenericFieldLabel } from "@app/components/secret-syncs"; import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas";
import { Badge } from "@app/components/v3"; 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 { SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP, SECRET_SYNC_MAP } from "@app/helpers/secretSyncs";
import { SecretSync, useDuplicateDestinationCheck } from "@app/hooks/api/secretSyncs"; import { SecretSync, useDuplicateDestinationCheck } from "@app/hooks/api/secretSyncs";
@@ -51,6 +51,7 @@ import { ZabbixSyncReviewFields } from "./ZabbixSyncReviewFields";
export const SecretSyncReviewFields = () => { export const SecretSyncReviewFields = () => {
const { watch } = useFormContext<TSecretSyncForm>(); const { watch } = useFormContext<TSecretSyncForm>();
const { currentProject } = useProject(); const { currentProject } = useProject();
const { currentOrg } = useOrganization();
let DestinationFieldsComponent: ReactNode; let DestinationFieldsComponent: ReactNode;
let AdditionalSyncOptionsFieldsComponent: ReactNode; let AdditionalSyncOptionsFieldsComponent: ReactNode;
@@ -193,18 +194,50 @@ export const SecretSyncReviewFields = () => {
{isChecking && <span className="text-xs text-mineshaft-400">Checking...</span>} {isChecking && <span className="text-xs text-mineshaft-400">Checking...</span>}
</div> </div>
{hasDuplicate && ( {hasDuplicate && (
<div className="mb-2 flex items-start rounded-md border border-yellow-600 bg-yellow-900/20 px-3 py-2"> <div
<div className="flex text-sm text-yellow-100"> className={`mb-2 flex items-start rounded-md border px-3 py-2 ${
<FontAwesomeIcon icon={faWarning} className="mt-1 mr-2 text-yellow-600" /> 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> <div>
<p> <p>
Another secret sync in your organization is already configured with the same {currentOrg?.blockDuplicateSecretSyncDestinations
destination. This may lead to conflicts or unexpected behavior. ? "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> </p>
{duplicateProjectId && ( {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:{" "} 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} {duplicateProjectId}
</code> </code>
</p> </p>
@@ -35,7 +35,7 @@ export type TCertificateProfileWithDetails = TCertificateProfile & {
apiConfig?: { apiConfig?: {
id: string; id: string;
autoRenew: boolean; autoRenew: boolean;
autoRenewDays?: number; renewBeforeDays?: number;
}; };
}; };
@@ -53,7 +53,7 @@ export type TCreateCertificateProfileDTO = {
}; };
apiConfig?: { apiConfig?: {
autoRenew?: boolean; autoRenew?: boolean;
autoRenewDays?: number; renewBeforeDays?: number;
}; };
}; };
@@ -68,7 +68,7 @@ export type TUpdateCertificateProfileDTO = {
}; };
apiConfig?: { apiConfig?: {
autoRenew?: boolean; 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"; export { useGetCert, useGetCertBody } from "./queries";
@@ -9,7 +9,10 @@ import {
TDeleteCertDTO, TDeleteCertDTO,
TImportCertificateDTO, TImportCertificateDTO,
TImportCertificateResponse, TImportCertificateResponse,
TRevokeCertDTO TRenewCertificateDTO,
TRenewCertificateResponse,
TRevokeCertDTO,
TUpdateRenewalConfigDTO
} from "./types"; } from "./types";
export const useDeleteCert = () => { 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; id: string;
caId: string; caId: string;
certificateTemplateId?: string; certificateTemplateId?: string;
profileId?: string;
status: CertStatus; status: CertStatus;
friendlyName: string; friendlyName: string;
commonName: string; commonName: string;
@@ -13,6 +14,12 @@ export type TCertificate = {
notAfter: string; notAfter: string;
keyUsages: CertKeyUsage[]; keyUsages: CertKeyUsage[];
extendedKeyUsages: CertExtendedKeyUsage[]; extendedKeyUsages: CertExtendedKeyUsage[];
renewBeforeDays?: number;
renewedBy?: string;
renewedFromCertificateId?: string;
renewedByCertificateId?: string;
renewalError?: string;
hasPrivateKey?: boolean;
}; };
export type TDeleteCertDTO = { export type TDeleteCertDTO = {
@@ -43,3 +50,24 @@ export type TImportCertificateResponse = {
privateKey: string; privateKey: string;
serialNumber: 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, scannerProductEnabled,
shareSecretsProductEnabled, shareSecretsProductEnabled,
maxSharedSecretLifetime, maxSharedSecretLifetime,
maxSharedSecretViewLimit maxSharedSecretViewLimit,
blockDuplicateSecretSyncDestinations
}) => { }) => {
return apiRequest.patch(`/api/v1/organization/${orgId}`, { return apiRequest.patch(`/api/v1/organization/${orgId}`, {
name, name,
@@ -146,7 +147,8 @@ export const useUpdateOrg = () => {
scannerProductEnabled, scannerProductEnabled,
shareSecretsProductEnabled, shareSecretsProductEnabled,
maxSharedSecretLifetime, maxSharedSecretLifetime,
maxSharedSecretViewLimit maxSharedSecretViewLimit,
blockDuplicateSecretSyncDestinations
}); });
}, },
onSuccess: () => { onSuccess: () => {
@@ -29,6 +29,7 @@ export type Organization = {
shareSecretsProductEnabled: boolean; shareSecretsProductEnabled: boolean;
maxSharedSecretLifetime: number; maxSharedSecretLifetime: number;
maxSharedSecretViewLimit: number | null; maxSharedSecretViewLimit: number | null;
blockDuplicateSecretSyncDestinations: boolean;
}; };
export type UpdateOrgDTO = { export type UpdateOrgDTO = {
@@ -52,6 +53,7 @@ export type UpdateOrgDTO = {
shareSecretsProductEnabled?: boolean; shareSecretsProductEnabled?: boolean;
maxSharedSecretViewLimit?: number | null; maxSharedSecretViewLimit?: number | null;
maxSharedSecretLifetime?: number; maxSharedSecretLifetime?: number;
blockDuplicateSecretSyncDestinations?: boolean;
}; };
export type BillingDetails = { export type BillingDetails = {
+6 -2
View File
@@ -18,7 +18,10 @@ export const PamLayout = () => {
useEffect(() => { useEffect(() => {
if (subscription && !subscription.pam) { if (subscription && !subscription.pam) {
handlePopUpOpen("upgradePlan"); handlePopUpOpen("upgradePlan", {
description: "You can use PAM if you switch to Infisical's Enterprise plan.",
isEnterpriseFeature: true
});
} }
}, [subscription]); }, [subscription]);
@@ -111,7 +114,8 @@ export const PamLayout = () => {
onOpenChange={(isOpen) => { onOpenChange={(isOpen) => {
handlePopUpToggle("upgradePlan", 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) { if (!subscription.hsm) {
handlePopUpOpen("upgradePlan", { handlePopUpOpen("upgradePlan", {
isEnterpriseFeature: true,
description: "Hardware Security Module's (HSM's), are only available on Enterprise plans." description: "Hardware Security Module's (HSM's), are only available on Enterprise plans."
}); });
return; return;
@@ -144,6 +145,7 @@ export const EncryptionPageForm = () => {
isOpen={popUp.upgradePlan.isOpen} isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)} onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text={(popUp.upgradePlan?.data as { description: string })?.description} 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 <UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen} isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("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." text="Managing template enrollment options for EST is only available on Infisical's Enterprise plan."
/> />
</div> </div>
@@ -36,6 +36,7 @@ type Props = {
data?: { data?: {
id?: string; id?: string;
name?: string; name?: string;
isEnterpriseFeature?: boolean;
} }
) => void; ) => void;
}; };
@@ -90,7 +91,9 @@ export const CertificateTemplatesTable = ({ handlePopUpOpen, caId }: Props) => {
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
if (!subscription?.pkiEst) { if (!subscription?.pkiEst) {
handlePopUpOpen("upgradePlan"); handlePopUpOpen("upgradePlan", {
isEnterpriseFeature: true
});
return; return;
} }
@@ -16,7 +16,9 @@ import { usePopUp } from "@app/hooks/usePopUp";
import { CertificateCertModal } from "./CertificateCertModal"; import { CertificateCertModal } from "./CertificateCertModal";
import { CertificateImportModal } from "./CertificateImportModal"; import { CertificateImportModal } from "./CertificateImportModal";
import { CertificateIssuanceModal } from "./CertificateIssuanceModal"; import { CertificateIssuanceModal } from "./CertificateIssuanceModal";
import { CertificateManageRenewalModal } from "./CertificateManageRenewalModal";
import { CertificateModal } from "./CertificateModal"; import { CertificateModal } from "./CertificateModal";
import { CertificateRenewalModal } from "./CertificateRenewalModal";
import { CertificateRevocationModal } from "./CertificateRevocationModal"; import { CertificateRevocationModal } from "./CertificateRevocationModal";
import { CertificatesTable } from "./CertificatesTable"; import { CertificatesTable } from "./CertificatesTable";
@@ -33,7 +35,9 @@ export const CertificatesSection = () => {
"certificateImport", "certificateImport",
"certificateCert", "certificateCert",
"deleteCertificate", "deleteCertificate",
"revokeCertificate" "revokeCertificate",
"manageRenewal",
"renewCertificate"
] as const); ] as const);
const onRemoveCertificateSubmit = async (serialNumber: string) => { const onRemoveCertificateSubmit = async (serialNumber: string) => {
@@ -98,6 +102,8 @@ export const CertificatesSection = () => {
)} )}
<CertificateImportModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} /> <CertificateImportModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CertificateCertModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} /> <CertificateCertModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CertificateManageRenewalModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CertificateRenewalModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<CertificateRevocationModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} /> <CertificateRevocationModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal <DeleteActionModal
isOpen={popUp.deleteCertificate.isOpen} isOpen={popUp.deleteCertificate.isOpen}
@@ -5,12 +5,15 @@ import {
faEllipsis, faEllipsis,
faEye, faEye,
faFileExport, faFileExport,
faQuestionCircle,
faRedo,
faTrash faTrash
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format } from "date-fns"; import { format } from "date-fns";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions"; import { ProjectPermissionCan } from "@app/components/permissions";
import { import {
DropdownMenu, DropdownMenu,
@@ -33,25 +36,136 @@ import { Badge } from "@app/components/v3";
import { import {
ProjectPermissionCertificateActions, ProjectPermissionCertificateActions,
ProjectPermissionSub, ProjectPermissionSub,
useProject useProject,
useSubscription
} from "@app/context"; } 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 { caSupportsCapability } from "@app/hooks/api/ca/constants";
import { CaCapability, CaType } from "@app/hooks/api/ca/enums"; import { CaCapability, CaType } from "@app/hooks/api/ca/enums";
import { useListCasByProjectId } from "@app/hooks/api/ca/queries"; import { useListCasByProjectId } from "@app/hooks/api/ca/queries";
import { CertStatus } from "@app/hooks/api/certificates/enums"; import { CertStatus } from "@app/hooks/api/certificates/enums";
import { TCertificate } from "@app/hooks/api/certificates/types";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
import { getCertValidUntilBadgeDetails } from "./CertificatesTable.utils"; 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 = { type Props = {
handlePopUpOpen: ( handlePopUpOpen: (
popUpName: keyof UsePopUpState< popUpName: keyof UsePopUpState<
["certificate", "deleteCertificate", "revokeCertificate", "certificateCert"] [
"certificate",
"deleteCertificate",
"revokeCertificate",
"certificateCert",
"manageRenewal",
"renewCertificate"
]
>, >,
data?: { data?: {
serialNumber?: string; serialNumber?: string;
commonName?: string; commonName?: string;
certificateId?: string;
profileId?: string;
renewBeforeDays?: number;
ttlDays?: number;
notAfter?: string;
renewalError?: string;
renewedFromCertificateId?: string;
renewedByCertificateId?: string;
} }
) => void; ) => void;
}; };
@@ -61,6 +175,7 @@ const PER_PAGE_INIT = 25;
export const CertificatesTable = ({ handlePopUpOpen }: Props) => { export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(PER_PAGE_INIT); const [perPage, setPerPage] = useState(PER_PAGE_INIT);
const { subscription } = useSubscription();
const { currentProject } = useProject(); const { currentProject } = useProject();
const { data, isPending } = useListWorkspaceCertificates({ const { data, isPending } = useListWorkspaceCertificates({
@@ -69,10 +184,11 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
limit: perPage limit: perPage
}); });
// Fetch CA data to determine capabilities const { mutateAsync: updateRenewalConfig } = useUpdateRenewalConfig();
const isLegacyTemplatesEnabled = subscription.pkiLegacyTemplates;
const { data: caData } = useListCasByProjectId(currentProject?.id ?? ""); const { data: caData } = useListCasByProjectId(currentProject?.id ?? "");
// Create mapping from caId to CA type for capability checking
const caCapabilityMap = useMemo(() => { const caCapabilityMap = useMemo(() => {
if (!caData) return {}; if (!caData) return {};
@@ -83,6 +199,35 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
return map; return map;
}, [caData]); }, [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 ( return (
<TableContainer> <TableContainer>
<Table> <Table>
@@ -92,14 +237,24 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
<Th>Status</Th> <Th>Status</Th>
<Th>Not Before</Th> <Th>Not Before</Th>
<Th>Not After</Th> <Th>Not After</Th>
<Th>Renewal Status</Th>
<Th /> <Th />
</Tr> </Tr>
</THead> </THead>
<TBody> <TBody>
{isPending && <TableSkeleton columns={3} innerKey="project-cas" />} {isPending && <TableSkeleton columns={5} innerKey="project-cas" />}
{!isPending && {!isPending &&
data?.certificates.map((certificate) => { data?.certificates.map((certificate) => {
const { variant, label } = getCertValidUntilBadgeDetails(certificate.notAfter); 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 ( return (
<Tr className="h-10" key={`certificate-${certificate.id}`}> <Tr className="h-10" key={`certificate-${certificate.id}`}>
<Td>{certificate.commonName}</Td> <Td>{certificate.commonName}</Td>
@@ -120,6 +275,25 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
? format(new Date(certificate.notAfter), "yyyy-MM-dd") ? format(new Date(certificate.notAfter), "yyyy-MM-dd")
: "-"} : "-"}
</Td> </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"> <Td className="flex justify-end">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg"> <DropdownMenuTrigger asChild className="rounded-lg">
@@ -151,31 +325,172 @@ export const CertificatesTable = ({ handlePopUpOpen }: Props) => {
</DropdownMenuItem> </DropdownMenuItem>
)} )}
</ProjectPermissionCan> </ProjectPermissionCan>
<ProjectPermissionCan {isLegacyTemplatesEnabled && (
I={ProjectPermissionCertificateActions.Read} <ProjectPermissionCan
a={ProjectPermissionSub.Certificates} I={ProjectPermissionCertificateActions.Read}
> a={ProjectPermissionSub.Certificates}
{(isAllowed) => ( >
<DropdownMenuItem {(isAllowed) => (
className={twMerge( <DropdownMenuItem
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50" className={twMerge(
)} !isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
onClick={async () => )}
handlePopUpOpen("certificate", { onClick={async () =>
serialNumber: certificate.serialNumber handlePopUpOpen("certificate", {
}) serialNumber: certificate.serialNumber
} })
disabled={!isAllowed} }
icon={<FontAwesomeIcon icon={faEye} />} 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 {(isAllowed) => {
</DropdownMenuItem> return (
)} <DropdownMenuItem
</ProjectPermissionCan> 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 */} {/* Only show revoke button if CA supports revocation */}
{(() => { {(() => {
const caType = caCapabilityMap[certificate.caId]; const caType = caCapabilityMap[certificate.caId];
// If caId not found in map, assume CA supports revocation to avoid hiding revoke option
const supportsRevocation = const supportsRevocation =
!caType || !caType ||
caSupportsCapability(caType, CaCapability.REVOKE_CERTIFICATES); caSupportsCapability(caType, CaCapability.REVOKE_CERTIFICATES);
@@ -11,6 +11,26 @@ import {
mapTemplateSignatureAlgorithmToApi mapTemplateSignatureAlgorithmToApi
} from "@app/pages/cert-manager/PoliciesPage/components/CertificateTemplatesV2Tab/shared/certificate-constants"; } 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 = { export type TemplateConstraints = {
allowedKeyUsages: string[]; allowedKeyUsages: string[];
allowedExtendedKeyUsages: string[]; allowedExtendedKeyUsages: string[];
@@ -118,7 +138,7 @@ export const useCertificateTemplate = (
// Set TTL if available // Set TTL if available
if (templateData.validity?.max) { if (templateData.validity?.max) {
setValue("ttl", templateData.validity.max); setValue("ttl", convertTemplateTtlToCertificateTtl(templateData.validity.max));
} }
// Handle SAN types // Handle SAN types
@@ -203,7 +203,9 @@ export const PkiTemplateListPage = () => {
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (!subscription.pkiEst) { if (!subscription.pkiEst) {
handlePopUpOpen("estUpgradePlan"); handlePopUpOpen("estUpgradePlan", {
isEnterpriseFeature: true
});
return; return;
} }
handlePopUpOpen("enrollmentOptions", { handlePopUpOpen("enrollmentOptions", {
@@ -296,6 +298,7 @@ export const PkiTemplateListPage = () => {
isOpen={popUp.estUpgradePlan.isOpen} isOpen={popUp.estUpgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("estUpgradePlan", isOpen)} onOpenChange={(isOpen) => handlePopUpToggle("estUpgradePlan", isOpen)}
text="You can only configure template enrollment methods if you switch to Infisical's Enterprise plan." 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 { useEffect } from "react";
import { Controller, useForm } from "react-hook-form"; 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 { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
@@ -13,7 +15,8 @@ import {
ModalContent, ModalContent,
Select, Select,
SelectItem, SelectItem,
TextArea TextArea,
Tooltip
} from "@app/components/v2"; } from "@app/components/v2";
import { useProject } from "@app/context"; import { useProject } from "@app/context";
import { useListCasByProjectId } from "@app/hooks/api/ca/queries"; import { useListCasByProjectId } from "@app/hooks/api/ca/queries";
@@ -67,7 +70,7 @@ const createSchema = z
apiConfig: z apiConfig: z
.object({ .object({
autoRenew: z.boolean().optional(), autoRenew: z.boolean().optional(),
autoRenewDays: z.number().min(1).max(365).optional() renewBeforeDays: z.number().min(1).max(365).optional()
}) })
.optional() .optional()
}) })
@@ -115,7 +118,7 @@ const editSchema = z
apiConfig: z apiConfig: z
.object({ .object({
autoRenew: z.boolean().optional(), autoRenew: z.boolean().optional(),
autoRenewDays: z.number().min(1).max(365).optional() renewBeforeDays: z.number().min(1).max(365).optional()
}) })
.optional() .optional()
}) })
@@ -183,7 +186,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
profile.enrollmentType === "api" profile.enrollmentType === "api"
? { ? {
autoRenew: profile.apiConfig?.autoRenew || false, autoRenew: profile.apiConfig?.autoRenew || false,
autoRenewDays: profile.apiConfig?.autoRenewDays || 30 renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30
} }
: undefined : undefined
} }
@@ -195,7 +198,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
certificateTemplateId: "", certificateTemplateId: "",
apiConfig: { apiConfig: {
autoRenew: false, autoRenew: false,
autoRenewDays: 30 renewBeforeDays: 30
} }
} }
}); });
@@ -225,7 +228,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
profile.enrollmentType === "api" profile.enrollmentType === "api"
? { ? {
autoRenew: profile.apiConfig?.autoRenew || false, autoRenew: profile.apiConfig?.autoRenew || false,
autoRenewDays: profile.apiConfig?.autoRenewDays || 30 renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30
} }
: undefined : undefined
}); });
@@ -389,7 +392,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
} else { } else {
setValue("apiConfig", { setValue("apiConfig", {
autoRenew: false, autoRenew: false,
autoRenewDays: 30 renewBeforeDays: 30
}); });
setValue("estConfig", undefined); setValue("estConfig", undefined);
} }
@@ -433,7 +436,7 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
setValue("estConfig", undefined); setValue("estConfig", undefined);
setValue("apiConfig", { setValue("apiConfig", {
autoRenew: false, autoRenew: false,
autoRenewDays: 30 renewBeforeDays: 30
}); });
} }
onChange(value); onChange(value);
@@ -535,9 +538,18 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
name="apiConfig.autoRenew" name="apiConfig.autoRenew"
render={({ field: { value, onChange }, fieldState: { error } }) => ( render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message}> <FormControl isError={Boolean(error)} errorText={error?.message}>
<Checkbox id="autoRenew" isChecked={value} onCheckedChange={onChange}> <div className="flex items-center gap-2">
Enable Auto-Renewal <Checkbox id="autoRenew" isChecked={value} onCheckedChange={onChange}>
</Checkbox> 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> </FormControl>
)} )}
/> />
@@ -548,10 +560,10 @@ export const CreateProfileModal = ({ isOpen, onClose, profile, mode = "create" }
<div className="mb-4 space-y-4"> <div className="mb-4 space-y-4">
<Controller <Controller
control={control} control={control}
name="apiConfig.autoRenewDays" name="apiConfig.renewBeforeDays"
render={({ field, fieldState: { error } }) => ( render={({ field, fieldState: { error } }) => (
<FormControl <FormControl
label="Auto-Renewal Days" label="Auto-Renewal Days Before Expiration"
isError={Boolean(error)} isError={Boolean(error)}
errorText={error?.message} errorText={error?.message}
> >
@@ -170,7 +170,9 @@ export const KmipClientTable = () => {
leftIcon={<FontAwesomeIcon icon={faPlus} />} leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => { onClick={() => {
if (subscription && !subscription.kmip) { if (subscription && !subscription.kmip) {
handlePopUpOpen("upgradePlan"); handlePopUpOpen("upgradePlan", {
isEnterpriseFeature: true
});
return; return;
} }
@@ -343,6 +345,7 @@ export const KmipClientTable = () => {
isOpen={popUp.upgradePlan.isOpen} isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)} onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="KMIP requires an enterprise plan." text="KMIP requires an enterprise plan."
isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature}
/> />
</div> </div>
</motion.div> </motion.div>
@@ -28,7 +28,8 @@ export const OrgGroupsSection = () => {
if (!subscription?.groups) { if (!subscription?.groups) {
handlePopUpOpen("upgradePlan", { handlePopUpOpen("upgradePlan", {
description: 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 { } else {
handlePopUpOpen("group"); handlePopUpOpen("group");
@@ -96,6 +97,7 @@ export const OrgGroupsSection = () => {
<UpgradePlanModal <UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen} isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)} onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature}
text={(popUp.upgradePlan?.data as { description: string })?.description} text={(popUp.upgradePlan?.data as { description: string })?.description}
/> />
</div> </div>
@@ -292,7 +292,8 @@ export const IdentityAuthMethodModalContent = ({
<UpgradePlanModal <UpgradePlanModal
isOpen={popUp?.upgradePlan?.isOpen} isOpen={popUp?.upgradePlan?.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("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>; export type FormData = z.infer<typeof schema>;
type Props = { type Props = {
handlePopUpOpen: (popUpName: keyof UsePopUpState<["upgradePlan"]>) => void; handlePopUpOpen: (
popUpName: keyof UsePopUpState<["upgradePlan"]>,
data?: { isEnterpriseFeature?: boolean; featureName?: string }
) => void;
handlePopUpToggle: ( handlePopUpToggle: (
popUpName: keyof UsePopUpState<["identityAuthMethod"]>, popUpName: keyof UsePopUpState<["identityAuthMethod"]>,
state?: boolean state?: boolean
@@ -304,7 +307,10 @@ export const IdentityLdapAuthForm = ({
useEffect(() => { useEffect(() => {
if (!subscription?.ldap) { if (!subscription?.ldap) {
handlePopUpOpen("upgradePlan"); handlePopUpOpen("upgradePlan", {
isEnterpriseFeature: true,
featureName: "LDAP authentication"
});
handlePopUpToggle("identityAuthMethod", false); handlePopUpToggle("identityAuthMethod", false);
} }
}, [subscription, handlePopUpOpen, handlePopUpToggle]); }, [subscription, handlePopUpOpen, handlePopUpToggle]);
@@ -146,7 +146,7 @@ export const IdentitySection = withPermission(
if (!isMoreIdentitiesAllowed && !isEnterprise) { if (!isMoreIdentitiesAllowed && !isEnterprise) {
handlePopUpOpen("upgradePlan", { handlePopUpOpen("upgradePlan", {
description: 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; return;
} }
@@ -179,7 +179,11 @@ export const IdentitySection = withPermission(
leftIcon={<FontAwesomeIcon icon={faPlus} />} leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => { onClick={() => {
if (subscription && !subscription.machineIdentityAuthTemplates) { 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; return;
} }
handlePopUpOpen("createTemplate"); handlePopUpOpen("createTemplate");
@@ -249,7 +253,8 @@ export const IdentitySection = withPermission(
<UpgradePlanModal <UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen} isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("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> </div>
); );
@@ -76,7 +76,7 @@ export const OrgMembersSection = () => {
if (!isMoreIdentitiesAllowed && !isEnterprise) { if (!isMoreIdentitiesAllowed && !isEnterprise) {
handlePopUpOpen("upgradePlan", { 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; return;
} }
@@ -133,7 +133,8 @@ export const OrgMembersTable = ({
if (isCustomRole && subscription && !subscription?.rbac) { if (isCustomRole && subscription && !subscription?.rbac) {
handlePopUpOpen("upgradePlan", { 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; return;
} }
@@ -105,7 +105,7 @@ export const OrgRoleTable = () => {
if (isCustomRole && subscription && !subscription?.rbac) { if (isCustomRole && subscription && !subscription?.rbac) {
handlePopUpOpen("upgradePlan", { handlePopUpOpen("upgradePlan", {
description: 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; return;
} }
@@ -75,7 +75,9 @@ export const AppConnectionsSelect = ({ onSelect, projectType }: Props) => {
type="button" type="button"
onClick={() => onClick={() =>
enterprise && !subscription.enterpriseAppConnections enterprise && !subscription.enterpriseAppConnections
? handlePopUpOpen("upgradePlan") ? handlePopUpOpen("upgradePlan", {
isEnterpriseFeature: true
})
: onSelect(option.app) : 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" 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} isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)} onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can use every App Connection if you switch to Infisical's Enterprise plan." text="You can use every App Connection if you switch to Infisical's Enterprise plan."
isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature}
/> />
</div> </div>
); );
@@ -123,7 +123,7 @@ const LogsSectionComponent = ({
onOpenChange={(isOpen) => { onOpenChange={(isOpen) => {
handlePopUpToggle("upgradePlan", 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>
</div> </div>
@@ -167,7 +167,7 @@ const LogsSectionComponent = ({
onOpenChange={(isOpen) => { onOpenChange={(isOpen) => {
handlePopUpToggle("upgradePlan", 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>
); );
@@ -121,6 +121,7 @@ const Page = () => {
isOpen={popUp.upgradePlan.isOpen} isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)} onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text={(popUp.upgradePlan?.data as { description: string })?.description} text={(popUp.upgradePlan?.data as { description: string })?.description}
isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature}
/> />
<DeleteActionModal <DeleteActionModal
isOpen={popUp.deleteIdentity.isOpen} isOpen={popUp.deleteIdentity.isOpen}
@@ -92,7 +92,7 @@ export const ProjectsPage = () => {
<UpgradePlanModal <UpgradePlanModal
isOpen={popUp.upgradePlan.isOpen} isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("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> </div>
); );
@@ -29,7 +29,9 @@ export const AuditLogStreamsTab = withPermission(
<Button <Button
onClick={() => { onClick={() => {
if (subscription && !subscription?.auditLogStreams) { if (subscription && !subscription?.auditLogStreams) {
handlePopUpOpen("upgradePlan"); handlePopUpOpen("upgradePlan", {
isEnterpriseFeature: true
});
return; return;
} }
handlePopUpOpen("auditLogStreamForm"); handlePopUpOpen("auditLogStreamForm");
@@ -56,6 +58,7 @@ export const AuditLogStreamsTab = withPermission(
isOpen={popUp.upgradePlan.isOpen} isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)} onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="You can add audit log streams if you switch to Infisical's Enterprise plan." text="You can add audit log streams if you switch to Infisical's Enterprise plan."
isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature}
/> />
</div> </div>
); );
@@ -70,7 +70,9 @@ export const LogStreamProviderSelect = ({ onSelect }: Props) => {
type="button" type="button"
onClick={() => { onClick={() => {
if (option.provider === LogProvider.QRadar) { if (option.provider === LogProvider.QRadar) {
handlePopUpOpen("upgradePlan"); handlePopUpOpen("upgradePlan", {
isEnterpriseFeature: true
});
} else { } else {
onSelect(option.provider); onSelect(option.provider);
} }
@@ -110,6 +112,7 @@ export const LogStreamProviderSelect = ({ onSelect }: Props) => {
isOpen={popUp.upgradePlan.isOpen} isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)} onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="This audit log stream provider requires an enterprise license." text="This audit log stream provider requires an enterprise license."
isEnterpriseFeature={popUp.upgradePlan.data?.isEnterpriseFeature}
/> />
</div> </div>
); );
@@ -180,7 +180,9 @@ const OrgConfigSection = ({
className="mt-2" className="mt-2"
onClick={() => { onClick={() => {
if (subscription && !subscription.kmip) { if (subscription && !subscription.kmip) {
handlePopUpOpen("upgradePlan"); handlePopUpOpen("upgradePlan", {
isEnterpriseFeature: true
});
return; return;
} }
@@ -251,6 +253,7 @@ const OrgConfigSection = ({
isOpen={popUp.upgradePlan.isOpen} isOpen={popUp.upgradePlan.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)} onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
text="KMIP requires an enterprise plan." 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