diff --git a/Dockerfile.standalone-infisical b/Dockerfile.standalone-infisical index 01c9a737b..0674a00e9 100644 --- a/Dockerfile.standalone-infisical +++ b/Dockerfile.standalone-infisical @@ -34,6 +34,7 @@ ENV VITE_POSTHOG_API_KEY $POSTHOG_API_KEY ARG INTERCOM_ID ENV VITE_INTERCOM_ID $INTERCOM_ID ARG INFISICAL_PLATFORM_VERSION +ENV INFISICAL_PLATFORM_VERSION $INFISICAL_PLATFORM_VERSION ENV VITE_INFISICAL_PLATFORM_VERSION $INFISICAL_PLATFORM_VERSION ARG CAPTCHA_SITE_KEY ENV VITE_CAPTCHA_SITE_KEY $CAPTCHA_SITE_KEY diff --git a/backend/bdd/features/pki/acme/external-ca.feature b/backend/bdd/features/pki/acme/external-ca.feature index 26bfd84ad..f809b3686 100644 --- a/backend/bdd/features/pki/acme/external-ca.feature +++ b/backend/bdd/features/pki/acme/external-ca.feature @@ -1,6 +1,7 @@ Feature: External CA - Scenario: Issue a certificate from an external CA + @cloudflare + Scenario Outline: Issue a certificate from an external CA with Cloudflare Given I create a Cloudflare connection as cloudflare Then I memorize cloudflare with jq ".appConnection.id" as app_conn_id Given I create a external ACME CA with the following config as ext_ca @@ -92,9 +93,7 @@ Feature: External CA When I create certificate signing request as csr Then I add names to certificate signing request csr """ - { - "COMMON_NAME": "localhost" - } + """ # Pebble has a strict rule to only takes SANs Then I add subject alternative name to certificate signing request csr @@ -177,4 +176,196 @@ Feature: External CA [ "localhost" ] - """ \ No newline at end of file + """ + + Examples: + | subject | + | {"COMMON_NAME": "localhost"} | + | {} | + + @dnsme + Scenario Outline: Issue a certificate from an external CA with DNS Made Easy + Given I create a DNS Made Easy connection as dnsme + Then I memorize dnsme with jq ".appConnection.id" as app_conn_id + Given I create a external ACME CA with the following config as ext_ca + """ + { + "dnsProviderConfig": { + "provider": "dns-made-easy", + "hostedZoneId": "MOCK_ZONE_ID" + }, + "directoryUrl": "{PEBBLE_URL}", + "accountEmail": "fangpen@infisical.com", + "dnsAppConnectionId": "{app_conn_id}", + "eabKid": "", + "eabHmacKey": "" + } + """ + Then I memorize ext_ca with jq ".id" as ext_ca_id + Given I create a certificate template with the following config as cert_template + """ + { + "subject": [ + { + "type": "common_name", + "allowed": [ + "*" + ] + } + ], + "sans": [ + { + "type": "dns_name", + "allowed": [ + "*" + ] + } + ], + "keyUsages": { + "required": [], + "allowed": [ + "digital_signature", + "key_encipherment", + "non_repudiation", + "data_encipherment", + "key_agreement", + "key_cert_sign", + "crl_sign", + "encipher_only", + "decipher_only" + ] + }, + "extendedKeyUsages": { + "required": [], + "allowed": [ + "client_auth", + "server_auth", + "code_signing", + "email_protection", + "ocsp_signing", + "time_stamping" + ] + }, + "algorithms": { + "signature": [ + "SHA256-RSA", + "SHA512-RSA", + "SHA384-ECDSA", + "SHA384-RSA", + "SHA256-ECDSA", + "SHA512-ECDSA" + ], + "keyAlgorithm": [ + "RSA-2048", + "RSA-4096", + "ECDSA-P384", + "RSA-3072", + "ECDSA-P256", + "ECDSA-P521" + ] + }, + "validity": { + "max": "365d" + } + } + """ + Then I memorize cert_template with jq ".certificateTemplate.id" as cert_template_id + Given I create an ACME profile with ca {ext_ca_id} and template {cert_template_id} as "acme_profile" + When I have an ACME client connecting to "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory" + Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account + When I create certificate signing request as csr + Then I add names to certificate signing request csr + """ + + """ + # Pebble has a strict rule to only takes SANs + Then I add subject alternative name to certificate signing request csr + """ + [ + "localhost" + ] + """ + And I create a RSA private key pair as cert_key + And I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format + And I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order + And I select challenge with type http-01 for domain localhost from order in order as challenge + And I serve challenge response for challenge at localhost + And I tell ACME server that challenge is ready to be verified + Given I intercept outgoing requests + """ + [ + { + "scope": "https://api.dnsmadeeasy.com:443", + "method": "POST", + "path": "/V2.0/dns/managed/MOCK_ZONE_ID/records", + "status": 201, + "response": { + "gtdLocation": "DEFAULT", + "failed": false, + "monitor": false, + "failover": false, + "sourceId": 895364, + "dynamicDns": false, + "hardLink": false, + "ttl": 60, + "source": 1, + "name": "_acme-challenge", + "value": "\"MOCK_HTTP_01_VALUE\"", + "id": 12345678, + "type": "TXT" + }, + "responseIsBinary": false + }, + { + "scope": "https://api.dnsmadeeasy.com:443", + "method": "GET", + "path": "/V2.0/dns/managed/MOCK_ZONE_ID/records?type=TXT&recordName=_acme-challenge&page=0", + "status": 200, + "response": { + "totalRecords": 1, + "totalPages": 1, + "data": [ + { + "gtdLocation": "DEFAULT", + "failed": false, + "monitor": false, + "failover": false, + "sourceId": 895364, + "dynamicDns": false, + "hardLink": false, + "ttl": 60, + "source": 1, + "name": "_acme-challenge", + "value": "\"MOCK_CHALLENGE_VALUE\"", + "id": 1111111, + "type": "TXT" + } + ], + "page": 0 + }, + "responseIsBinary": false + }, + { + "scope": "https://api.dnsmadeeasy.com:443", + "method": "DELETE", + "path": "/V2.0/dns/managed/MOCK_ZONE_ID/records/1111111", + "status": 200, + "response": "", + "responseIsBinary": false + } + ] + """ + Then I poll and finalize the ACME order order as finalized_order + And the value finalized_order.body with jq ".status" should be equal to "valid" + And I parse the full-chain certificate from order finalized_order as cert + And the value cert with jq "[.extensions.subjectAltName.general_names.[].value] | sort" should be equal to json + """ + [ + "localhost" + ] + """ + + Examples: + | subject | + | {"COMMON_NAME": "localhost"} | + | {} | diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py index 353ec942d..7e2d07476 100644 --- a/backend/bdd/features/steps/pki_acme.py +++ b/backend/bdd/features/steps/pki_acme.py @@ -147,6 +147,40 @@ def step_impl(context: Context, var_name: str): context.vars[var_name] = response +@given("I create a DNS Made Easy connection as {var_name}") +def step_impl(context: Context, var_name: str): + jwt_token = context.vars["AUTH_TOKEN"] + conn_slug = faker.slug() + with with_nocks( + context, + definitions=[ + { + "scope": "https://api.dnsmadeeasy.com:443", + "method": "GET", + "path": "/V2.0/dns/managed/", + "status": 200, + "response": {"totalRecords": 0, "totalPages": 1, "data": [], "page": 0}, + "responseIsBinary": False, + } + ], + ): + response = context.http_client.post( + "/api/v1/app-connections/dns-made-easy", + headers=dict(authorization="Bearer {}".format(jwt_token)), + json={ + "name": conn_slug, + "description": "", + "method": "api-key-secret", + "credentials": { + "apiKey": "MOCK_API_KEY", + "secretKey": "MOCK_SECRET_KEY", + }, + }, + ) + response.raise_for_status() + context.vars[var_name] = response + + @given("I create a external ACME CA with the following config as {var_name}") def step_impl(context: Context, var_name: str): jwt_token = context.vars["AUTH_TOKEN"] diff --git a/backend/src/db/migrations/20251121124532_add-issuer-type-to-certificate-profiles.ts b/backend/src/db/migrations/20251121124532_add-issuer-type-to-certificate-profiles.ts new file mode 100644 index 000000000..61dcdb12e --- /dev/null +++ b/backend/src/db/migrations/20251121124532_add-issuer-type-to-certificate-profiles.ts @@ -0,0 +1,27 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasIssuerTypeColumn = await knex.schema.hasColumn(TableName.PkiCertificateProfile, "issuerType"); + + if (!hasIssuerTypeColumn) { + await knex.schema.alterTable(TableName.PkiCertificateProfile, (t) => { + t.string("issuerType").notNullable().defaultTo("ca"); + }); + } + + await knex.schema.alterTable(TableName.PkiCertificateProfile, (t) => { + t.uuid("caId").nullable().alter(); + }); +} + +export async function down(knex: Knex): Promise { + const hasIssuerTypeColumn = await knex.schema.hasColumn(TableName.PkiCertificateProfile, "issuerType"); + + if (hasIssuerTypeColumn) { + await knex.schema.alterTable(TableName.PkiCertificateProfile, (t) => { + t.dropColumn("issuerType"); + }); + } +} diff --git a/backend/src/db/schemas/pki-certificate-profiles.ts b/backend/src/db/schemas/pki-certificate-profiles.ts index 04560bec6..c0dd891f0 100644 --- a/backend/src/db/schemas/pki-certificate-profiles.ts +++ b/backend/src/db/schemas/pki-certificate-profiles.ts @@ -10,7 +10,7 @@ import { TImmutableDBKeys } from "./models"; export const PkiCertificateProfilesSchema = z.object({ id: z.string().uuid(), projectId: z.string(), - caId: z.string().uuid(), + caId: z.string().uuid().nullable().optional(), certificateTemplateId: z.string().uuid(), slug: z.string(), description: z.string().nullable().optional(), @@ -19,7 +19,8 @@ export const PkiCertificateProfilesSchema = z.object({ apiConfigId: z.string().uuid().nullable().optional(), createdAt: z.date(), updatedAt: z.date(), - acmeConfigId: z.string().uuid().nullable().optional() + acmeConfigId: z.string().uuid().nullable().optional(), + issuerType: z.string().default("ca") }); export type TPkiCertificateProfiles = z.infer; diff --git a/backend/src/ee/routes/v2/identity-project-additional-privilege-router.ts b/backend/src/ee/routes/v2/identity-project-additional-privilege-router.ts index f8ac34b4b..47b3f5258 100644 --- a/backend/src/ee/routes/v2/identity-project-additional-privilege-router.ts +++ b/backend/src/ee/routes/v2/identity-project-additional-privilege-router.ts @@ -158,6 +158,7 @@ export const registerIdentityProjectAdditionalPrivilegeRouter = async (server: F }, data: { ...req.body, + name: req.body.slug, ...req.body.type, permissions: req.body.permissions || undefined } diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index 21ea764d9..d0d1bc06c 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -2797,6 +2797,7 @@ interface CreateCertificateProfile { name: string; projectId: string; enrollmentType: string; + issuerType: string; }; } diff --git a/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-queue.ts b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-queue.ts index 93c3dd147..d62a1eeb2 100644 --- a/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-queue.ts +++ b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-queue.ts @@ -1,10 +1,18 @@ +import { ProjectMembershipRole } from "@app/db/schemas"; import { DisableRotationErrors } from "@app/ee/services/secret-rotation/secret-rotation-queue"; +import { getConfig } from "@app/lib/config/env"; +import { applyJitter } from "@app/lib/delay"; import { NotFoundError } from "@app/lib/errors"; import { logger } from "@app/lib/logger"; import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; +import { TIdentityDALFactory } from "@app/services/identity/identity-dal"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; import { KmsDataKey } from "@app/services/kms/kms-types"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; +import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; +import { TUserDALFactory } from "@app/services/user/user-dal"; import { TDynamicSecretDALFactory } from "../dynamic-secret/dynamic-secret-dal"; import { DynamicSecretStatus } from "../dynamic-secret/dynamic-secret-types"; @@ -15,7 +23,12 @@ import { TDynamicSecretLeaseConfig } from "./dynamic-secret-lease-types"; type TDynamicSecretLeaseQueueServiceFactoryDep = { queueService: TQueueServiceFactory; dynamicSecretLeaseDAL: Pick; - dynamicSecretDAL: Pick; + smtpService: Pick; + userDAL: Pick; + identityDAL: TIdentityDALFactory; + dynamicSecretDAL: Pick; + projectMembershipDAL: Pick; + projectDAL: Pick; dynamicSecretProviders: Record; kmsService: Pick; folderDAL: Pick; @@ -23,18 +36,24 @@ type TDynamicSecretLeaseQueueServiceFactoryDep = { export type TDynamicSecretLeaseQueueServiceFactory = { pruneDynamicSecret: (dynamicSecretCfgId: string) => Promise; - setLeaseRevocation: (leaseId: string, expiryAt: Date) => Promise; + setLeaseRevocation: (leaseId: string, dynamicSecretId: string, expiryAt: Date) => Promise; unsetLeaseRevocation: (leaseId: string) => Promise; + queueFailedRevocation: (leaseId: string, dynamicSecretId: string) => Promise; init: () => Promise; }; +const MAX_REVOCATION_RETRY_COUNT = 10; + export const dynamicSecretLeaseQueueServiceFactory = ({ queueService, dynamicSecretDAL, dynamicSecretProviders, dynamicSecretLeaseDAL, kmsService, - folderDAL + folderDAL, + projectMembershipDAL, + projectDAL, + smtpService }: TDynamicSecretLeaseQueueServiceFactoryDep): TDynamicSecretLeaseQueueServiceFactory => { const pruneDynamicSecret = async (dynamicSecretCfgId: string) => { await queueService.queuePg( @@ -48,10 +67,10 @@ export const dynamicSecretLeaseQueueServiceFactory = ({ ); }; - const setLeaseRevocation = async (leaseId: string, expiryAt: Date) => { + const setLeaseRevocation = async (leaseId: string, dynamicSecretId: string, expiryAt: Date) => { await queueService.queuePg( QueueJobs.DynamicSecretRevocation, - { leaseId }, + { leaseId, dynamicSecretId }, { id: leaseId, singletonKey: leaseId, @@ -68,10 +87,53 @@ export const dynamicSecretLeaseQueueServiceFactory = ({ await queueService.stopJobByIdPg(QueueName.DynamicSecretRevocation, leaseId); }; + const queueFailedRevocation = async (leaseId: string, dynamicSecretId: string) => { + const appConfig = getConfig(); + + const retryDelaySeconds = appConfig.isDevelopmentMode ? 1 : Math.floor(applyJitter(3_600_000 * 4) / 1000); // retry every 4 hours with 20% +- jitter (convert ms to seconds for pgboss) + + await queueService.queuePg( + QueueJobs.DynamicSecretRevocation, + { leaseId, isRetry: true, dynamicSecretId }, + { + singletonKey: `${leaseId}-retry`, // avoid conflicts with scheduled revocation + retryDelay: retryDelaySeconds, + retryLimit: MAX_REVOCATION_RETRY_COUNT, // we dont want it to ever hit the limit, we want the expireInHours to take effect. + expireInHours: 23 // if we set it to 24 hours, pgboss will complain that the expireIn is too high + } + ); + }; + + const $queueDynamicSecretLeaseRevocationFailedEmail = async (leaseId: string, dynamicSecretId: string) => { + const appConfig = getConfig(); + + const delay = appConfig.isDevelopmentMode ? 1_000 * 60 : 1_000 * 60 * 15; // 1 minute in development, 15 minutes in production + + await queueService.queue( + QueueName.DynamicSecretLeaseRevocationFailedEmail, + QueueJobs.DynamicSecretLeaseRevocationFailedEmail, + { + leaseId + }, + { + jobId: `dynamic-secret-lease-revocation-failed-email-${dynamicSecretId}`, + delay, + attempts: 3, + backoff: { + type: "exponential", + delay: 1000 * 60 // 1 minute + }, + removeOnComplete: true, + removeOnFail: true + } + ); + }; + const $dynamicSecretQueueJob = async ( jobName: string, jobId: string, - data: { leaseId: string } | { dynamicSecretCfgId: string } + data: { leaseId: string; dynamicSecretId: string; isRetry?: boolean } | { dynamicSecretCfgId: string }, + retryCount?: number ): Promise => { try { if (jobName === QueueJobs.DynamicSecretRevocation) { @@ -79,7 +141,9 @@ export const dynamicSecretLeaseQueueServiceFactory = ({ logger.info("Dynamic secret lease revocation started: ", leaseId, jobId); const dynamicSecretLease = await dynamicSecretLeaseDAL.findById(leaseId); - if (!dynamicSecretLease) throw new DisableRotationErrors({ message: "Dynamic secret lease not found" }); + if (!dynamicSecretLease) { + throw new DisableRotationErrors({ message: "Dynamic secret lease not found" }); + } const folder = await folderDAL.findById(dynamicSecretLease.dynamicSecret.folderId); if (!folder) @@ -150,7 +214,7 @@ export const dynamicSecretLeaseQueueServiceFactory = ({ } logger.info("Finished dynamic secret job", jobId); } catch (error) { - logger.error(error); + logger.error(error, "Failed to delete dynamic secret"); if (jobName === QueueJobs.DynamicSecretPruning) { const { dynamicSecretCfgId } = data as { dynamicSecretCfgId: string }; @@ -161,20 +225,97 @@ export const dynamicSecretLeaseQueueServiceFactory = ({ } if (jobName === QueueJobs.DynamicSecretRevocation) { - const { leaseId } = data as { leaseId: string }; + const { leaseId, isRetry, dynamicSecretId } = data as { + leaseId: string; + isRetry?: boolean; + dynamicSecretId: string; + }; await dynamicSecretLeaseDAL.updateById(leaseId, { status: DynamicSecretStatus.FailedDeletion, - statusDetails: (error as Error)?.message?.slice(0, 255) + statusDetails: `${(error as Error)?.message?.slice(0, 255)} - Retrying automatically` }); + + // only add to retry queue if this is not a retry, and if the error is not a DisableRotationErrors error + if (!isRetry && !(error instanceof DisableRotationErrors)) { + // if revocation fails, we should stop the job and queue a new job to retry the revocation at a later time. + await queueService.stopJobByIdPg(QueueName.DynamicSecretRevocation, jobId); + await queueService.stopRepeatableJobByJobId(QueueName.DynamicSecretRevocation, jobId); + await queueFailedRevocation(leaseId, dynamicSecretId); + + // if its the last attempt, and the error isn't a DisableRotationErrors error, send an email to the project admins (debounced) + } else if (isRetry && !(error instanceof DisableRotationErrors)) { + if (retryCount && retryCount === MAX_REVOCATION_RETRY_COUNT) { + // if all retries fail, we should also stop the automatic revocation job. + // the ID of the revocation job is set to the leaseId, so we can use that to stop the job + + // we dont have to stop the retry job, because if we hit this point, its the last attempt and the retry job will be stopped by pgboss itself after this point, + await queueService.stopJobByIdPg(QueueName.DynamicSecretRevocation, leaseId); + await queueService.stopRepeatableJobByJobId(QueueName.DynamicSecretRevocation, leaseId); + + await $queueDynamicSecretLeaseRevocationFailedEmail(leaseId, dynamicSecretId); + } + } } if (error instanceof DisableRotationErrors) { if (jobId) { await queueService.stopRepeatableJobByJobId(QueueName.DynamicSecretRevocation, jobId); await queueService.stopJobByIdPg(QueueName.DynamicSecretRevocation, jobId); } + } else { + // propagate to next part + throw error; + } + } + }; + + // send alert email once all revocation attempts have failed + const $dynamicSecretLeaseRevocationFailedEmailJob = async (jobId: string, data: { leaseId: string }) => { + try { + const appCfg = getConfig(); + + const { leaseId } = data; + logger.info( + { leaseId, jobId }, + "Dynamic secret revocation failed. Notifying project admins about failed revocation." + ); + + const lease = await dynamicSecretLeaseDAL.findById(leaseId); + if (!lease) { + throw new DisableRotationErrors({ message: "Dynamic secret lease not found" }); + } + + const folder = await folderDAL.findById(lease.dynamicSecret.folderId); + if (!folder) throw new NotFoundError({ message: `Failed to find folder with ${lease.dynamicSecret.folderId}` }); + + const project = await projectDAL.findById(folder.projectId); + const projectMembers = await projectMembershipDAL.findAllProjectMembers(project.id); + + const projectAdmins = projectMembers.filter((member) => + member.roles.some((role) => role.role === ProjectMembershipRole.Admin) + ); + + await smtpService.sendMail({ + recipients: projectAdmins.map((member) => member.user.email!).filter(Boolean), + template: SmtpTemplates.DynamicSecretLeaseRevocationFailed, + subjectLine: "Dynamic Secret Lease Revocation Failed", + substitutions: { + dynamicSecretLeaseUrl: `${appCfg.SITE_URL}/organizations/${project.orgId}/projects/secret-management/${project.id}/secrets/${folder.environment.envSlug}?dynamicSecretId=${lease.dynamicSecret.id}&filterBy=dynamic&search=${lease.dynamicSecret.name}`, + dynamicSecretName: lease.dynamicSecret.name, + projectName: project.name, + environmentSlug: folder.environment.envSlug, + errorMessage: lease.statusDetails || "An unknown error occurred" + } + }); + } catch (error) { + logger.error(error, "Failed to send dynamic secret lease revocation failed email"); + if (error instanceof DisableRotationErrors) { + if (jobId) { + await queueService.stopRepeatableJobByJobId(QueueName.DynamicSecretLeaseRevocationFailedEmail, jobId); + await queueService.stopJobById(QueueName.DynamicSecretLeaseRevocationFailedEmail, jobId); + } + } else { + throw error; } - // propogate to next part - throw error; } }; @@ -182,14 +323,21 @@ export const dynamicSecretLeaseQueueServiceFactory = ({ await $dynamicSecretQueueJob(job.name, job.id as string, job.data); }); + // we use redis for sending the email because: + // 1. we are insensitive to losing the jobs in queue in case of a disaster event + // 2. pgboss does not support exclusive job keys on v0.10.x, and upgrading to v0.11.x which supports exclusive jobs comes with a lot of breaking changes, and we would need to manually migrate our existing jobs to the new version + queueService.start(QueueName.DynamicSecretLeaseRevocationFailedEmail, async (job) => { + await $dynamicSecretLeaseRevocationFailedEmailJob(job.id as string, job.data); + }); + const init = async () => { await queueService.startPg( QueueJobs.DynamicSecretRevocation, async ([job]) => { - await $dynamicSecretQueueJob(job.name, job.id, job.data); + await $dynamicSecretQueueJob(job.name, job.id, job.data, job.retryCount); }, { - workerCount: 5, + workerCount: 10, pollingIntervalSeconds: 1 } ); @@ -210,6 +358,7 @@ export const dynamicSecretLeaseQueueServiceFactory = ({ pruneDynamicSecret, setLeaseRevocation, unsetLeaseRevocation, + queueFailedRevocation, init }; }; diff --git a/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts index cf37626c7..ea5efd502 100644 --- a/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts +++ b/backend/src/ee/services/dynamic-secret-lease/dynamic-secret-lease-service.ts @@ -178,7 +178,7 @@ export const dynamicSecretLeaseServiceFactory = ({ config }); - await dynamicSecretQueueService.setLeaseRevocation(dynamicSecretLease.id, expireAt); + await dynamicSecretQueueService.setLeaseRevocation(dynamicSecretLease.id, dynamicSecretCfg.id, expireAt); return { lease: dynamicSecretLease, dynamicSecret: dynamicSecretCfg, data }; }; @@ -272,7 +272,7 @@ export const dynamicSecretLeaseServiceFactory = ({ ); await dynamicSecretQueueService.unsetLeaseRevocation(dynamicSecretLease.id); - await dynamicSecretQueueService.setLeaseRevocation(dynamicSecretLease.id, expireAt); + await dynamicSecretQueueService.setLeaseRevocation(dynamicSecretLease.id, dynamicSecretCfg.id, expireAt); const updatedDynamicSecretLease = await dynamicSecretLeaseDAL.updateById(dynamicSecretLease.id, { expireAt, externalEntityId: entityId @@ -358,11 +358,13 @@ export const dynamicSecretLeaseServiceFactory = ({ if ((revokeResponse as { error?: Error })?.error) { const { error } = revokeResponse as { error?: Error }; logger.error(error?.message, "Failed to revoke lease"); - const deletedDynamicSecretLease = await dynamicSecretLeaseDAL.updateById(dynamicSecretLease.id, { + const updatedDynamicSecretLease = await dynamicSecretLeaseDAL.updateById(dynamicSecretLease.id, { status: DynamicSecretLeaseStatus.FailedDeletion, statusDetails: error?.message?.slice(0, 255) }); - return deletedDynamicSecretLease; + // queue a job to retry the revocation at a later time + await dynamicSecretQueueService.queueFailedRevocation(dynamicSecretLease.id, dynamicSecretCfg.id); + return updatedDynamicSecretLease; } await dynamicSecretQueueService.unsetLeaseRevocation(dynamicSecretLease.id); diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index d808c0181..09ff9e108 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -15,7 +15,7 @@ export const isOfflineLicenseKey = (licenseKey: string): boolean => { return "signature" in contents && "license" in contents; } catch (error) { - return true; + return false; } }; @@ -25,7 +25,7 @@ export const getLicenseKeyConfig = ( const cfg = config || getConfig(); if (!cfg) { - return { isValid: true }; + return { isValid: false }; } const licenseKey = cfg.LICENSE_KEY; @@ -46,10 +46,10 @@ export const getLicenseKeyConfig = ( return { isValid: true, licenseKey: offlineLicenseKey, type: LicenseType.Offline }; } - return { isValid: true }; + return { isValid: false }; } - return { isValid: true }; + return { isValid: false }; }; export const getDefaultOnPremFeatures = (): TFeatureSet => ({ @@ -64,56 +64,56 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ environmentsUsed: 0, identityLimit: null, identitiesUsed: 0, - dynamicSecret: true, + dynamicSecret: false, secretVersioning: true, - pitRecovery: true, - ipAllowlisting: true, - rbac: true, - githubOrgSync: true, - customRateLimits: true, - subOrganization: true, - customAlerts: true, - secretAccessInsights: true, - auditLogs: true, + pitRecovery: false, + ipAllowlisting: false, + rbac: false, + githubOrgSync: false, + customRateLimits: false, + subOrganization: false, + customAlerts: false, + secretAccessInsights: false, + auditLogs: false, auditLogsRetentionDays: 0, - auditLogStreams: true, + auditLogStreams: false, auditLogStreamLimit: 3, - samlSSO: true, - enforceGoogleSSO: true, - hsm: true, - oidcSSO: true, - scim: true, - ldap: true, - groups: true, + samlSSO: false, + enforceGoogleSSO: false, + hsm: false, + oidcSSO: false, + scim: false, + ldap: false, + groups: false, status: null, trial_end: null, has_used_trial: true, - secretApproval: true, - secretRotation: true, - caCrl: true, - instanceUserManagement: true, - externalKms: true, + secretApproval: false, + secretRotation: false, + caCrl: false, + instanceUserManagement: false, + externalKms: false, rateLimits: { readLimit: 60, writeLimit: 200, secretsLimit: 40 }, - pkiEst: true, - pkiAcme: true, - enforceMfa: true, - projectTemplates: true, - kmip: true, - gateway: true, - sshHostGroups: true, - secretScanning: true, - enterpriseSecretSyncs: true, - enterpriseCertificateSyncs: true, - enterpriseAppConnections: true, - fips: true, - eventSubscriptions: true, - machineIdentityAuthTemplates: true, - pkiLegacyTemplates: true, - pam: true + pkiEst: false, + pkiAcme: false, + enforceMfa: false, + projectTemplates: false, + kmip: false, + gateway: false, + sshHostGroups: false, + secretScanning: false, + enterpriseSecretSyncs: false, + enterpriseCertificateSyncs: false, + enterpriseAppConnections: false, + fips: false, + eventSubscriptions: false, + machineIdentityAuthTemplates: false, + pkiLegacyTemplates: false, + pam: false }); export const setupLicenseRequestWithStore = ( diff --git a/backend/src/ee/services/pki-acme/pki-acme-service.ts b/backend/src/ee/services/pki-acme/pki-acme-service.ts index 69a6a648f..89ba17dd5 100644 --- a/backend/src/ee/services/pki-acme/pki-acme-service.ts +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -683,6 +683,13 @@ export const pkiAcmeServiceFactory = ({ payload: TFinalizeAcmeOrderPayload; }): Promise> => { const profile = (await certificateProfileDAL.findByIdWithConfigs(profileId))!; + + if (!profile.caId) { + throw new BadRequestError({ + message: "Self-signed certificates are not supported for ACME enrollment" + }); + } + let order = await acmeOrderDAL.findByAccountAndOrderIdWithAuthorizations(accountId, orderId); if (!order) { throw new NotFoundError({ message: "ACME order not found" }); @@ -729,7 +736,7 @@ export const pkiAcmeServiceFactory = ({ throw new AcmeBadCSRError({ message: "Invalid CSR: Common name + SANs mismatch with order identifiers" }); } - const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId); + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId!); if (!ca) { throw new NotFoundError({ message: "Certificate Authority not found" }); } @@ -769,7 +776,9 @@ export const pkiAcmeServiceFactory = ({ const cert = await orderCertificate( { caId: certificateAuthority!.id, - commonName: certificateRequest.commonName!, + // It is possible that the CSR does not have a common name, in which case we use an empty string + // (more likely than not for a CSR from a modern ACME client like certbot, cert-manager, etc.) + commonName: certificateRequest.commonName ?? "", altNames: certificateRequest.subjectAlternativeNames?.map((san) => san.value), csr: Buffer.from(csrPem), // TODO: not 100% sure what are these columns for, but let's put the values for common website SSL certs for now diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 11de57667..21e83c2b7 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -119,6 +119,7 @@ const envSchema = z }) .default("{}") ), + DNS_MADE_EASY_SANDBOX_ENABLED: zodStrBool.default("false").optional(), // smtp options SMTP_HOST: zpStr(z.string().optional()), SMTP_IGNORE_TLS: zodStrBool.default("false"), diff --git a/backend/src/lib/delay/index.ts b/backend/src/lib/delay/index.ts index 32cb8ebfc..a5d4250fc 100644 --- a/backend/src/lib/delay/index.ts +++ b/backend/src/lib/delay/index.ts @@ -2,3 +2,13 @@ export const delay = (ms: number) => new Promise((resolve) => { setTimeout(resolve, ms); }); + +export const applyJitter = (delayMs: number) => { + const jitterFactor = 0.2; + + // generates random value in [-0.2, +0.2] range + const randomFactor = (Math.random() * 2 - 1) * jitterFactor; + const jitterAmount = randomFactor * delayMs; + + return delayMs + jitterAmount; +}; diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index 8cf8555f9..57409d173 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -61,6 +61,7 @@ export enum QueueName { SecretPushEventScan = "secret-push-event-scan", UpgradeProjectToGhost = "upgrade-project-to-ghost", DynamicSecretRevocation = "dynamic-secret-revocation", + DynamicSecretLeaseRevocationFailedEmail = "dynamic-secret-lease-revocation-failed-email", CaCrlRotation = "ca-crl-rotation", CaLifecycle = "ca-lifecycle", // parent queue to ca-order-certificate-for-subscriber SecretReplication = "secret-replication", @@ -120,6 +121,7 @@ export enum QueueJobs { SecretRotationV2RotateSecrets = "secret-rotation-v2-rotate-secrets", SecretRotationV2SendNotification = "secret-rotation-v2-send-notification", CreateFolderTreeCheckpoint = "create-folder-tree-checkpoint", + DynamicSecretLeaseRevocationFailedEmail = "dynamic-secret-lease-revocation-failed-email", InvalidateCache = "invalidate-cache", SecretScanningV2FullScan = "secret-scanning-v2-full-scan", SecretScanningV2DiffScan = "secret-scanning-v2-diff-scan", @@ -219,11 +221,19 @@ export type TQueueJobTypes = { name: QueueJobs.TelemetryInstanceStats; payload: undefined; }; + [QueueName.DynamicSecretLeaseRevocationFailedEmail]: { + name: QueueJobs.DynamicSecretLeaseRevocationFailedEmail; + payload: { + leaseId: string; + }; + }; [QueueName.DynamicSecretRevocation]: | { name: QueueJobs.DynamicSecretRevocation; payload: { + isRetry?: boolean; leaseId: string; + dynamicSecretId: string; }; } | { diff --git a/backend/src/server/plugins/serve-ui.ts b/backend/src/server/plugins/serve-ui.ts index b71451b6e..633b4211a 100644 --- a/backend/src/server/plugins/serve-ui.ts +++ b/backend/src/server/plugins/serve-ui.ts @@ -43,7 +43,9 @@ export const registerServeUI = async ( const frontendPath = path.join(dir, frontendName); await server.register(staticServe, { root: frontendPath, - wildcard: false + wildcard: false, + maxAge: "30d", + immutable: true }); server.route({ @@ -58,11 +60,12 @@ export const registerServeUI = async ( return; } - // This should help avoid caching any chunks (temp fix) - void reply.header("Cache-Control", "no-cache, no-store, must-revalidate, private, max-age=0"); - void reply.header("Pragma", "no-cache"); - void reply.header("Expires", "0"); - return reply.sendFile("index.html"); + return reply.sendFile("index.html", { + immutable: false, + maxAge: 0, + lastModified: false, + etag: false + }); } }); } diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 2b2023eb2..860912934 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1874,7 +1874,12 @@ export const registerRoutes = async ( dynamicSecretProviders, dynamicSecretDAL, folderDAL, - kmsService + kmsService, + smtpService, + userDAL, + identityDAL, + projectMembershipDAL, + projectDAL }); const dynamicSecretService = dynamicSecretServiceFactory({ projectDAL, @@ -2219,7 +2224,10 @@ export const registerRoutes = async ( permissionService, certificateSyncDAL, pkiSyncDAL, - pkiSyncQueue + pkiSyncQueue, + kmsService, + projectDAL, + certificateBodyDAL }); const certificateV3Queue = certificateV3QueueServiceFactory({ diff --git a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts index 5a3496750..48fdc7c38 100644 --- a/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts +++ b/backend/src/server/routes/v1/app-connection-routers/app-connection-router.ts @@ -61,6 +61,10 @@ import { DigitalOceanConnectionListItemSchema, SanitizedDigitalOceanConnectionSchema } from "@app/services/app-connection/digital-ocean"; +import { + DNSMadeEasyConnectionListItemSchema, + SanitizedDNSMadeEasyConnectionSchema +} from "@app/services/app-connection/dns-made-easy/dns-made-easy-connection-schema"; import { FlyioConnectionListItemSchema, SanitizedFlyioConnectionSchema } from "@app/services/app-connection/flyio"; import { GcpConnectionListItemSchema, SanitizedGcpConnectionSchema } from "@app/services/app-connection/gcp"; import { GitHubConnectionListItemSchema, SanitizedGitHubConnectionSchema } from "@app/services/app-connection/github"; @@ -170,7 +174,8 @@ const SanitizedAppConnectionSchema = z.union([ ...SanitizedAzureADCSConnectionSchema.options, ...SanitizedRedisConnectionSchema.options, ...SanitizedLaravelForgeConnectionSchema.options, - ...SanitizedChefConnectionSchema.options + ...SanitizedChefConnectionSchema.options, + ...SanitizedDNSMadeEasyConnectionSchema.options ]); const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ @@ -215,7 +220,8 @@ const AppConnectionOptionsSchema = z.discriminatedUnion("app", [ AzureADCSConnectionListItemSchema, RedisConnectionListItemSchema, LaravelForgeConnectionListItemSchema, - ChefConnectionListItemSchema + ChefConnectionListItemSchema, + DNSMadeEasyConnectionListItemSchema ]); export const registerAppConnectionRouter = async (server: FastifyZodProvider) => { diff --git a/backend/src/server/routes/v1/app-connection-routers/dns-made-easy-connection-router.ts b/backend/src/server/routes/v1/app-connection-routers/dns-made-easy-connection-router.ts new file mode 100644 index 000000000..e1e0b2860 --- /dev/null +++ b/backend/src/server/routes/v1/app-connection-routers/dns-made-easy-connection-router.ts @@ -0,0 +1,51 @@ +import z from "zod"; + +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + CreateDNSMadeEasyConnectionSchema, + SanitizedDNSMadeEasyConnectionSchema, + UpdateDNSMadeEasyConnectionSchema +} from "@app/services/app-connection/dns-made-easy/dns-made-easy-connection-schema"; +import { AuthMode } from "@app/services/auth/auth-type"; + +import { registerAppConnectionEndpoints } from "./app-connection-endpoints"; + +export const registerDNSMadeEasyConnectionRouter = async (server: FastifyZodProvider) => { + registerAppConnectionEndpoints({ + app: AppConnection.DNSMadeEasy, + server, + sanitizedResponseSchema: SanitizedDNSMadeEasyConnectionSchema, + createSchema: CreateDNSMadeEasyConnectionSchema, + updateSchema: UpdateDNSMadeEasyConnectionSchema + }); + + // The below endpoints are not exposed and for Infisical App use + server.route({ + method: "GET", + url: `/:connectionId/dns-made-easy-zones`, + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + connectionId: z.string().uuid() + }), + response: { + 200: z + .object({ + id: z.string(), + name: z.string() + }) + .array() + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { connectionId } = req.params; + const zones = await server.services.appConnection.dnsMadeEasy.listZones(connectionId, req.permission); + return zones; + } + }); +}; diff --git a/backend/src/server/routes/v1/app-connection-routers/index.ts b/backend/src/server/routes/v1/app-connection-routers/index.ts index aa1d671b6..d7a4065fd 100644 --- a/backend/src/server/routes/v1/app-connection-routers/index.ts +++ b/backend/src/server/routes/v1/app-connection-routers/index.ts @@ -16,6 +16,7 @@ import { registerCamundaConnectionRouter } from "./camunda-connection-router"; import { registerChecklyConnectionRouter } from "./checkly-connection-router"; import { registerCloudflareConnectionRouter } from "./cloudflare-connection-router"; import { registerDatabricksConnectionRouter } from "./databricks-connection-router"; +import { registerDNSMadeEasyConnectionRouter } from "./dns-made-easy-connection-router"; import { registerDigitalOceanConnectionRouter } from "./digital-ocean-connection-router"; import { registerFlyioConnectionRouter } from "./flyio-connection-router"; import { registerGcpConnectionRouter } from "./gcp-connection-router"; @@ -78,6 +79,7 @@ export const APP_CONNECTION_REGISTER_ROUTER_MAP: Record { server.route({ @@ -23,7 +23,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid body: z .object({ projectId: z.string().min(1), - caId: z.string().uuid(), + caId: z.string().uuid().optional(), certificateTemplateId: z.string().uuid(), slug: z .string() @@ -32,6 +32,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid .regex(new RE2("^[a-z0-9-]+$"), "Slug must contain only lowercase letters, numbers, and hyphens"), description: z.string().max(1000).optional(), enrollmentType: z.nativeEnum(EnrollmentType), + issuerType: z.nativeEnum(IssuerType).default(IssuerType.CA), estConfig: z .object({ disableBootstrapCaValidation: z.boolean().default(false), @@ -50,43 +51,100 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid .refine( (data) => { if (data.enrollmentType === EnrollmentType.EST) { - if (!data.estConfig) { - return false; - } - if (data.apiConfig) { - return false; - } - if (data.acmeConfig) { - return false; - } - } - if (data.enrollmentType === EnrollmentType.API) { - if (!data.apiConfig) { - return false; - } - if (data.estConfig) { - return false; - } - if (data.acmeConfig) { - return false; - } - } - if (data.enrollmentType === EnrollmentType.ACME) { - if (!data.acmeConfig) { - return false; - } - if (data.estConfig) { - return false; - } - if (data.apiConfig) { - return false; - } + return !!data.estConfig; } return true; }, { - message: - "EST enrollment type requires EST configuration and cannot have API or ACME configuration. API enrollment type requires API configuration and cannot have EST or ACME configuration. ACME enrollment type requires ACME configuration and cannot have EST or API configuration." + message: "EST enrollment type requires EST configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.API) { + return !!data.apiConfig; + } + return true; + }, + { + message: "API enrollment type requires API configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.ACME) { + return !!data.acmeConfig; + } + return true; + }, + { + message: "ACME enrollment type requires ACME configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.EST) { + return !data.apiConfig && !data.acmeConfig; + } + return true; + }, + { + message: "EST enrollment type cannot have API or ACME configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.API) { + return !data.estConfig && !data.acmeConfig; + } + return true; + }, + { + message: "API enrollment type cannot have EST or ACME configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.ACME) { + return !data.estConfig && !data.apiConfig; + } + return true; + }, + { + message: "ACME enrollment type cannot have EST or API configuration" + } + ) + .refine( + (data) => { + if (data.issuerType === IssuerType.CA) { + return !!data.caId; + } + return true; + }, + { + message: "CA issuer type requires a CA ID" + } + ) + .refine( + (data) => { + if (data.issuerType === IssuerType.SELF_SIGNED) { + return !data.caId; + } + return true; + }, + { + message: "Self-signed issuer type cannot have a CA ID" + } + ) + .refine( + (data) => { + if (data.issuerType === IssuerType.SELF_SIGNED) { + return data.enrollmentType === EnrollmentType.API; + } + return true; + }, + { + message: "Self-signed issuer type only supports API enrollment" } ), response: { @@ -115,7 +173,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid certificateProfileId: certificateProfile.id, name: certificateProfile.slug, projectId: certificateProfile.projectId, - enrollmentType: certificateProfile.enrollmentType + enrollmentType: certificateProfile.enrollmentType, + issuerType: certificateProfile.issuerType } } }); @@ -139,6 +198,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid limit: z.coerce.number().min(1).max(100).default(20), search: z.string().optional(), enrollmentType: z.nativeEnum(EnrollmentType).optional(), + issuerType: z.nativeEnum(IssuerType).optional(), caId: z.string().uuid().optional() }), response: { @@ -339,6 +399,7 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid .optional(), description: z.string().max(1000).optional(), enrollmentType: z.nativeEnum(EnrollmentType).optional(), + issuerType: z.nativeEnum(IssuerType).optional(), estConfig: z .object({ disableBootstrapCaValidation: z.boolean().default(false), diff --git a/backend/src/services/app-connection/app-connection-enums.ts b/backend/src/services/app-connection/app-connection-enums.ts index 1c184a436..8e0260c01 100644 --- a/backend/src/services/app-connection/app-connection-enums.ts +++ b/backend/src/services/app-connection/app-connection-enums.ts @@ -29,6 +29,7 @@ export enum AppConnection { Flyio = "flyio", GitLab = "gitlab", Cloudflare = "cloudflare", + DNSMadeEasy = "dns-made-easy", Zabbix = "zabbix", Railway = "railway", Bitbucket = "bitbucket", diff --git a/backend/src/services/app-connection/app-connection-fns.ts b/backend/src/services/app-connection/app-connection-fns.ts index aeef2b25b..d8af3773b 100644 --- a/backend/src/services/app-connection/app-connection-fns.ts +++ b/backend/src/services/app-connection/app-connection-fns.ts @@ -88,6 +88,11 @@ import { getDigitalOceanConnectionListItem, validateDigitalOceanConnectionCredentials } from "./digital-ocean"; +import { DNSMadeEasyConnectionMethod } from "./dns-made-easy/dns-made-easy-connection-enum"; +import { + getDNSMadeEasyConnectionListItem, + validateDNSMadeEasyConnectionCredentials +} from "./dns-made-easy/dns-made-easy-connection-fns"; import { FlyioConnectionMethod, getFlyioConnectionListItem, validateFlyioConnectionCredentials } from "./flyio"; import { GcpConnectionMethod, getGcpConnectionListItem, validateGcpConnectionCredentials } from "./gcp"; import { getGitHubConnectionListItem, GitHubConnectionMethod, validateGitHubConnectionCredentials } from "./github"; @@ -171,7 +176,8 @@ const PKI_APP_CONNECTIONS = [ AppConnection.Cloudflare, AppConnection.AzureADCS, AppConnection.AzureKeyVault, - AppConnection.Chef + AppConnection.Chef, + AppConnection.DNSMadeEasy ]; export const listAppConnectionOptions = (projectType?: ProjectType) => { @@ -207,6 +213,7 @@ export const listAppConnectionOptions = (projectType?: ProjectType) => { getFlyioConnectionListItem(), getGitLabConnectionListItem(), getCloudflareConnectionListItem(), + getDNSMadeEasyConnectionListItem(), getZabbixConnectionListItem(), getRailwayConnectionListItem(), getBitbucketConnectionListItem(), @@ -339,6 +346,7 @@ export const validateAppConnectionCredentials = async ( [AppConnection.Flyio]: validateFlyioConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.GitLab]: validateGitLabConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Cloudflare]: validateCloudflareConnectionCredentials as TAppConnectionCredentialsValidator, + [AppConnection.DNSMadeEasy]: validateDNSMadeEasyConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Zabbix]: validateZabbixConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Railway]: validateRailwayConnectionCredentials as TAppConnectionCredentialsValidator, [AppConnection.Bitbucket]: validateBitbucketConnectionCredentials as TAppConnectionCredentialsValidator, @@ -395,6 +403,8 @@ export const getAppConnectionMethodName = (method: TAppConnection["method"]) => case OktaConnectionMethod.ApiToken: case LaravelForgeConnectionMethod.ApiToken: return "API Token"; + case DNSMadeEasyConnectionMethod.APIKeySecret: + return "API Key & Secret"; case PostgresConnectionMethod.UsernameAndPassword: case MsSqlConnectionMethod.UsernameAndPassword: case MySqlConnectionMethod.UsernameAndPassword: @@ -483,6 +493,7 @@ export const TRANSITION_CONNECTION_CREDENTIALS_TO_PLATFORM: Record< [AppConnection.Flyio]: platformManagedCredentialsNotSupported, [AppConnection.GitLab]: platformManagedCredentialsNotSupported, [AppConnection.Cloudflare]: platformManagedCredentialsNotSupported, + [AppConnection.DNSMadeEasy]: platformManagedCredentialsNotSupported, [AppConnection.Zabbix]: platformManagedCredentialsNotSupported, [AppConnection.Railway]: platformManagedCredentialsNotSupported, [AppConnection.Bitbucket]: platformManagedCredentialsNotSupported, diff --git a/backend/src/services/app-connection/app-connection-maps.ts b/backend/src/services/app-connection/app-connection-maps.ts index 5b8cc3fc1..27d6a27a8 100644 --- a/backend/src/services/app-connection/app-connection-maps.ts +++ b/backend/src/services/app-connection/app-connection-maps.ts @@ -32,6 +32,7 @@ export const APP_CONNECTION_NAME_MAP: Record = { [AppConnection.Flyio]: "Fly.io", [AppConnection.GitLab]: "GitLab", [AppConnection.Cloudflare]: "Cloudflare", + [AppConnection.DNSMadeEasy]: "DNS Made Easy", [AppConnection.Zabbix]: "Zabbix", [AppConnection.Railway]: "Railway", [AppConnection.Bitbucket]: "Bitbucket", @@ -77,6 +78,7 @@ export const APP_CONNECTION_PLAN_MAP: Record; + page: number; +} + +export const getDNSMadeEasyUrl = (path: string) => { + const appCfg = getConfig(); + return `${appCfg.DNS_MADE_EASY_SANDBOX_ENABLED ? IntegrationUrls.DNS_MADE_EASY_SANDBOX_API_URL : IntegrationUrls.DNS_MADE_EASY_API_URL}${path}`; +}; + +export const makeDNSMadeEasyAuthHeaders = ( + apiKey: string, + secretKey: string, + currentDate?: Date +): Record => { + // Format date as "Day, DD Mon YYYY HH:MM:SS GMT" (e.g., "Mon, 01 Jan 2024 12:00:00 GMT") + const requestDate = (currentDate ?? new Date()).toUTCString(); + + // Generate HMAC-SHA1 signature + const hmac = crypto.nativeCrypto.createHmac("sha1", secretKey); + hmac.update(requestDate); + const hmacSignature = hmac.digest("hex"); + + return { + "x-dnsme-apiKey": apiKey, + "x-dnsme-hmac": hmacSignature, + "x-dnsme-requestDate": requestDate + }; +}; + +export const getDNSMadeEasyConnectionListItem = () => { + return { + name: "DNS Made Easy" as const, + app: AppConnection.DNSMadeEasy as const, + methods: Object.values(DNSMadeEasyConnectionMethod) as [DNSMadeEasyConnectionMethod.APIKeySecret] + }; +}; + +export const listDNSMadeEasyZones = async (appConnection: TDNSMadeEasyConnection): Promise => { + if (appConnection.method !== DNSMadeEasyConnectionMethod.APIKeySecret) { + throw new BadRequestError({ message: "Unsupported DNS Made Easy connection method" }); + } + + const { + credentials: { apiKey, secretKey } + } = appConnection; + + try { + const allZones: TDNSMadeEasyZone[] = []; + let currentPage = 0; + let totalPages = 1; + + // Fetch all pages of zones + while (currentPage < totalPages) { + // eslint-disable-next-line no-await-in-loop + const resp = await request.get(getDNSMadeEasyUrl("/V2.0/dns/managed/"), { + headers: { + ...makeDNSMadeEasyAuthHeaders(apiKey, secretKey), + Accept: "application/json" + }, + params: { + page: currentPage + } + }); + + if (resp.data?.data) { + // Map the API response to TDNSMadeEasyZone format + const zones = resp.data.data.map((zone) => ({ + id: String(zone.id), + name: zone.name + })); + allZones.push(...zones); + + // Update pagination info + totalPages = resp.data.totalPages || 1; + currentPage += 1; + } else { + break; + } + } + + return allZones; + } catch (error: unknown) { + logger.error(error, "Error listing DNS Made Easy zones"); + if (error instanceof AxiosError) { + throw new BadRequestError({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + message: `Failed to list DNS Made Easy zones: ${error.response?.data?.error?.[0] || error.message || "Unknown error"}` + }); + } + throw new BadRequestError({ + message: "Unable to list DNS Made Easy zones" + }); + } +}; + +export const listDNSMadeEasyRecords = async ( + appConnection: TDNSMadeEasyConnection, + options: { zoneId: string; type?: string; name?: string } +): Promise => { + if (appConnection.method !== DNSMadeEasyConnectionMethod.APIKeySecret) { + throw new BadRequestError({ message: "Unsupported DNS Made Easy connection method" }); + } + const { + credentials: { apiKey, secretKey } + } = appConnection; + const { zoneId, type, name } = options; + + try { + const allRecords: DNSMadeEasyApiResponse["data"] = []; + let currentPage = 0; + let totalPages = 1; + + // Fetch all pages of records + while (currentPage < totalPages) { + // Build query parameters + const queryParams: Record = {}; + if (type) { + queryParams.type = type; + } + if (name) { + queryParams.recordName = name; + } + queryParams.page = currentPage; + + // eslint-disable-next-line no-await-in-loop + const resp = await request.get( + getDNSMadeEasyUrl(`/V2.0/dns/managed/${encodeURIComponent(zoneId)}/records`), + { + headers: { + ...makeDNSMadeEasyAuthHeaders(apiKey, secretKey), + Accept: "application/json" + }, + params: queryParams + } + ); + + if (resp.data?.data) { + allRecords.push(...resp.data.data); + + // Update pagination info + totalPages = resp.data.totalPages || 1; + currentPage += 1; + } else { + break; + } + } + + return allRecords; + } catch (error: unknown) { + logger.error(error, "Error listing DNS Made Easy records"); + if (error instanceof AxiosError) { + throw new BadRequestError({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + message: `Failed to list DNS Made Easy records: ${error.response?.data?.error?.[0] || error.message || "Unknown error"}` + }); + } + throw new BadRequestError({ + message: "Unable to list DNS Made Easy records" + }); + } +}; + +export const validateDNSMadeEasyConnectionCredentials = async (config: TDNSMadeEasyConnectionConfig) => { + if (config.method !== DNSMadeEasyConnectionMethod.APIKeySecret) { + throw new BadRequestError({ message: "Unsupported DNS Made Easy connection method" }); + } + + const { apiKey, secretKey } = config.credentials; + + try { + const resp = await request.get(getDNSMadeEasyUrl("/V2.0/dns/managed/"), { + headers: { + ...makeDNSMadeEasyAuthHeaders(apiKey, secretKey), + Accept: "application/json" + } + }); + if (resp.status !== 200) { + throw new BadRequestError({ + message: "Unable to validate connection: Invalid API credentials provided." + }); + } + } catch (error: unknown) { + if (error instanceof AxiosError) { + throw new BadRequestError({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + message: `Failed to validate credentials: ${error.response?.data?.error?.[0] || error.message || "Unknown error"}` + }); + } + logger.error(error, "Error validating DNS Made Easy connection credentials"); + throw new BadRequestError({ + message: "Unable to validate connection: verify credentials" + }); + } + + return config.credentials; +}; diff --git a/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-schema.ts b/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-schema.ts new file mode 100644 index 000000000..d968ba768 --- /dev/null +++ b/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-schema.ts @@ -0,0 +1,64 @@ +import z from "zod"; + +import { AppConnections } from "@app/lib/api-docs"; +import { AppConnection } from "@app/services/app-connection/app-connection-enums"; +import { + BaseAppConnectionSchema, + GenericCreateAppConnectionFieldsSchema, + GenericUpdateAppConnectionFieldsSchema +} from "@app/services/app-connection/app-connection-schemas"; + +import { APP_CONNECTION_NAME_MAP } from "../app-connection-maps"; +import { DNSMadeEasyConnectionMethod } from "./dns-made-easy-connection-enum"; + +export const DNSMadeEasyConnectionApiKeyCredentialsSchema = z.object({ + apiKey: z.string().trim().min(1, "API key required").max(256, "API key cannot exceed 256 characters"), + secretKey: z.string().trim().min(1, "Secret key required").max(256, "Secret key cannot exceed 256 characters") +}); + +const BaseDNSMadeEasyConnectionSchema = BaseAppConnectionSchema.extend({ + app: z.literal(AppConnection.DNSMadeEasy) +}); + +export const DNSMadeEasyConnectionSchema = BaseDNSMadeEasyConnectionSchema.extend({ + method: z.literal(DNSMadeEasyConnectionMethod.APIKeySecret), + credentials: DNSMadeEasyConnectionApiKeyCredentialsSchema +}); + +export const SanitizedDNSMadeEasyConnectionSchema = z.discriminatedUnion("method", [ + BaseDNSMadeEasyConnectionSchema.extend({ + method: z.literal(DNSMadeEasyConnectionMethod.APIKeySecret), + credentials: DNSMadeEasyConnectionApiKeyCredentialsSchema.pick({ apiKey: true }) + }).describe(JSON.stringify({ title: `${APP_CONNECTION_NAME_MAP[AppConnection.DNSMadeEasy]} (API Key)` })) +]); + +export const ValidateDNSMadeEasyConnectionCredentialsSchema = z.discriminatedUnion("method", [ + z.object({ + method: z + .literal(DNSMadeEasyConnectionMethod.APIKeySecret) + .describe(AppConnections.CREATE(AppConnection.DNSMadeEasy).method), + credentials: DNSMadeEasyConnectionApiKeyCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.DNSMadeEasy).credentials + ) + }) +]); + +export const CreateDNSMadeEasyConnectionSchema = ValidateDNSMadeEasyConnectionCredentialsSchema.and( + GenericCreateAppConnectionFieldsSchema(AppConnection.DNSMadeEasy) +); + +export const UpdateDNSMadeEasyConnectionSchema = z + .object({ + credentials: DNSMadeEasyConnectionApiKeyCredentialsSchema.optional().describe( + AppConnections.UPDATE(AppConnection.DNSMadeEasy).credentials + ) + }) + .and(GenericUpdateAppConnectionFieldsSchema(AppConnection.DNSMadeEasy)); + +export const DNSMadeEasyConnectionListItemSchema = z + .object({ + name: z.literal("DNS Made Easy"), + app: z.literal(AppConnection.DNSMadeEasy), + methods: z.nativeEnum(DNSMadeEasyConnectionMethod).array() + }) + .describe(JSON.stringify({ title: APP_CONNECTION_NAME_MAP[AppConnection.DNSMadeEasy] })); diff --git a/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-service.ts b/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-service.ts new file mode 100644 index 000000000..b50c9b73c --- /dev/null +++ b/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-service.ts @@ -0,0 +1,35 @@ +import { BadRequestError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; +import { OrgServiceActor } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { listDNSMadeEasyZones } from "./dns-made-easy-connection-fns"; +import { TDNSMadeEasyConnection } from "./dns-made-easy-connection-types"; + +type TGetAppConnectionFunc = ( + app: AppConnection, + connectionId: string, + actor: OrgServiceActor +) => Promise; + +export const dnsMadeEasyConnectionService = (getAppConnection: TGetAppConnectionFunc) => { + const listZones = async (connectionId: string, actor: OrgServiceActor) => { + const appConnection = await getAppConnection(AppConnection.DNSMadeEasy, connectionId, actor); + try { + const zones = await listDNSMadeEasyZones(appConnection); + return zones; + } catch (error) { + logger.error( + error, + `Failed to list DNS Made Easy zones for DNS Made Easy connection [connectionId=${connectionId}]` + ); + throw new BadRequestError({ + message: `Failed to list DNS Made Easy zones: ${error instanceof Error ? error.message : "Unknown error"}` + }); + } + }; + + return { + listZones + }; +}; diff --git a/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-types.ts b/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-types.ts new file mode 100644 index 000000000..eff96f6f9 --- /dev/null +++ b/backend/src/services/app-connection/dns-made-easy/dns-made-easy-connection-types.ts @@ -0,0 +1,30 @@ +import z from "zod"; + +import { DiscriminativePick } from "@app/lib/types"; + +import { AppConnection } from "../app-connection-enums"; +import { + CreateDNSMadeEasyConnectionSchema, + DNSMadeEasyConnectionSchema, + ValidateDNSMadeEasyConnectionCredentialsSchema +} from "./dns-made-easy-connection-schema"; + +export type TDNSMadeEasyConnection = z.infer; + +export type TDNSMadeEasyConnectionInput = z.infer & { + app: AppConnection.DNSMadeEasy; +}; + +export type TValidateDNSMadeEasyConnectionCredentialsSchema = typeof ValidateDNSMadeEasyConnectionCredentialsSchema; + +export type TDNSMadeEasyConnectionConfig = DiscriminativePick< + TDNSMadeEasyConnectionInput, + "method" | "app" | "credentials" +> & { + orgId: string; +}; + +export type TDNSMadeEasyZone = { + id: string; + name: string; +}; diff --git a/backend/src/services/certificate-authority/acme/acme-certificate-authority-enums.ts b/backend/src/services/certificate-authority/acme/acme-certificate-authority-enums.ts index c4703d49f..09431f4f8 100644 --- a/backend/src/services/certificate-authority/acme/acme-certificate-authority-enums.ts +++ b/backend/src/services/certificate-authority/acme/acme-certificate-authority-enums.ts @@ -1,4 +1,5 @@ export enum AcmeDnsProvider { Route53 = "route53", - Cloudflare = "cloudflare" + Cloudflare = "cloudflare", + DNSMadeEasy = "dns-made-easy" } diff --git a/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts b/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts index ff95083c6..db4340f4e 100644 --- a/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts +++ b/backend/src/services/certificate-authority/acme/acme-certificate-authority-fns.ts @@ -14,6 +14,7 @@ import { decryptAppConnection } from "@app/services/app-connection/app-connectio import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; import { TAwsConnection } from "@app/services/app-connection/aws/aws-connection-types"; import { TCloudflareConnection } from "@app/services/app-connection/cloudflare/cloudflare-connection-types"; +import { TDNSMadeEasyConnection } from "@app/services/app-connection/dns-made-easy/dns-made-easy-connection-types"; import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal"; @@ -43,6 +44,7 @@ import { TUpdateAcmeCertificateAuthorityDTO } from "./acme-certificate-authority-types"; import { cloudflareDeleteTxtRecord, cloudflareInsertTxtRecord } from "./dns-providers/cloudflare"; +import { dnsMadeEasyDeleteTxtRecord, dnsMadeEasyInsertTxtRecord } from "./dns-providers/dns-made-easy"; import { route53DeleteTxtRecord, route53InsertTxtRecord } from "./dns-providers/route54"; type TAcmeCertificateAuthorityFnsDeps = { @@ -120,6 +122,22 @@ export const castDbEntryToAcmeCertificateAuthority = ( }; }; +const getAcmeChallengeRecord = ( + provider: AcmeDnsProvider, + identifierValue: string, + keyAuthorization: string +): { recordName: string; recordValue: string } => { + let recordName: string; + if (provider === AcmeDnsProvider.DNSMadeEasy) { + // For DNS Made Easy, we don't need to provide the domain name in the record name. + recordName = "_acme-challenge"; + } else { + recordName = `_acme-challenge.${identifierValue}`; // e.g., "_acme-challenge.example.com" + } + const recordValue = `"${keyAuthorization}"`; // must be double quoted + return { recordName, recordValue }; +}; + export const orderCertificate = async ( { caId, @@ -241,8 +259,11 @@ export const orderCertificate = async ( throw new Error("Unsupported challenge type"); } - const recordName = `_acme-challenge.${authz.identifier.value}`; // e.g., "_acme-challenge.example.com" - const recordValue = `"${keyAuthorization}"`; // must be double quoted + const { recordName, recordValue } = getAcmeChallengeRecord( + acmeCa.configuration.dnsProviderConfig.provider, + authz.identifier.value, + keyAuthorization + ); switch (acmeCa.configuration.dnsProviderConfig.provider) { case AcmeDnsProvider.Route53: { @@ -263,14 +284,26 @@ export const orderCertificate = async ( ); break; } + case AcmeDnsProvider.DNSMadeEasy: { + await dnsMadeEasyInsertTxtRecord( + connection as TDNSMadeEasyConnection, + acmeCa.configuration.dnsProviderConfig.hostedZoneId, + recordName, + recordValue + ); + break; + } default: { throw new Error(`Unsupported DNS provider: ${acmeCa.configuration.dnsProviderConfig.provider as string}`); } } }, challengeRemoveFn: async (authz, challenge, keyAuthorization) => { - const recordName = `_acme-challenge.${authz.identifier.value}`; // e.g., "_acme-challenge.example.com" - const recordValue = `"${keyAuthorization}"`; // must be double quoted + const { recordName, recordValue } = getAcmeChallengeRecord( + acmeCa.configuration.dnsProviderConfig.provider, + authz.identifier.value, + keyAuthorization + ); switch (acmeCa.configuration.dnsProviderConfig.provider) { case AcmeDnsProvider.Route53: { @@ -291,6 +324,15 @@ export const orderCertificate = async ( ); break; } + case AcmeDnsProvider.DNSMadeEasy: { + await dnsMadeEasyDeleteTxtRecord( + connection as TDNSMadeEasyConnection, + acmeCa.configuration.dnsProviderConfig.hostedZoneId, + recordName, + recordValue + ); + break; + } default: { throw new Error(`Unsupported DNS provider: ${acmeCa.configuration.dnsProviderConfig.provider as string}`); } @@ -413,6 +455,12 @@ export const AcmeCertificateAuthorityFns = ({ }); } + if (dnsProviderConfig.provider === AcmeDnsProvider.DNSMadeEasy && appConnection.app !== AppConnection.DNSMadeEasy) { + throw new BadRequestError({ + message: `App connection with ID '${dnsAppConnectionId}' is not a DNS Made Easy connection` + }); + } + // validates permission to connect await appConnectionService.validateAppConnectionUsageById( appConnection.app as AppConnection, @@ -508,6 +556,15 @@ export const AcmeCertificateAuthorityFns = ({ }); } + if ( + dnsProviderConfig.provider === AcmeDnsProvider.DNSMadeEasy && + appConnection.app !== AppConnection.DNSMadeEasy + ) { + throw new BadRequestError({ + message: `App connection with ID '${dnsAppConnectionId}' is not a DNS Made Easy connection` + }); + } + const ca = await certificateAuthorityDAL.findById(id); if (!ca) { diff --git a/backend/src/services/certificate-authority/acme/dns-providers/dns-made-easy.ts b/backend/src/services/certificate-authority/acme/dns-providers/dns-made-easy.ts new file mode 100644 index 000000000..cbfb26a2e --- /dev/null +++ b/backend/src/services/certificate-authority/acme/dns-providers/dns-made-easy.ts @@ -0,0 +1,106 @@ +import axios from "axios"; + +import { request } from "@app/lib/config/request"; +import { logger } from "@app/lib/logger"; +import { + getDNSMadeEasyUrl, + listDNSMadeEasyRecords, + makeDNSMadeEasyAuthHeaders +} from "@app/services/app-connection/dns-made-easy/dns-made-easy-connection-fns"; +import { TDNSMadeEasyConnection } from "@app/services/app-connection/dns-made-easy/dns-made-easy-connection-types"; + +export const dnsMadeEasyInsertTxtRecord = async ( + connection: TDNSMadeEasyConnection, + hostedZoneId: string, + domain: string, + value: string +) => { + const { + credentials: { apiKey, secretKey } + } = connection; + + logger.info({ hostedZoneId, domain, value }, "Inserting TXT record for DNS Made Easy"); + try { + await request.post( + getDNSMadeEasyUrl(`/V2.0/dns/managed/${encodeURIComponent(hostedZoneId)}/records`), + { + type: "TXT", + name: domain, + value, + ttl: 60 + }, + { + headers: { + ...makeDNSMadeEasyAuthHeaders(apiKey, secretKey), + "Content-Type": "application/json", + Accept: "application/json" + } + } + ); + } catch (error) { + if (axios.isAxiosError(error)) { + const errorMessage = + (error.response?.data as { error?: string[] | string })?.error?.[0] || + (error.response?.data as { error?: string[] | string })?.error || + error.message || + "Unknown error"; + + if (error.status === 400 && error.message.includes("already exists")) { + logger.info({ domain, value }, `Record already exists for domain: ${domain} and value: ${value}`); + return; + } + + throw new Error(typeof errorMessage === "string" ? errorMessage : String(errorMessage)); + } + throw error; + } +}; + +export const dnsMadeEasyDeleteTxtRecord = async ( + connection: TDNSMadeEasyConnection, + hostedZoneId: string, + domain: string, + value: string +) => { + const { + credentials: { apiKey, secretKey } + } = connection; + + logger.info({ hostedZoneId, domain, value }, "Deleting TXT record for DNS Made Easy"); + try { + const dnsRecords = await listDNSMadeEasyRecords(connection, { zoneId: hostedZoneId, type: "TXT", name: domain }); + + let foundRecord = false; + if (dnsRecords.length > 0) { + const recordToDelete = dnsRecords.find( + (record) => record.type === "TXT" && record.name === domain && record.value === value + ); + + if (recordToDelete) { + await request.delete( + getDNSMadeEasyUrl(`/V2.0/dns/managed/${encodeURIComponent(hostedZoneId)}/records/${recordToDelete.id}`), + { + headers: { + ...makeDNSMadeEasyAuthHeaders(apiKey, secretKey), + Accept: "application/json" + } + } + ); + foundRecord = true; + } + } + if (!foundRecord) { + logger.warn({ hostedZoneId, domain, value }, "Record to delete not found"); + } + } catch (error) { + if (axios.isAxiosError(error)) { + const errorMessage = + (error.response?.data as { error?: string[] | string })?.error?.[0] || + (error.response?.data as { error?: string[] | string })?.error || + error.message || + "Unknown error"; + throw new Error(typeof errorMessage === "string" ? errorMessage : String(errorMessage)); + } + throw error; + } +}; diff --git a/backend/src/services/certificate-est-v3/certificate-est-v3-service.ts b/backend/src/services/certificate-est-v3/certificate-est-v3-service.ts index 0d8ef30d0..2499a18d2 100644 --- a/backend/src/services/certificate-est-v3/certificate-est-v3-service.ts +++ b/backend/src/services/certificate-est-v3/certificate-est-v3-service.ts @@ -67,6 +67,12 @@ export const certificateEstV3ServiceFactory = ({ throw new BadRequestError({ message: "EST enrollment not configured for this profile" }); } + if (!profile.caId) { + throw new BadRequestError({ + message: "Self-signed certificates are not supported for EST enrollment" + }); + } + const estConfig = await estEnrollmentConfigDAL.findById(profile.estConfigId); if (!estConfig) { throw new NotFoundError({ message: "EST configuration not found" }); @@ -169,6 +175,12 @@ export const certificateEstV3ServiceFactory = ({ throw new BadRequestError({ message: "EST enrollment not configured for this profile" }); } + if (!profile.caId) { + throw new BadRequestError({ + message: "Self-signed certificates are not supported for EST enrollment" + }); + } + const estConfig = await estEnrollmentConfigDAL.findById(profile.estConfigId); if (!estConfig) { throw new NotFoundError({ message: "EST configuration not found" }); @@ -281,6 +293,12 @@ export const certificateEstV3ServiceFactory = ({ throw new BadRequestError({ message: "EST enrollment not configured for this profile" }); } + if (!profile.caId) { + throw new BadRequestError({ + message: "Self-signed certificates are not supported for EST enrollment" + }); + } + const estConfig = await estEnrollmentConfigDAL.findById(profile.estConfigId); if (!estConfig) { throw new NotFoundError({ message: "EST configuration not found" }); diff --git a/backend/src/services/certificate-profile/certificate-profile-dal.ts b/backend/src/services/certificate-profile/certificate-profile-dal.ts index d2f468248..dc74e2ea4 100644 --- a/backend/src/services/certificate-profile/certificate-profile-dal.ts +++ b/backend/src/services/certificate-profile/certificate-profile-dal.ts @@ -7,6 +7,7 @@ import { ormify, selectAllTableCols } from "@app/lib/knex"; import { EnrollmentType, + IssuerType, TCertificateProfile, TCertificateProfileCertificate, TCertificateProfileInsert, @@ -198,6 +199,7 @@ export const certificateProfileDALFactory = (db: TDbClient) => { slug: result.slug, description: result.description, enrollmentType: result.enrollmentType as EnrollmentType, + issuerType: result.issuerType as IssuerType, estConfigId: result.estConfigId, apiConfigId: result.apiConfigId, acmeConfigId: result.acmeConfigId, @@ -239,12 +241,13 @@ export const certificateProfileDALFactory = (db: TDbClient) => { limit?: number; search?: string; enrollmentType?: EnrollmentType; + issuerType?: IssuerType; caId?: string; } = {}, tx?: Knex ): Promise => { try { - const { offset = 0, limit = 20, search, enrollmentType, caId } = options; + const { offset = 0, limit = 20, search, enrollmentType, issuerType, caId } = options; let baseQuery = (tx || db)(TableName.PkiCertificateProfile).where( `${TableName.PkiCertificateProfile}.projectId`, @@ -269,6 +272,10 @@ export const certificateProfileDALFactory = (db: TDbClient) => { baseQuery = baseQuery.where(`${TableName.PkiCertificateProfile}.caId`, caId); } + if (issuerType) { + baseQuery = baseQuery.where(`${TableName.PkiCertificateProfile}.issuerType`, issuerType); + } + const query = baseQuery .leftJoin( TableName.PkiEstEnrollmentConfig, @@ -338,8 +345,10 @@ export const certificateProfileDALFactory = (db: TDbClient) => { slug: result.slug, description: result.description, enrollmentType: result.enrollmentType as EnrollmentType, + issuerType: result.issuerType as IssuerType, estConfigId: result.estConfigId, apiConfigId: result.apiConfigId, + acmeConfigId: result.acmeConfigId, createdAt: result.createdAt, updatedAt: result.updatedAt, estConfig, @@ -359,12 +368,13 @@ export const certificateProfileDALFactory = (db: TDbClient) => { options: { search?: string; enrollmentType?: EnrollmentType; + issuerType?: IssuerType; caId?: string; } = {}, tx?: Knex ): Promise => { try { - const { search, enrollmentType, caId } = options; + const { search, enrollmentType, issuerType, caId } = options; let query = (tx || db)(TableName.PkiCertificateProfile).where({ projectId }); @@ -384,6 +394,10 @@ export const certificateProfileDALFactory = (db: TDbClient) => { query = query.where({ caId }); } + if (issuerType) { + query = query.where({ issuerType }); + } + const result = await query.count("*").first(); return parseInt((result as unknown as { count: string }).count || "0", 10); } catch (error) { diff --git a/backend/src/services/certificate-profile/certificate-profile-schemas.ts b/backend/src/services/certificate-profile/certificate-profile-schemas.ts index bf88593bd..e6b574dea 100644 --- a/backend/src/services/certificate-profile/certificate-profile-schemas.ts +++ b/backend/src/services/certificate-profile/certificate-profile-schemas.ts @@ -1,12 +1,13 @@ import RE2 from "re2"; import { z } from "zod"; -import { EnrollmentType } from "./certificate-profile-types"; +import { CertStatus } from "../certificate/certificate-types"; +import { EnrollmentType, IssuerType } from "./certificate-profile-types"; export const createCertificateProfileSchema = z .object({ projectId: z.string().uuid("Project ID must be valid"), - caId: z.string().uuid(), + caId: z.string().uuid().nullable().optional(), certificateTemplateId: z.string().uuid(), slug: z .string() @@ -15,6 +16,7 @@ export const createCertificateProfileSchema = z .regex(new RE2("^[a-z0-9-]+$"), "Slug must contain only lowercase letters, numbers, and hyphens"), description: z.string().max(1000).optional(), enrollmentType: z.nativeEnum(EnrollmentType), + issuerType: z.nativeEnum(IssuerType).default(IssuerType.CA), estConfig: z .object({ disableBootstrapCaValidation: z.boolean().default(false), @@ -33,43 +35,100 @@ export const createCertificateProfileSchema = z .refine( (data) => { if (data.enrollmentType === EnrollmentType.EST) { - if (!data.estConfig) { - return false; - } - if (data.apiConfig) { - return false; - } - if (data.acmeConfig) { - return false; - } - } - if (data.enrollmentType === EnrollmentType.API) { - if (!data.apiConfig) { - return false; - } - if (data.estConfig) { - return false; - } - if (data.acmeConfig) { - return false; - } - } - if (data.enrollmentType === EnrollmentType.ACME) { - if (!data.acmeConfig) { - return false; - } - if (data.estConfig) { - return false; - } - if (data.apiConfig) { - return false; - } + return !!data.estConfig; } return true; }, { - message: - "EST enrollment type requires EST configuration and cannot have API configuration. API enrollment type requires API configuration and cannot have EST configuration." + message: "EST enrollment type requires EST configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.API) { + return !!data.apiConfig; + } + return true; + }, + { + message: "API enrollment type requires API configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.ACME) { + return !!data.acmeConfig; + } + return true; + }, + { + message: "ACME enrollment type requires ACME configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.EST) { + return !data.apiConfig && !data.acmeConfig; + } + return true; + }, + { + message: "EST enrollment type cannot have API or ACME configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.API) { + return !data.estConfig && !data.acmeConfig; + } + return true; + }, + { + message: "API enrollment type cannot have EST or ACME configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.ACME) { + return !data.estConfig && !data.apiConfig; + } + return true; + }, + { + message: "ACME enrollment type cannot have EST or API configuration" + } + ) + .refine( + (data) => { + if (data.issuerType === IssuerType.CA) { + return !!data.caId; + } + return true; + }, + { + message: "CA issuer type requires a CA ID" + } + ) + .refine( + (data) => { + if (data.issuerType === IssuerType.SELF_SIGNED) { + return !data.caId; + } + return true; + }, + { + message: "Self-signed issuer type cannot have a CA ID" + } + ) + .refine( + (data) => { + if (data.issuerType === IssuerType.SELF_SIGNED) { + return data.enrollmentType === EnrollmentType.API; + } + return true; + }, + { + message: "Self-signed issuer type only supports API enrollment" } ); @@ -83,6 +142,7 @@ export const updateCertificateProfileSchema = z .optional(), description: z.string().max(1000).optional(), enrollmentType: z.nativeEnum(EnrollmentType).optional(), + issuerType: z.nativeEnum(IssuerType).optional(), estConfig: z .object({ disableBootstrapCaValidation: z.boolean().default(false), @@ -100,19 +160,34 @@ export const updateCertificateProfileSchema = z .refine( (data) => { if (data.enrollmentType === EnrollmentType.EST) { - if (data.apiConfig) { - return false; - } - } - if (data.enrollmentType === EnrollmentType.API) { - if (data.estConfig) { - return false; - } + return !data.apiConfig; } return true; }, { - message: "Cannot have EST config with API enrollment type or API config with EST enrollment type." + message: "EST enrollment type cannot have API configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.API) { + return !data.estConfig; + } + return true; + }, + { + message: "API enrollment type cannot have EST configuration" + } + ) + .refine( + (data) => { + if (data.issuerType === IssuerType.SELF_SIGNED) { + return !data.enrollmentType || data.enrollmentType === EnrollmentType.API; + } + return true; + }, + { + message: "Self-signed issuer type only supports API enrollment" } ); @@ -131,6 +206,7 @@ export const listCertificateProfilesSchema = z.object({ limit: z.coerce.number().min(1).max(100).default(20), search: z.string().optional(), enrollmentType: z.nativeEnum(EnrollmentType).optional(), + issuerType: z.nativeEnum(IssuerType).optional(), caId: z.string().uuid().optional() }); @@ -142,6 +218,6 @@ export const listCertificatesByProfileSchema = z.object({ profileId: z.string().uuid(), offset: z.coerce.number().min(0).default(0), limit: z.coerce.number().min(1).max(100).default(20), - status: z.enum(["active", "expired", "revoked"]).optional(), + status: z.nativeEnum(CertStatus).optional(), search: z.string().optional() }); diff --git a/backend/src/services/certificate-profile/certificate-profile-service.test.ts b/backend/src/services/certificate-profile/certificate-profile-service.test.ts index 3b75c1088..122dde8cd 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.test.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.test.ts @@ -22,7 +22,12 @@ import type { TKmsServiceFactory } from "../kms/kms-service"; import type { TProjectDALFactory } from "../project/project-dal"; import type { TCertificateProfileDALFactory } from "./certificate-profile-dal"; import { certificateProfileServiceFactory, TCertificateProfileServiceFactory } from "./certificate-profile-service"; -import { EnrollmentType, TCertificateProfile, TCertificateProfileWithConfigs } from "./certificate-profile-types"; +import { + EnrollmentType, + IssuerType, + TCertificateProfile, + TCertificateProfileWithConfigs +} from "./certificate-profile-types"; vi.mock("@app/lib/crypto/cryptography", () => ({ crypto: { @@ -90,6 +95,7 @@ describe("CertificateProfileService", () => { description: "Test certificate profile", slug: "test-profile", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", apiConfigId: "api-config-123", @@ -272,6 +278,7 @@ describe("CertificateProfileService", () => { slug: "new-profile", description: "New test profile", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", apiConfig: { @@ -312,6 +319,7 @@ describe("CertificateProfileService", () => { slug: "new-profile", description: "New test profile", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", apiConfigId: "api-config-123", @@ -383,6 +391,7 @@ describe("CertificateProfileService", () => { slug: "invalid-profile", description: "Invalid test profile", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123" }; @@ -401,6 +410,7 @@ describe("CertificateProfileService", () => { slug: "api-profile", description: "Profile with API enrollment", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", apiConfig: { @@ -726,6 +736,7 @@ describe("CertificateProfileService", () => { slug: "est-profile", description: "Profile with EST enrollment", enrollmentType: EnrollmentType.EST, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", estConfig: { @@ -776,6 +787,7 @@ describe("CertificateProfileService", () => { slug: "different-profile-name", description: "Profile with duplicate slug", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", apiConfig: { @@ -801,6 +813,7 @@ describe("CertificateProfileService", () => { slug: "auto-renew-profile", description: "Profile with auto-renewal", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", apiConfig: { @@ -965,6 +978,7 @@ describe("CertificateProfileService", () => { slug: "invalid-template-profile", description: "Profile with invalid template", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "nonexistent-template", apiConfig: { @@ -990,6 +1004,7 @@ describe("CertificateProfileService", () => { slug: "concurrent-profile", description: "Profile created concurrently", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", apiConfig: { @@ -1018,6 +1033,7 @@ describe("CertificateProfileService", () => { slug: "cross-project-profile", description: "Profile using template from different project", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-456", apiConfig: { @@ -1047,6 +1063,7 @@ describe("CertificateProfileService", () => { slug: "invalid-slug-profile", description: "Profile with invalid slug format", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", apiConfig: { diff --git a/backend/src/services/certificate-profile/certificate-profile-service.ts b/backend/src/services/certificate-profile/certificate-profile-service.ts index 12e272ad6..0ce3ca9b6 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.ts @@ -32,6 +32,7 @@ import { getProjectKmsCertificateKeyId } from "../project/project-fns"; import { TCertificateProfileDALFactory } from "./certificate-profile-dal"; import { EnrollmentType, + IssuerType, TCertificateProfile, TCertificateProfileCertificate, TCertificateProfileInsert, @@ -39,6 +40,34 @@ import { TCertificateProfileWithConfigs } from "./certificate-profile-types"; +const validateIssuerTypeConstraints = ( + issuerType: IssuerType, + enrollmentType: EnrollmentType, + caId: string | null, + existingCaId?: string | null +) => { + if (issuerType === IssuerType.CA) { + if (!caId && !existingCaId) { + throw new ForbiddenRequestError({ + message: "CA issuer type requires a Certificate Authority to be selected" + }); + } + } + + if (issuerType === IssuerType.SELF_SIGNED) { + if (caId) { + throw new ForbiddenRequestError({ + message: "Self-signed issuer type cannot have a Certificate Authority" + }); + } + if (enrollmentType !== EnrollmentType.API) { + throw new ForbiddenRequestError({ + message: "Self-signed issuer type only supports API enrollment" + }); + } + } +}; + const generateAndEncryptAcmeEabSecret = async ( projectId: string, kmsService: Pick, @@ -163,7 +192,8 @@ export type TCertificateProfileServiceFactory = ReturnType): TCertificateProfile => { return { ...dalResult, - enrollmentType: dalResult.enrollmentType as EnrollmentType + enrollmentType: dalResult.enrollmentType as EnrollmentType, + issuerType: dalResult.issuerType as IssuerType } as TCertificateProfile; }; @@ -240,6 +270,8 @@ export const certificateProfileServiceFactory = ({ }); } + validateIssuerTypeConstraints(data.issuerType, data.enrollmentType, data.caId ?? null); + // Validate enrollment configuration requirements if (data.enrollmentType === EnrollmentType.EST && !data.estConfig) { throw new ForbiddenRequestError({ @@ -376,7 +408,16 @@ export const certificateProfileServiceFactory = ({ } } - const { estConfig, apiConfig, ...profileUpdateData } = data; + const finalIssuerType = data.issuerType || existingProfile.issuerType; + const finalEnrollmentType = data.enrollmentType || existingProfile.enrollmentType; + const finalCaId = data.caId !== undefined ? data.caId : existingProfile.caId; + + validateIssuerTypeConstraints(finalIssuerType, finalEnrollmentType, finalCaId ?? null, existingProfile.caId); + + const updatedData = + finalIssuerType === IssuerType.SELF_SIGNED && existingProfile.caId ? { ...data, caId: null } : data; + + const { estConfig, apiConfig, ...profileUpdateData } = updatedData; const updatedProfile = await certificateProfileDAL.transaction(async (tx) => { if (estConfig && existingProfile.estConfigId) { @@ -569,6 +610,7 @@ export const certificateProfileServiceFactory = ({ limit = 20, search, enrollmentType, + issuerType, caId }: { actor: ActorType; @@ -580,6 +622,7 @@ export const certificateProfileServiceFactory = ({ limit?: number; search?: string; enrollmentType?: EnrollmentType; + issuerType?: IssuerType; caId?: string; }): Promise<{ profiles: TCertificateProfileWithConfigs[]; @@ -603,12 +646,14 @@ export const certificateProfileServiceFactory = ({ limit, search, enrollmentType, + issuerType, caId }); const totalCount = await certificateProfileDAL.countByProjectId(projectId, { search, enrollmentType, + issuerType, caId }); diff --git a/backend/src/services/certificate-profile/certificate-profile-types.ts b/backend/src/services/certificate-profile/certificate-profile-types.ts index 030548e97..85260b25d 100644 --- a/backend/src/services/certificate-profile/certificate-profile-types.ts +++ b/backend/src/services/certificate-profile/certificate-profile-types.ts @@ -10,16 +10,24 @@ export enum EnrollmentType { ACME = "acme" } -export type TCertificateProfile = Omit & { +export enum IssuerType { + CA = "ca", + SELF_SIGNED = "self-signed" +} + +export type TCertificateProfile = Omit & { enrollmentType: EnrollmentType; + issuerType: IssuerType; }; -export type TCertificateProfileInsert = Omit & { +export type TCertificateProfileInsert = Omit & { enrollmentType: EnrollmentType; + issuerType: IssuerType; }; -export type TCertificateProfileUpdate = Omit & { +export type TCertificateProfileUpdate = Omit & { enrollmentType?: EnrollmentType; + issuerType?: IssuerType; estConfig?: { disableBootstrapCaValidation?: boolean; passphrase?: string; diff --git a/backend/src/services/certificate-v3/certificate-v3-service.test.ts b/backend/src/services/certificate-v3/certificate-v3-service.test.ts index 6c664e324..93291b051 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.test.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.test.ts @@ -22,7 +22,7 @@ import { CertSubjectAttributeType } from "@app/services/certificate-common/certificate-constants"; import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; -import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; +import { EnrollmentType, IssuerType } from "@app/services/certificate-profile/certificate-profile-types"; import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; import { ActorType, AuthMethod } from "../auth/auth-type"; @@ -40,18 +40,29 @@ vi.mock("../certificate-common/certificate-csr-utils", () => ({ describe("CertificateV3Service", () => { let service: TCertificateV3ServiceFactory; - const mockCertificateDAL: Pick = { + const mockCertificateDAL: Pick< + TCertificateDALFactory, + "findOne" | "findById" | "updateById" | "transaction" | "create" + > = { findOne: vi.fn(), findById: vi.fn(), updateById: vi.fn(), + create: vi.fn().mockResolvedValue({ + id: "new-cert-id", + serialNumber: "123456789", + friendlyName: "Test Certificate", + commonName: "test.example.com", + status: "ACTIVE" + }), transaction: vi.fn().mockImplementation(async (callback: (tx: any) => Promise) => { const mockTx = {}; return callback(mockTx); }) }; - const mockCertificateSecretDAL: Pick = { - findOne: vi.fn() + const mockCertificateSecretDAL: Pick = { + findOne: vi.fn(), + create: vi.fn() }; const mockCertificateAuthorityDAL: Pick = { @@ -150,7 +161,24 @@ describe("CertificateV3Service", () => { }, pkiSyncQueue: { queuePkiSyncSyncCertificatesById: vi.fn().mockResolvedValue(undefined) - } + }, + certificateBodyDAL: { + create: vi.fn().mockResolvedValue({ id: "body-123" }) + }, + kmsService: { + generateKmsKey: vi.fn().mockResolvedValue("kms-key-123"), + encryptWithKmsKey: vi.fn().mockResolvedValue(vi.fn().mockResolvedValue(Buffer.from("encrypted"))), + decryptWithKmsKey: vi.fn().mockResolvedValue(vi.fn().mockResolvedValue(Buffer.from("decrypted"))) + }, + projectDAL: { + findOne: vi.fn().mockResolvedValue({ id: "project-123" }), + findById: vi.fn().mockResolvedValue({ id: "project-123" }), + updateById: vi.fn().mockResolvedValue({ id: "project-123" }), + transaction: vi.fn().mockImplementation(async (callback: (tx: any) => Promise) => { + const mockTx = {}; + return callback(mockTx); + }) + } as any }); }); @@ -175,6 +203,7 @@ describe("CertificateV3Service", () => { id: profileId, projectId: "project-123", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", createdAt: new Date(), @@ -319,6 +348,7 @@ describe("CertificateV3Service", () => { id: profileId, projectId: "project-123", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", createdAt: new Date(), @@ -508,6 +538,7 @@ describe("CertificateV3Service", () => { id: profileId, projectId: "project-123", enrollmentType: EnrollmentType.EST, // Wrong enrollment type + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", createdAt: new Date(), @@ -561,6 +592,7 @@ describe("CertificateV3Service", () => { id: profileId, projectId: "project-123", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", createdAt: new Date(), @@ -721,6 +753,7 @@ describe("CertificateV3Service", () => { id: profileId, projectId: "project-123", enrollmentType: EnrollmentType.EST, // Wrong enrollment type + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", createdAt: new Date(), @@ -772,6 +805,7 @@ describe("CertificateV3Service", () => { id: profileId, projectId: "project-123", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", createdAt: new Date(), @@ -933,6 +967,7 @@ describe("CertificateV3Service", () => { id: profileId, projectId: "project-123", enrollmentType: EnrollmentType.EST, // Wrong enrollment type + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", createdAt: new Date(), @@ -971,6 +1006,7 @@ describe("CertificateV3Service", () => { caId: "ca-1", certificateTemplateId: "template-1", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, createdAt: new Date(), updatedAt: new Date(), description: "Test profile for algorithm compatibility", @@ -1552,6 +1588,7 @@ describe("CertificateV3Service", () => { id: "profile-123", projectId: "project-123", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, caId: "ca-123", certificateTemplateId: "template-123", apiConfig: { @@ -1733,9 +1770,9 @@ describe("CertificateV3Service", () => { }); }); - it("should reject renewal if certificate is not from a profile", async () => { - const certWithoutProfile = { ...mockOriginalCert, profileId: null }; - vi.mocked(mockCertificateDAL.findById).mockResolvedValue(certWithoutProfile); + it("should reject renewal if certificate has no profile and no CA", async () => { + const certWithoutProfileAndCA = { ...mockOriginalCert, profileId: null, caId: null }; + vi.mocked(mockCertificateDAL.findById).mockResolvedValue(certWithoutProfileAndCA); // Set up transaction mock to properly handle errors vi.mocked(mockCertificateDAL.transaction).mockImplementation(async (callback: (tx: any) => Promise) => { @@ -2008,6 +2045,7 @@ describe("CertificateV3Service", () => { const mockProfile = { id: "profile-123", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, projectId: "project-123" }; @@ -2084,6 +2122,7 @@ describe("CertificateV3Service", () => { const mockProfile = { id: "profile-123", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, projectId: "project-123" }; @@ -2129,6 +2168,7 @@ describe("CertificateV3Service", () => { const mockProfile = { id: "profile-123", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, projectId: "project-123" }; @@ -2172,6 +2212,7 @@ describe("CertificateV3Service", () => { const mockProfile = { id: "profile-123", enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, projectId: "project-123" }; diff --git a/backend/src/services/certificate-v3/certificate-v3-service.ts b/backend/src/services/certificate-v3/certificate-v3-service.ts index a537ddc06..0fadbc8de 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.ts @@ -1,8 +1,9 @@ import { ForbiddenError } from "@casl/ability"; +import * as x509 from "@peculiar/x509"; import { randomUUID } from "crypto"; import RE2 from "re2"; -import { ActionProjectType } from "@app/db/schemas"; +import { ActionProjectType, TCertificates } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { ProjectPermissionCertificateActions, @@ -10,8 +11,11 @@ import { ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { TPkiAcmeAccountDALFactory } from "@app/ee/services/pki-acme/pki-acme-account-dal"; +import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; +import { ms } from "@app/lib/ms"; import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; +import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; import { TCertificateSecretDALFactory } from "@app/services/certificate/certificate-secret-dal"; import { @@ -28,12 +32,25 @@ import { TCertificateAuthorityWithAssociatedCa } from "@app/services/certificate-authority/certificate-authority-dal"; import { CaStatus, CaType } from "@app/services/certificate-authority/certificate-authority-enums"; +import { + createDistinguishedName, + createSerialNumber, + keyAlgorithmToAlgCfg, + signatureAlgorithmToAlgCfg +} from "@app/services/certificate-authority/certificate-authority-fns"; import { TInternalCertificateAuthorityServiceFactory } from "@app/services/certificate-authority/internal/internal-certificate-authority-service"; import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; -import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; +import { EnrollmentType, IssuerType } from "@app/services/certificate-profile/certificate-profile-types"; import { TCertificateTemplateV2ServiceFactory } from "@app/services/certificate-template-v2/certificate-template-v2-service"; +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; +import { getProjectKmsCertificateKeyId } from "@app/services/project/project-fns"; -import { CertSubjectAlternativeNameType } from "../certificate-common/certificate-constants"; +import { + CertExtendedKeyUsageType, + CertKeyUsageType, + CertSubjectAlternativeNameType +} from "../certificate-common/certificate-constants"; import { extractAlgorithmsFromCSR, extractCertificateRequestFromCSR @@ -68,8 +85,9 @@ import { } from "./certificate-v3-types"; type TCertificateV3ServiceFactoryDep = { - certificateDAL: Pick; - certificateSecretDAL: Pick; + certificateDAL: Pick; + certificateBodyDAL: Pick; + certificateSecretDAL: Pick; certificateAuthorityDAL: Pick; certificateProfileDAL: Pick; acmeAccountDAL: Pick; @@ -85,6 +103,8 @@ type TCertificateV3ServiceFactoryDep = { >; pkiSyncDAL: Pick; pkiSyncQueue: Pick; + kmsService: Pick; + projectDAL: TProjectDALFactory; }; export type TCertificateV3ServiceFactory = ReturnType; @@ -329,6 +349,158 @@ const parseTtlToDays = (ttl: string): number => { } }; +const generateSelfSignedCertificate = async ({ + certificateRequest, + template, + effectiveSignatureAlgorithm, + effectiveKeyAlgorithm +}: { + certificateRequest: { + commonName?: string; + keyUsages?: CertKeyUsageType[]; + extendedKeyUsages?: CertExtendedKeyUsageType[]; + altNames?: Array<{ + type: CertSubjectAlternativeNameType; + value: string; + }>; + validity: { ttl: string }; + notBefore?: Date; + notAfter?: Date; + }; + template?: { + subject?: Array<{ + type: string; + allowed?: string[]; + required?: string[]; + denied?: string[]; + }>; + sans?: Array<{ + type: string; + allowed?: string[]; + required?: string[]; + denied?: string[]; + }>; + } | null; + effectiveSignatureAlgorithm: CertSignatureAlgorithm; + effectiveKeyAlgorithm: CertKeyAlgorithm; +}): Promise<{ + certificate: Buffer; + privateKey: Buffer; + serialNumber: string; + notBefore: Date; + notAfter: Date; + certificateSubject: Record; + subjectAlternativeNames: Array<{ + type: CertSubjectAlternativeNameType; + value: string; + }>; +}> => { + const certificateSubject = buildCertificateSubjectFromTemplate(certificateRequest, template?.subject); + const subjectAlternativeNames = buildSubjectAlternativeNamesFromTemplate( + { subjectAlternativeNames: certificateRequest.altNames }, + template?.sans + ); + + const keyGenAlg = keyAlgorithmToAlgCfg(effectiveKeyAlgorithm); + const keyPair = await crypto.nativeCrypto.subtle.generateKey(keyGenAlg, true, ["sign", "verify"]); + + const signatureAlgorithmConfig = signatureAlgorithmToAlgCfg(effectiveSignatureAlgorithm, effectiveKeyAlgorithm); + + const notBeforeDate = certificateRequest.notBefore ? new Date(certificateRequest.notBefore) : new Date(); + + let notAfterDate: Date; + if (certificateRequest.notAfter) { + notAfterDate = new Date(certificateRequest.notAfter); + } else if (certificateRequest.validity.ttl) { + notAfterDate = new Date(new Date().getTime() + ms(certificateRequest.validity.ttl)); + } else { + throw new BadRequestError({ + message: "Either notAfter date or TTL must be provided for certificate validity" + }); + } + + const serialNumber = createSerialNumber(); + const dn = createDistinguishedName({ + commonName: certificateSubject.common_name, + organization: certificateSubject.organization, + ou: certificateSubject.organizational_unit, + country: certificateSubject.country, + province: certificateSubject.state_or_province_name, + locality: certificateSubject.locality_name + }); + + const cert = await x509.X509CertificateGenerator.createSelfSigned({ + name: dn, + serialNumber, + notBefore: notBeforeDate, + notAfter: notAfterDate, + signingAlgorithm: signatureAlgorithmConfig, + keys: keyPair, + extensions: [ + new x509.BasicConstraintsExtension(false, undefined, false), + ...(certificateRequest.keyUsages?.length + ? [ + new x509.KeyUsagesExtension( + (convertKeyUsageArrayToLegacy(certificateRequest.keyUsages) || []).reduce( + // eslint-disable-next-line no-bitwise + (acc: number, usage) => acc | x509.KeyUsageFlags[usage], + 0 + ), + false + ) + ] + : []), + ...(certificateRequest.extendedKeyUsages?.length + ? [ + new x509.ExtendedKeyUsageExtension( + (convertExtendedKeyUsageArrayToLegacy(certificateRequest.extendedKeyUsages) || []).map( + (eku) => x509.ExtendedKeyUsage[eku] + ), + false + ) + ] + : []), + ...(subjectAlternativeNames + ? [ + new x509.SubjectAlternativeNameExtension( + certificateRequest.altNames?.map((san) => { + switch (san.type) { + case CertSubjectAlternativeNameType.DNS_NAME: + return { type: "dns" as const, value: san.value }; + case CertSubjectAlternativeNameType.IP_ADDRESS: + return { type: "ip" as const, value: san.value }; + case CertSubjectAlternativeNameType.EMAIL: + return { type: "email" as const, value: san.value }; + case CertSubjectAlternativeNameType.URI: + return { type: "url" as const, value: san.value }; + default: + throw new BadRequestError({ + message: `Unsupported Subject Alternative Name type: ${san.type as string}` + }); + } + }) || [], + false + ) + ] + : []) + ] + }); + + const certificatePem = cert.toString("pem"); + const privateKeyObj = crypto.nativeCrypto.KeyObject.from(keyPair.privateKey); + const privateKeyPem = privateKeyObj.export({ format: "pem", type: "pkcs8" }) as string; + + return { + certificate: Buffer.from(certificatePem), + privateKey: Buffer.from(privateKeyPem), + serialNumber, + notBefore: notBeforeDate, + notAfter: notAfterDate, + certificateSubject, + subjectAlternativeNames: certificateRequest.altNames || [] + }; +}; + const calculateFinalRenewBeforeDays = ( profile: { apiConfig?: { autoRenew?: boolean; renewBeforeDays?: number } }, ttl: string, @@ -348,8 +520,248 @@ const calculateFinalRenewBeforeDays = ( return isValidRenewalTiming(renewBeforeDays, certificateExpiryDate) ? renewBeforeDays : undefined; }; +const getEffectiveAlgorithms = ( + requestSignatureAlgorithm?: CertSignatureAlgorithm, + requestKeyAlgorithm?: CertKeyAlgorithm, + originalSignatureAlgorithm?: CertSignatureAlgorithm, + originalKeyAlgorithm?: CertKeyAlgorithm +) => { + return { + signatureAlgorithm: requestSignatureAlgorithm || originalSignatureAlgorithm || CertSignatureAlgorithm.RSA_SHA256, + keyAlgorithm: requestKeyAlgorithm || originalKeyAlgorithm || CertKeyAlgorithm.RSA_2048 + }; +}; + +const createSelfSignedCertificateRecord = async ({ + selfSignedResult, + certificateRequest, + profile, + originalCert, + certificateDAL, + tx, + isRenewal = false +}: { + selfSignedResult: Awaited>; + certificateRequest: { + commonName?: string; + keyUsages?: CertKeyUsageType[]; + extendedKeyUsages?: CertExtendedKeyUsageType[]; + }; + profile?: { id: string; projectId: string } | null; + originalCert?: { + id: string; + friendlyName?: string | null; + commonName?: string | null; + projectId: string; + }; + certificateDAL: Pick; + tx: Parameters[1]; + isRenewal?: boolean; +}) => { + const subjectCommonName = + (selfSignedResult.certificateSubject.common_name as string) || + certificateRequest.commonName || + originalCert?.commonName || + ""; + + const altNamesList = selfSignedResult.subjectAlternativeNames.map((san) => san.value).join(","); + + const projectId = originalCert?.projectId || profile?.projectId; + if (!projectId) { + throw new BadRequestError({ message: "Project ID is required for certificate creation" }); + } + + const baseRecord = { + serialNumber: selfSignedResult.serialNumber, + friendlyName: originalCert?.friendlyName || subjectCommonName, + commonName: subjectCommonName, + altNames: altNamesList, + status: CertStatus.ACTIVE, + notBefore: selfSignedResult.notBefore, + notAfter: selfSignedResult.notAfter, + projectId, + keyUsages: convertKeyUsageArrayToLegacy(certificateRequest.keyUsages) || [], + extendedKeyUsages: convertExtendedKeyUsageArrayToLegacy(certificateRequest.extendedKeyUsages) || [], + profileId: profile?.id || null + }; + + const renewalRecord = + isRenewal && originalCert + ? { + renewedFromCertificateId: originalCert.id + } + : {}; + + return certificateDAL.create( + { + ...baseRecord, + ...renewalRecord + }, + tx + ); +}; + +const createEncryptedCertificateData = async ({ + certificateId, + certificate, + privateKey, + projectId, + certificateBodyDAL, + certificateSecretDAL, + kmsService, + projectDAL, + tx +}: { + certificateId: string; + certificate: Buffer; + privateKey: Buffer; + projectId: string; + certificateBodyDAL: Pick; + certificateSecretDAL: Pick; + kmsService: Pick; + projectDAL: TProjectDALFactory; + tx: Parameters[1]; +}) => { + const certificateManagerKeyId = await getProjectKmsCertificateKeyId({ + projectId, + projectDAL, + kmsService + }); + + const kmsEncryptor = await kmsService.encryptWithKmsKey({ kmsId: certificateManagerKeyId }); + + const encryptedCertificate = await kmsEncryptor({ + plainText: certificate + }); + + await certificateBodyDAL.create( + { + certId: certificateId, + encryptedCertificate: encryptedCertificate.cipherTextBlob + }, + tx + ); + + const encryptedPrivateKey = await kmsEncryptor({ + plainText: privateKey + }); + + await certificateSecretDAL.create( + { + certId: certificateId, + encryptedPrivateKey: encryptedPrivateKey.cipherTextBlob + }, + tx + ); +}; + +const processSelfSignedCertificate = async ({ + certificateRequest, + template, + profile, + originalCert, + effectiveAlgorithms, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + kmsService, + projectDAL, + tx, + isRenewal = false +}: { + certificateRequest: { + commonName?: string; + keyUsages?: CertKeyUsageType[]; + extendedKeyUsages?: CertExtendedKeyUsageType[]; + validity: { ttl: string }; + notBefore?: Date; + notAfter?: Date; + }; + template?: { + subject?: Array<{ + type: string; + allowed?: string[]; + required?: string[]; + denied?: string[]; + }>; + sans?: Array<{ + type: string; + allowed?: string[]; + required?: string[]; + denied?: string[]; + }>; + } | null; + profile?: { id: string; projectId: string } | null; + originalCert?: { + id: string; + friendlyName?: string | null; + commonName?: string | null; + projectId: string; + }; + effectiveAlgorithms: { + signatureAlgorithm: CertSignatureAlgorithm; + keyAlgorithm: CertKeyAlgorithm; + }; + certificateDAL: Pick; + certificateBodyDAL: Pick; + certificateSecretDAL: Pick; + kmsService: Pick; + projectDAL: TProjectDALFactory; + tx: Parameters[1]; + isRenewal?: boolean; +}) => { + const projectId = originalCert?.projectId || profile?.projectId; + if (!projectId) { + throw new BadRequestError({ message: "Project ID is required for certificate creation" }); + } + + const selfSignedResult = await generateSelfSignedCertificate({ + certificateRequest, + template, + effectiveSignatureAlgorithm: effectiveAlgorithms.signatureAlgorithm, + effectiveKeyAlgorithm: effectiveAlgorithms.keyAlgorithm + }); + + const certificateData = await createSelfSignedCertificateRecord({ + selfSignedResult, + certificateRequest, + profile, + originalCert, + certificateDAL, + tx, + isRenewal + }); + + await certificateDAL.updateById( + certificateData.id, + { + signatureAlgorithm: effectiveAlgorithms.signatureAlgorithm, + keyAlgorithm: effectiveAlgorithms.keyAlgorithm + }, + tx + ); + + await createEncryptedCertificateData({ + certificateId: certificateData.id, + certificate: selfSignedResult.certificate, + privateKey: selfSignedResult.privateKey, + projectId, + certificateBodyDAL, + certificateSecretDAL, + kmsService, + projectDAL, + tx + }); + + return { + selfSignedResult, + certificateData + }; +}; + export const certificateV3ServiceFactory = ({ certificateDAL, + certificateBodyDAL, certificateSecretDAL, certificateAuthorityDAL, certificateProfileDAL, @@ -359,7 +771,9 @@ export const certificateV3ServiceFactory = ({ permissionService, certificateSyncDAL, pkiSyncDAL, - pkiSyncQueue + pkiSyncQueue, + kmsService, + projectDAL }: TCertificateV3ServiceFactoryDep) => { const issueCertificateFromProfile = async ({ profileId, @@ -416,15 +830,6 @@ export const certificateV3ServiceFactory = ({ }); } - const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId); - if (!ca) { - throw new NotFoundError({ message: "Certificate Authority not found" }); - } - - validateCaSupport(ca, "direct certificate issuance"); - - validateAlgorithmCompatibility(ca, template); - const effectiveSignatureAlgorithm = certificateRequest.signatureAlgorithm as CertSignatureAlgorithm | undefined; const effectiveKeyAlgorithm = certificateRequest.keyAlgorithm as CertKeyAlgorithm | undefined; @@ -440,12 +845,76 @@ export const certificateV3ServiceFactory = ({ }); } - const certificateSubject = buildCertificateSubjectFromTemplate(certificateRequest, template.subject); + const certificateSubject = buildCertificateSubjectFromTemplate(certificateRequest, template?.subject); const subjectAlternativeNames = buildSubjectAlternativeNamesFromTemplate( { subjectAlternativeNames: certificateRequest.altNames }, - template.sans + template?.sans ); + const issuerType = profile?.issuerType || (profile?.caId ? IssuerType.CA : IssuerType.SELF_SIGNED); + + if (issuerType === IssuerType.SELF_SIGNED) { + const result = await certificateDAL.transaction(async (tx) => { + const effectiveAlgorithms = getEffectiveAlgorithms(effectiveSignatureAlgorithm, effectiveKeyAlgorithm); + + return processSelfSignedCertificate({ + certificateRequest, + template, + profile, + effectiveAlgorithms, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + kmsService, + projectDAL, + tx + }); + }); + + const { selfSignedResult, certificateData } = result; + + const subjectCommonName = + (selfSignedResult.certificateSubject.common_name as string) || + certificateRequest.commonName || + "Self-signed Certificate"; + + const finalRenewBeforeDays = calculateFinalRenewBeforeDays( + profile, + certificateRequest.validity.ttl, + selfSignedResult.notAfter + ); + + if (finalRenewBeforeDays !== undefined) { + await certificateDAL.updateById(certificateData.id, { + renewBeforeDays: finalRenewBeforeDays + }); + } + + return { + certificate: selfSignedResult.certificate.toString("utf8"), + issuingCaCertificate: "", + certificateChain: selfSignedResult.certificate.toString("utf8"), + privateKey: selfSignedResult.privateKey.toString("utf8"), + serialNumber: selfSignedResult.serialNumber, + certificateId: certificateData.id, + projectId: profile.projectId, + profileName: profile.slug, + commonName: subjectCommonName + }; + } + + if (!profile.caId) { + throw new NotFoundError({ message: "Certificate Authority ID not found" }); + } + + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId); + if (!ca) { + throw new NotFoundError({ message: "Certificate Authority not found" }); + } + + validateCaSupport(ca, "direct certificate issuance"); + validateAlgorithmCompatibility(ca, template); + const { certificate, certificateChain, issuingCaCertificate, privateKey, serialNumber } = await internalCaService.issueCertFromCa({ caId: ca.id, @@ -477,10 +946,11 @@ export const certificateV3ServiceFactory = ({ new Date(cert.notAfter) ); - await certificateDAL.updateById(cert.id, { - profileId, - renewBeforeDays: finalRenewBeforeDays - }); + const updateData: { profileId: string; renewBeforeDays?: number } = { profileId }; + if (finalRenewBeforeDays !== undefined) { + updateData.renewBeforeDays = finalRenewBeforeDays; + } + await certificateDAL.updateById(cert.id, updateData); let finalCertificateChain = bufferToString(certificateChain); if (removeRootsFromChain) { @@ -525,6 +995,12 @@ export const certificateV3ServiceFactory = ({ enrollmentType ); + if (!profile.caId) { + throw new BadRequestError({ + message: "Self-signed certificates are not supported for CSR signing" + }); + } + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId); if (!ca) { throw new NotFoundError({ message: "Certificate Authority not found" }); @@ -592,10 +1068,11 @@ export const certificateV3ServiceFactory = ({ const finalRenewBeforeDays = calculateFinalRenewBeforeDays(profile, validity.ttl, new Date(cert.notAfter)); - await certificateDAL.updateById(cert.id, { - profileId, - renewBeforeDays: finalRenewBeforeDays - }); + const updateData2: { profileId: string; renewBeforeDays?: number } = { profileId }; + if (finalRenewBeforeDays !== undefined) { + updateData2.renewBeforeDays = finalRenewBeforeDays; + } + await certificateDAL.updateById(cert.id, updateData2); const certificateString = extractCertificateFromBuffer(certificate as unknown as Buffer); let certificateChainString = extractCertificateFromBuffer(certificateChain as unknown as Buffer); @@ -640,10 +1117,25 @@ export const certificateV3ServiceFactory = ({ commonName: certificateOrder.commonName, keyUsages: certificateOrder.keyUsages, extendedKeyUsages: certificateOrder.extendedKeyUsages, - subjectAlternativeNames: certificateOrder.altNames.map((san) => ({ - type: san.type === "dns" ? CertSubjectAlternativeNameType.DNS_NAME : CertSubjectAlternativeNameType.IP_ADDRESS, - value: san.value - })), + subjectAlternativeNames: certificateOrder.altNames.map((san) => { + let certType: CertSubjectAlternativeNameType; + switch (san.type) { + case "dns": + certType = CertSubjectAlternativeNameType.DNS_NAME; + break; + case "ip": + certType = CertSubjectAlternativeNameType.IP_ADDRESS; + break; + default: + throw new BadRequestError({ + message: `Unsupported Subject Alternative Name type: ${san.type as string}` + }); + } + return { + type: certType, + value: san.value + }; + }), validity: certificateOrder.validity, notBefore: certificateOrder.notBefore, notAfter: certificateOrder.notAfter, @@ -663,6 +1155,12 @@ export const certificateV3ServiceFactory = ({ }); } + if (!profile.caId) { + throw new BadRequestError({ + message: "Self-signed certificates are not supported for certificate ordering" + }); + } + const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId); if (!ca) { throw new NotFoundError({ message: "Certificate Authority not found" }); @@ -741,15 +1239,19 @@ export const certificateV3ServiceFactory = ({ }); } - const profile = await certificateProfileDAL.findByIdWithConfigs(originalCert.profileId); - if (!profile) { - throw new NotFoundError({ message: "Certificate profile not found" }); - } + let profile = null; + if (originalCert.profileId) { + 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" - }); + if (profile.enrollmentType !== EnrollmentType.API) { + throw new ForbiddenRequestError({ + message: + "Certificate is not eligible for renewal: Only certificates issued from an API enrollment profile can be renewed through this endpoint" + }); + } } const certificateSecret = await certificateSecretDAL.findOne({ certId: originalCert.id }, tx); @@ -761,10 +1263,11 @@ export const certificateV3ServiceFactory = ({ } if (!internal) { + const projectId = profile?.projectId || originalCert.projectId; const { permission } = await permissionService.getProjectPermission({ actor, actorId, - projectId: profile.projectId, + projectId, actorAuthMethod, actorOrgId, actionProjectType: ActionProjectType.CertificateManager @@ -776,33 +1279,46 @@ export const certificateV3ServiceFactory = ({ ); } - const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId); - if (!ca) { - throw new NotFoundError({ message: "Certificate Authority not found" }); + const issuerType = profile?.issuerType || (originalCert.caId ? IssuerType.CA : IssuerType.SELF_SIGNED); + + let ca; + if (issuerType === IssuerType.CA) { + const caId = profile?.caId || originalCert.caId; + if (!caId) { + throw new NotFoundError({ message: "Certificate Authority ID not found" }); + } + + ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(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 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(", ")}` - }); - } + const templateId = profile?.certificateTemplateId || originalCert.certificateTemplateId; + const template = templateId + ? await certificateTemplateV2Service.getTemplateV2ById({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + templateId, + internal + }) + : null; - validateCaSupport(ca, "direct certificate issuance"); - - const template = await certificateTemplateV2Service.getTemplateV2ById({ - actor, - actorId, - actorAuthMethod, - actorOrgId, - templateId: profile.certificateTemplateId, - internal - }); - - if (!template) { + if (!template && profile) { throw new NotFoundError({ message: "Certificate template not found for this profile" }); } @@ -857,10 +1373,13 @@ export const certificateV3ServiceFactory = ({ keyAlgorithm: originalCert.keyAlgorithm || undefined }; - const validationResult = await certificateTemplateV2Service.validateCertificateRequest( - profile.certificateTemplateId, - certificateRequest - ); + let validationResult: { isValid: boolean; errors: string[] } = { isValid: true, errors: [] }; + if (profile?.certificateTemplateId) { + validationResult = await certificateTemplateV2Service.validateCertificateRequest( + profile.certificateTemplateId, + certificateRequest + ); + } if (!validationResult.isValid) { await certificateDAL.updateById(originalCert.id, { @@ -872,14 +1391,28 @@ export const certificateV3ServiceFactory = ({ }); } - 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 finalRenewBeforeDays = profile ? calculateFinalRenewBeforeDays(profile, ttl, notAfter) : undefined; - const { certificate, certificateChain, issuingCaCertificate, serialNumber } = - await internalCaService.issueCertFromCa({ + let certificate: string; + let certificateChain: string; + let issuingCaCertificate: string; + let serialNumber: string; + let newCert: TCertificates; + + if (issuerType === IssuerType.CA) { + // CA-signed certificate renewal + if (!ca) { + throw new NotFoundError({ message: "Certificate Authority not found for CA-signed certificate renewal" }); + } + + validateAlgorithmCompatibility(ca, { + algorithms: template?.algorithms + } as { algorithms?: { signature?: string[] } }); + + const caResult = await internalCaService.issueCertFromCa({ caId: ca.id, friendlyName: originalCert.friendlyName || originalCert.commonName || "Renewed Certificate", commonName: originalCert.commonName || "", @@ -900,20 +1433,72 @@ export const certificateV3ServiceFactory = ({ tx }); - const newCert = await certificateDAL.findOne({ serialNumber, caId: ca.id }, tx); + certificate = caResult.certificate; + certificateChain = caResult.certificateChain; + issuingCaCertificate = caResult.issuingCaCertificate; + serialNumber = caResult.serialNumber; + + const foundCert = await certificateDAL.findOne({ serialNumber, caId: ca.id }, tx); + if (!foundCert) { + throw new NotFoundError({ message: "Certificate was signed but could not be found in database" }); + } + newCert = foundCert; + } else { + // Self-signed certificate renewal + const effectiveAlgorithms = getEffectiveAlgorithms( + undefined, + undefined, + originalSignatureAlgorithm, + originalKeyAlgorithm + ); + + const selfSignedRenewalResult = await processSelfSignedCertificate({ + certificateRequest, + template, + profile, + originalCert, + effectiveAlgorithms, + certificateDAL, + certificateBodyDAL, + certificateSecretDAL, + kmsService, + projectDAL, + tx, + isRenewal: true + }); + + certificate = selfSignedRenewalResult.selfSignedResult.certificate.toString("utf8"); + certificateChain = selfSignedRenewalResult.selfSignedResult.certificate.toString("utf8"); // Self-signed has no chain + issuingCaCertificate = ""; // No issuing CA for self-signed + serialNumber = selfSignedRenewalResult.selfSignedResult.serialNumber; + newCert = selfSignedRenewalResult.certificateData; + } + 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, + // For self-signed certificates, we already set the renewal data during creation + // For CA-signed certificates, we need to set it now + if (issuerType === IssuerType.CA) { + const renewalUpdateData: { + profileId: string | null; + renewedFromCertificateId: string; + renewBeforeDays?: number; + } = { + profileId: originalCert.profileId || null, renewedFromCertificateId: originalCert.id - }, - tx - ); + }; + + if (finalRenewBeforeDays !== undefined) { + renewalUpdateData.renewBeforeDays = finalRenewBeforeDays; + } + + await certificateDAL.updateById(newCert.id, renewalUpdateData, tx); + } else if (finalRenewBeforeDays !== undefined) { + // For self-signed certificates, just update the renewBeforeDays if needed + await certificateDAL.updateById(newCert.id, { renewBeforeDays: finalRenewBeforeDays }, tx); + } await certificateDAL.updateById( originalCert.id, @@ -953,8 +1538,8 @@ export const certificateV3ServiceFactory = ({ certificateChain: finalCertificateChain, serialNumber: renewalResult.serialNumber, certificateId: renewalResult.newCert.id, - projectId: renewalResult.profile.projectId, - profileName: renewalResult.profile.slug, + projectId: renewalResult.originalCert.projectId, + profileName: renewalResult.profile?.slug || "Self-signed Certificate", commonName: renewalResult.originalCert.commonName || "" }; }; diff --git a/backend/src/services/certificate/certificate-service.ts b/backend/src/services/certificate/certificate-service.ts index b632e76fb..515ee3ee0 100644 --- a/backend/src/services/certificate/certificate-service.ts +++ b/backend/src/services/certificate/certificate-service.ts @@ -309,6 +309,14 @@ export const certificateServiceFactory = ({ const certBody = await certificateBodyDAL.findOne({ certId: cert.id }); + if (!certBody) { + throw new NotFoundError({ message: "Certificate body not found" }); + } + + if (!certBody.encryptedCertificate) { + throw new BadRequestError({ message: "Certificate data not available" }); + } + const certificateManagerKeyId = await getProjectKmsCertificateKeyId({ projectId: cert.projectId, projectDAL, @@ -599,6 +607,14 @@ export const certificateServiceFactory = ({ const certBody = await certificateBodyDAL.findOne({ certId: cert.id }); + if (!certBody) { + throw new NotFoundError({ message: "Certificate body not found" }); + } + + if (!certBody.encryptedCertificate) { + throw new BadRequestError({ message: "Certificate data not available" }); + } + const certificateManagerKeyId = await getProjectKmsCertificateKeyId({ projectId: cert.projectId, projectDAL, diff --git a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts index 9322e48cb..212cb0894 100644 --- a/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts +++ b/backend/src/services/identity-kubernetes-auth/identity-kubernetes-auth-service.ts @@ -270,7 +270,13 @@ export const identityKubernetesAuthServiceFactory = ({ } ) .catch((err) => { + const tokenReviewerJwtSnippet = `${tokenReviewerJwt?.substring?.(0, 10) || ""}...${tokenReviewerJwt?.substring?.(tokenReviewerJwt.length - 10) || ""}`; + const serviceAccountJwtSnippet = `${serviceAccountJwt?.substring?.(0, 10) || ""}...${serviceAccountJwt?.substring?.(serviceAccountJwt.length - 10) || ""}`; if (err instanceof AxiosError) { + logger.error( + { response: err.response, host, port, tokenReviewerJwtSnippet, serviceAccountJwtSnippet }, + "tokenReviewCallbackRaw: Kubernetes token review request error (request error)" + ); if (err.response) { const { message } = err?.response?.data as unknown as { message?: string }; @@ -281,6 +287,11 @@ export const identityKubernetesAuthServiceFactory = ({ }); } } + } else { + logger.error( + { error: err as Error, host, port, tokenReviewerJwtSnippet, serviceAccountJwtSnippet }, + "tokenReviewCallbackRaw: Kubernetes token review request error (non-request error)" + ); } throw err; }); diff --git a/backend/src/services/integration-auth/integration-list.ts b/backend/src/services/integration-auth/integration-list.ts index e4e1d3126..b76e90470 100644 --- a/backend/src/services/integration-auth/integration-list.ts +++ b/backend/src/services/integration-auth/integration-list.ts @@ -105,7 +105,9 @@ export enum IntegrationUrls { GCP_CLOUD_PLATFORM_SCOPE = "https://www.googleapis.com/auth/cloud-platform", GITHUB_USER_INSTALLATIONS = "https://api.github.com/user/installations", - CHEF_API_URL = "https://api.chef.io" + CHEF_API_URL = "https://api.chef.io", + DNS_MADE_EASY_API_URL = "https://api.dnsmadeeasy.com", + DNS_MADE_EASY_SANDBOX_API_URL = "https://api.sandbox.dnsmadeeasy.com" } export const getIntegrationOptions = async () => { diff --git a/backend/src/services/membership-identity/membership-identity-dal.ts b/backend/src/services/membership-identity/membership-identity-dal.ts index 4a90e1edd..bcf855c88 100644 --- a/backend/src/services/membership-identity/membership-identity-dal.ts +++ b/backend/src/services/membership-identity/membership-identity-dal.ts @@ -94,6 +94,7 @@ export const membershipIdentityDALFactory = (db: TDbClient) => { db.ref("hasDeleteProtection").withSchema(TableName.Identity).as("identityHasDeleteProtection"), db.ref("slug").withSchema(TableName.Role).as("roleSlug"), + db.ref("name").withSchema(TableName.Role).as("roleName"), db.ref("id").withSchema(TableName.MembershipRole).as("membershipRoleId"), db.ref("role").withSchema(TableName.MembershipRole).as("membershipRole"), db.ref("temporaryMode").withSchema(TableName.MembershipRole).as("membershipRoleTemporaryMode"), @@ -180,6 +181,7 @@ export const membershipIdentityDALFactory = (db: TDbClient) => { label: "roles" as const, mapper: ({ roleSlug, + roleName, membershipRoleId, membershipRole, membershipRoleIsTemporary, @@ -193,6 +195,7 @@ export const membershipIdentityDALFactory = (db: TDbClient) => { id: membershipRoleId, role: membershipRole, customRoleSlug: roleSlug, + customRoleName: roleName, temporaryRange: membershipRoleTemporaryRange, temporaryMode: membershipRoleTemporaryMode, temporaryAccessStartTime: membershipRoleTemporaryAccessStartTime, diff --git a/backend/src/services/smtp/emails/DynamicSecretLeaseRevocationFailedTemplate.tsx b/backend/src/services/smtp/emails/DynamicSecretLeaseRevocationFailedTemplate.tsx new file mode 100644 index 000000000..94e2e8f6a --- /dev/null +++ b/backend/src/services/smtp/emails/DynamicSecretLeaseRevocationFailedTemplate.tsx @@ -0,0 +1,68 @@ +import { Heading, Section, Text } from "@react-email/components"; + +import { BaseButton } from "./BaseButton"; +import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; + +interface DynamicSecretLeaseRevocationFailedTemplateProps + extends Omit { + siteUrl: string; + dynamicSecretLeaseUrl: string; + dynamicSecretName: string; + projectName: string; + environmentSlug: string; + errorMessage: string; +} + +export const DynamicSecretLeaseRevocationFailedTemplate = ({ + siteUrl, + dynamicSecretLeaseUrl, + dynamicSecretName, + projectName, + environmentSlug, + errorMessage +}: DynamicSecretLeaseRevocationFailedTemplateProps) => { + return ( + + + Dynamic Secret Lease Revocation Failed + +
+ + One or more leases for the dynamic secret {dynamicSecretName} in project{" "} + {projectName} and environment {environmentSlug} have failed to revoke after + multiple attempts. + + + Please review the dynamic secret leases and attempt to revoke them again. + +
+ +
+ + Latest error message + + {errorMessage} +
+ +
+ View Dynamic Secret Leases +
+
+ ); +}; + +export default DynamicSecretLeaseRevocationFailedTemplate; + +DynamicSecretLeaseRevocationFailedTemplate.PreviewProps = { + errorMessage: 'REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM "[REDACTED]" - tuple concurrently updated.', + dynamicSecretLeaseUrl: "https://infisical.com/test", + leaseId: "717d5013-7194-49d9-b6ac-6192328c2914", + dynamicSecretName: "postgres-prod-db", + projectName: "Development Team", + environmentSlug: "dev", + siteUrl: "https://infisical.com" +} as DynamicSecretLeaseRevocationFailedTemplateProps; diff --git a/backend/src/services/smtp/smtp-service.ts b/backend/src/services/smtp/smtp-service.ts index cef22009a..62906764f 100644 --- a/backend/src/services/smtp/smtp-service.ts +++ b/backend/src/services/smtp/smtp-service.ts @@ -43,6 +43,7 @@ import { SubOrganizationInvitationTemplate, UnlockAccountTemplate } from "./emails"; +import DynamicSecretLeaseRevocationFailedTemplate from "./emails/DynamicSecretLeaseRevocationFailedTemplate"; export type TSmtpConfig = SMTPTransport.Options; export type TSmtpSendMail = { @@ -89,7 +90,8 @@ export enum SmtpTemplates { SecretScanningV2ScanFailed = "secretScanningV2ScanFailed", SecretScanningV2SecretsDetected = "secretScanningV2SecretsDetected", AccountDeletionConfirmation = "accountDeletionConfirmation", - HealthAlert = "healthAlert" + HealthAlert = "healthAlert", + DynamicSecretLeaseRevocationFailed = "dynamicSecretLeaseRevocationFailed" } export enum SmtpHost { @@ -137,7 +139,8 @@ const EmailTemplateMap: Record> = { [SmtpTemplates.SecretScanningV2ScanFailed]: SecretScanningScanFailedTemplate, [SmtpTemplates.SecretScanningV2SecretsDetected]: SecretScanningSecretsDetectedTemplate, [SmtpTemplates.AccountDeletionConfirmation]: AccountDeletionConfirmationTemplate, - [SmtpTemplates.HealthAlert]: HealthAlertTemplate + [SmtpTemplates.HealthAlert]: HealthAlertTemplate, + [SmtpTemplates.DynamicSecretLeaseRevocationFailed]: DynamicSecretLeaseRevocationFailedTemplate }; export const smtpServiceFactory = (cfg: TSmtpConfig) => { diff --git a/docs/docs.json b/docs/docs.json index 57f44596f..f7687229f 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -118,6 +118,7 @@ "integrations/app-connections/cloudflare", "integrations/app-connections/databricks", "integrations/app-connections/digital-ocean", + "integrations/app-connections/dns-made-easy", "integrations/app-connections/flyio", "integrations/app-connections/gcp", "integrations/app-connections/github", @@ -490,6 +491,10 @@ "pages": [ "integrations/platforms/ansible", "integrations/platforms/apache-airflow", + { + "group": "AWS", + "pages": ["integrations/platforms/aws/lambda"] + }, { "group": "Kubernetes Operator", "pages": [ @@ -752,7 +757,7 @@ { "group": "Infrastructure Integrations", "pages": [ - "documentation/platform/pki/pki-issuer", + "documentation/platform/pki/k8s-cert-manager", "documentation/platform/pki/integration-guides/gloo-mesh", "documentation/platform/pki/integration-guides/windows-server-acme", "documentation/platform/pki/integration-guides/nginx-certbot", diff --git a/docs/documentation/getting-started/concepts/client-integrations.mdx b/docs/documentation/getting-started/concepts/client-integrations.mdx index bcd935830..aa7b37d4a 100644 --- a/docs/documentation/getting-started/concepts/client-integrations.mdx +++ b/docs/documentation/getting-started/concepts/client-integrations.mdx @@ -24,7 +24,7 @@ Infisical offers a non-exhaustive set of clients and interfaces to support a wid - [External Secrets Operator (ESO)](https://external-secrets.io/latest/provider/infisical): Allows Infisical to act as a backend provider for syncing secrets into Kubernetes `Secret` objects using the widely adopted External Secrets Operator. -- [Kubernetes PKI Issuer](/documentation/platform/pki/pki-issuer): A controller that issues X.509 certificates from Infisical PKI using the cert-manager Issuer and Certificate CRDs. +- [Kubernetes cert-manager](/documentation/platform/pki/k8s-cert-manager): A controller that issues X.509 certificates from Infisical using the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme) configured on a [certificate profile](/documentation/platform/pki/certificates/profiles) using the cert-manager Issuer and Certificate CRDs. - [Secret Syncs](/integrations/secret-syncs/overview): Native integrations to forward secrets to services like GitHub, GitLab, AWS Secrets Manager, Vercel, and more. diff --git a/docs/documentation/platform/pki/ca/acme-ca.mdx b/docs/documentation/platform/pki/ca/acme-ca.mdx index 774590c73..27132518d 100644 --- a/docs/documentation/platform/pki/ca/acme-ca.mdx +++ b/docs/documentation/platform/pki/ca/acme-ca.mdx @@ -17,7 +17,7 @@ their **ACME Directory URL** such as: - ZeroSSL: `https://acme.zerossl.com/v2/DV90`. - SSL.com: `https://acme.ssl.com/sslcom-dv-rsa`. -When Infisical requests a certificate from an ACME-compatible CA, it creates a TXT record at `_acme-challenge.{your-domain}` in your configured DNS provider (e.g. Route53, Cloudflare, etc.); this TXT record contains the challenge token issued by the ACME-compatible CA to validate domain control for the requested certificate. +When Infisical requests a certificate from an ACME-compatible CA, it creates a TXT record at `_acme-challenge.{your-domain}` in your configured DNS provider (e.g. Route53, Cloudflare, DNS Made Easy, etc.); this TXT record contains the challenge token issued by the ACME-compatible CA to validate domain control for the requested certificate. The ACME provider checks for the existence of this TXT record to verify domain control before issuing the certificate back to Infisical. After validation completes successfully, Infisical automatically removes the TXT record from your DNS provider. @@ -120,6 +120,11 @@ In the following steps, we explore how to connect Infisical to an ACME-compatibl For detailed instructions on setting up a Cloudflare connection, see the [Cloudflare Connection](/integrations/app-connections/cloudflare) documentation. + + Navigate to your Certificate Management Project > App Connections and create a new DNS Made Easy connection. + + For detailed instructions on setting up a DNS Made Easy connection, see the [DNS Made Easy Connection](/integrations/app-connections/dns-made-easy) documentation. + diff --git a/docs/documentation/platform/pki/certificates/certificates.mdx b/docs/documentation/platform/pki/certificates/certificates.mdx index abe198750..79168c4fc 100644 --- a/docs/documentation/platform/pki/certificates/certificates.mdx +++ b/docs/documentation/platform/pki/certificates/certificates.mdx @@ -19,10 +19,12 @@ where you can manage various aspects of its lifecycle including deployment to cl ## Guide to Issuing Certificates -To issue a certificate, you must first create a [certificate profile](/documentation/platform/pki/certificates/profiles) and a [certificate template](/documentation/platform/pki/certificates/templates) to go along with it. +To [issue a certificate](/documentation/platform/pki/concepts/certificate-lifecycle#enrollment-request-%2F-issuance), you must first create a [certificate profile](/documentation/platform/pki/certificates/profiles) and a [certificate template](/documentation/platform/pki/certificates/templates) to go along with it. -The [enrollment method](/documentation/platform/pki/enrollment-methods/overview) configured on the certificate profile determines how a certificate is issued for it. -Refer to the documentation for each enrollment method to learn more about how to issue certificates using it. +- Self-Signed Certificates: To issue a [self-signed certificate](https://en.wikipedia.org/wiki/Self-signed_certificate), you must configure the certificate profile to use the `Self-Signed` issuer type. You can then use the [API enrollment method](/documentation/platform/pki/enrollment-methods/api) to request a self-signed certificate against it. +- CA-Issued Certificates: To issue a certificate from a certificate authority, you must configure the certificate profile to use the `Certificate Authority` issuer type and select the [issuing CA](/documentation/platform/pki/ca/overview) to use. You can then use one of the [enrollment methods](/documentation/platform/pki/enrollment-methods/overview) to request a certificate against it. + +Refer to the documentation for each [enrollment method](/documentation/platform/pki/enrollment-methods/overview) to learn more about how to issue certificates using it. ## Guide to Renewing Certificates diff --git a/docs/documentation/platform/pki/certificates/profiles.mdx b/docs/documentation/platform/pki/certificates/profiles.mdx index ccbef89cd..1121437cf 100644 --- a/docs/documentation/platform/pki/certificates/profiles.mdx +++ b/docs/documentation/platform/pki/certificates/profiles.mdx @@ -21,7 +21,8 @@ Here's some guidance on each field: - Name: A slug-friendly name for the profile such as `web-servers`. - Description: An optional description for the profile. -- Issuing CA: The [issuing CA](/documentation/platform/pki/ca/overview) that should be used to issue certificates for the profile. +- Issuer Type: The type of issuer that should be used to issue certificates for the profile; this can be either `Certificate Authority` or `Self-Signed`. If `Self-Signed` is selected, then the profile will only support the API enrollment method and be used to issue self-signed certificates over REST API. +- Issuing CA: The [issuing CA](/documentation/platform/pki/ca/overview) that should be used to issue certificates for the profile when the **Issuer Type** is set to `Certificate Authority`. - Certificate Template: The [certificate template](/documentation/platform/pki/certificates/templates) that should be used to validate certificate requests for the profile. - Enrollment Method: The enrollment method that should be used to enroll certificates for the profile such as ACME, EST, API, etc. diff --git a/docs/documentation/platform/pki/enrollment-methods/acme.mdx b/docs/documentation/platform/pki/enrollment-methods/acme.mdx index 12c4779b5..50a4a615c 100644 --- a/docs/documentation/platform/pki/enrollment-methods/acme.mdx +++ b/docs/documentation/platform/pki/enrollment-methods/acme.mdx @@ -5,7 +5,7 @@ sidebarTitle: "ACME" ## Concept -The ACME enrollment method allows you to issue and manage certificates against a specific [certificate profile](/documentation/platform/pki/certificates/profiles) using the [ACME protocol](https://en.wikipedia.org/wiki/Automatic_Certificate_Management_Environment). +The ACME enrollment method allows Infisical to act as an ACME server. It lets you request and manage certificates against a specific [certificate profile](/documentation/platform/pki/certificates/profiles) using the [ACME protocol](https://en.wikipedia.org/wiki/Automatic_Certificate_Management_Environment). This method is suitable for web servers, load balancers, and other general-purpose servers that can run an [ACME client](https://letsencrypt.org/docs/client-options/) for automated certificate management. Infisical's ACME enrollment method is based on [RFC 8555](https://datatracker.ietf.org/doc/html/rfc8555/). diff --git a/docs/documentation/platform/pki/integration-guides/apache-certbot.mdx b/docs/documentation/platform/pki/integration-guides/apache-certbot.mdx index 78f0301e1..f7e066d7b 100644 --- a/docs/documentation/platform/pki/integration-guides/apache-certbot.mdx +++ b/docs/documentation/platform/pki/integration-guides/apache-certbot.mdx @@ -1,9 +1,9 @@ --- title: "Apache Server" -description: "Learn how to issue SSL/TLS certificates from Infisical using ACME enrollment on Apache Server with Certbot" +description: "Learn how to issue TLS certificates from Infisical using ACME enrollment on Apache Server with Certbot" --- -This guide demonstrates how to use Infisical to issue SSL/TLS certificates for your [Apache HTTP Server](https://httpd.apache.org/). +This guide demonstrates how to use Infisical to issue TLS certificates for your [Apache HTTP Server](https://httpd.apache.org/). It uses [Certbot](https://certbot.eff.org/), an installable [ACME](https://en.wikipedia.org/wiki/Automatic_Certificate_Management_Environment) client, to request and renew certificates from Infisical using the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme) configured on a [certificate profile](/documentation/platform/pki/certificates/profiles). Apache benefits from excellent Certbot integration, allowing both certificate-only mode and automatic SSL configuration. @@ -182,4 +182,5 @@ Before you begin, make sure you have: - \ No newline at end of file + + diff --git a/docs/documentation/platform/pki/integration-guides/gloo-mesh.mdx b/docs/documentation/platform/pki/integration-guides/gloo-mesh.mdx index d1f1273fd..d83e062fa 100644 --- a/docs/documentation/platform/pki/integration-guides/gloo-mesh.mdx +++ b/docs/documentation/platform/pki/integration-guides/gloo-mesh.mdx @@ -1,13 +1,13 @@ --- title: "Gloo Mesh" -description: "Learn how to automatically provision and manage Istio intermediate CA certificates for Gloo Mesh using Infisical PKI" +description: "Learn how to automatically provision and manage Istio intermediate CA certificates for Gloo Mesh using Infisical" --- -This guide will provide a high level overview on how you can use Infisical PKI and cert-manager to issue Istio intermediate CA certificates for your Gloo Mesh workload clusters. For more background about Istio certificates, see the [Istio CA overview](https://istio.io/latest/docs/concepts/security/#pki). +This guide will provide a high level overview on how you can use Infisical and [cert-manager](https://cert-manager.io/) to issue Istio intermediate CA certificates for your Gloo Mesh workload clusters. For more background about Istio certificates, see the [Istio CA overview](https://istio.io/latest/docs/concepts/security/#pki). ## Overview -In this setup, we will use Infisical PKI to generate and store your root CA and subordinate CAs that are used to generate Istio intermediate CAs for your Gloo Mesh workload clusters. +In this setup, we will use Infisical to generate and store your root CA and subordinate CAs that are used to generate Istio intermediate CAs for your Gloo Mesh workload clusters. To manage the lifecycle of Istio intermediate CA certificates, you'll also install [cert-manager](https://cert-manager.io/). Cert-manager is a Kubernetes controller that helps you automate the process of obtaining and renewing certificates from various PKI providers. @@ -21,19 +21,19 @@ With this approach, you get the following benefits: ## General Setup The certificate provisioning workflow begins with setting up your PKI hierarchy in Infisical, where you create root and subordinate certificate authorities. -When you deploy a `Certificate` CRD in your workload cluster, `cert-manager` uses the Infisical PKI Issuer controller to authenticate with Infisical using machine identity credentials and request an intermediate CA certificate. +When you deploy a `Certificate` CRD in your workload cluster, `cert-manager` uses the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme) configured on a [certificate profile](/documentation/platform/pki/certificates/profiles) to authenticate using EAB credentials and request an intermediate CA certificate. Infisical verifies the request against your certificate templates and returns the signed certificate. From there, Istio's control plane will automatically use this intermediate CA to sign leaf certificates for workloads in the service mesh, enabling secure mTLS communication across your entire Gloo Mesh infrastructure. -Follow the [Infisical PKI Issuer guide](/documentation/platform/pki/pki-issuer) for detailed instructions on how to set up the Infisical PKI Issuer and cert-manager for your Istio intermediate CA certificates in Gloo Mesh clusters. +Follow the [Kubernetes cert-manager guide](/documentation/platform/pki/k8s-cert-manager) for detailed instructions on how to set up the Infisical and cert-manager for your Istio intermediate CA certificates in Gloo Mesh clusters. For Gloo Mesh-specific configuration, ensure that: - The Certificate resource targets the `istio-system` namespace with `secretName: cacerts` -- Certificate templates in Infisical PKI are configured for intermediate CA usage with appropriate key usage and constraints -- Multiple workload clusters use the same Infisical PKI root to enable cross-cluster mTLS communication +- Certificate profiles in Infisical are configured for intermediate CA usage with appropriate key usage and constraints +- Multiple workload clusters use the same Infisical root to enable cross-cluster mTLS communication ## Using the certificates Once the `cacerts` Kubernetes secret is created in the `istio-system` namespace, Istio automatically uses the custom CA certificate instead of the default self-signed certificate. -When you deploy applications to your Gloo Mesh service mesh, the workloads will receive leaf certificates signed by your Infisical PKI intermediate CA, enabling secure mTLS communication across your entire mesh infrastructure. +When you deploy applications to your Gloo Mesh service mesh, the workloads will receive leaf certificates signed by your Infisical intermediate CA, enabling secure mTLS communication across your entire mesh infrastructure. diff --git a/docs/documentation/platform/pki/integration-guides/jboss-certbot.mdx b/docs/documentation/platform/pki/integration-guides/jboss-certbot.mdx index c0e1c896b..f666e8be6 100644 --- a/docs/documentation/platform/pki/integration-guides/jboss-certbot.mdx +++ b/docs/documentation/platform/pki/integration-guides/jboss-certbot.mdx @@ -1,9 +1,9 @@ --- title: "JBoss/WildFly" -description: "Learn how to issue SSL/TLS certificates from Infisical using ACME enrollment on JBoss/WildFly with Certbot" +description: "Learn how to issue TLS certificates from Infisical using ACME enrollment on JBoss/WildFly with Certbot" --- -This guide demonstrates how to use Infisical to issue SSL/TLS certificates for your [JBoss](https://www.jboss.org/)/[WildFly](https://wildfly.org/) application server. +This guide demonstrates how to use Infisical to issue TLS certificates for your [JBoss](https://www.jboss.org/)/[WildFly](https://wildfly.org/) application server. It uses [Certbot](https://certbot.eff.org/), an installable [ACME](https://en.wikipedia.org/wiki/Automatic_Certificate_Management_Environment) client, to request and renew certificates from Infisical using the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme) configured on a [certificate profile](/documentation/platform/pki/certificates/profiles). JBoss/WildFly requires certificates in Java keystore format, which this guide addresses through the certificate conversion process. @@ -223,4 +223,5 @@ Before you begin, make sure you have: Certbot automatically renews certificates when they are within 30 days of expiration using its built-in systemd timer. The deploy hook above will run after each successful renewal, handling the keystore conversion and service restart automatically. Because JBoss/WildFly requires the standalone authenticator (which stops the service temporarily), plan for brief service interruptions during renewal. - \ No newline at end of file + + diff --git a/docs/documentation/platform/pki/integration-guides/nginx-certbot.mdx b/docs/documentation/platform/pki/integration-guides/nginx-certbot.mdx index f28e5ee09..4e0cdba1d 100644 --- a/docs/documentation/platform/pki/integration-guides/nginx-certbot.mdx +++ b/docs/documentation/platform/pki/integration-guides/nginx-certbot.mdx @@ -1,9 +1,9 @@ --- title: "Nginx" -description: "Learn how to issue SSL/TLS certificates from Infisical using ACME enrollment on Nginx with Certbot" +description: "Learn how to issue TLS certificates from Infisical using ACME enrollment on Nginx with Certbot" --- -This guide demonstrates how to use Infisical to issue SSL/TLS certificates for your [Nginx](https://nginx.org/) server. +This guide demonstrates how to use Infisical to issue TLS certificates for your [Nginx](https://nginx.org/) server. It uses [Certbot](https://certbot.eff.org/), an installable [ACME](https://en.wikipedia.org/wiki/Automatic_Certificate_Management_Environment) client, to request and renew certificates from Infisical using the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme) configured on a [certificate profile](/documentation/platform/pki/certificates/profiles). diff --git a/docs/documentation/platform/pki/integration-guides/tomcat-certbot.mdx b/docs/documentation/platform/pki/integration-guides/tomcat-certbot.mdx index ffb07bf1b..3b8727748 100644 --- a/docs/documentation/platform/pki/integration-guides/tomcat-certbot.mdx +++ b/docs/documentation/platform/pki/integration-guides/tomcat-certbot.mdx @@ -1,9 +1,9 @@ --- title: "Tomcat" -description: "Learn how to issue SSL/TLS certificates from Infisical using ACME enrollment on Tomcat with Certbot" +description: "Learn how to issue TLS certificates from Infisical using ACME enrollment on Tomcat with Certbot" --- -This guide demonstrates how to use Infisical to issue SSL/TLS certificates for your [Apache Tomcat](https://tomcat.apache.org/) application server. +This guide demonstrates how to use Infisical to issue TLS certificates for your [Apache Tomcat](https://tomcat.apache.org/) application server. It uses [Certbot](https://certbot.eff.org/), an installable [ACME](https://en.wikipedia.org/wiki/Automatic_Certificate_Management_Environment) client, to request and renew certificates from Infisical using the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme) configured on a [certificate profile](/documentation/platform/pki/certificates/profiles). Unlike web servers with native Certbot plugins, Tomcat requires certificates to be manually configured after issuance. @@ -248,4 +248,5 @@ Before you begin, make sure you have: Since Tomcat reads certificates from the file system on startup, you only need to restart the service after certificate renewal. The certificate file paths in `/etc/letsencrypt/live/` are symbolic links that automatically point to the latest certificates. - \ No newline at end of file + + diff --git a/docs/documentation/platform/pki/integration-guides/windows-server-acme.mdx b/docs/documentation/platform/pki/integration-guides/windows-server-acme.mdx index 2aab0870d..0a630a10a 100644 --- a/docs/documentation/platform/pki/integration-guides/windows-server-acme.mdx +++ b/docs/documentation/platform/pki/integration-guides/windows-server-acme.mdx @@ -1,9 +1,9 @@ --- title: "Windows Server" -description: "Learn how to issue SSL/TLS certificates from Infisical using ACME enrollment on Windows Server with win-acme" +description: "Learn how to issue TLS certificates from Infisical using ACME enrollment on Windows Server with win-acme" --- -This guide demonstrates how to use Infisical to issue SSL/TLS certificates for your [Windows Server](https://www.microsoft.com/en-us/windows-server) environments. +This guide demonstrates how to use Infisical to issue TLS certificates for your [Windows Server](https://www.microsoft.com/en-us/windows-server) environments. It uses [win-acme](https://www.win-acme.com/), a feature-rich [ACME](https://en.wikipedia.org/wiki/Automatic_Certificate_Management_Environment) client designed specifically for Windows, to request and renew certificates from Infisical using the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme) configured on a [certificate profile](/documentation/platform/pki/certificates/profiles). Win-acme offers excellent integration with IIS, Windows Certificate Store, and various certificate storage options. @@ -191,4 +191,5 @@ Before you begin, make sure you have: + diff --git a/docs/documentation/platform/pki/k8s-cert-manager.mdx b/docs/documentation/platform/pki/k8s-cert-manager.mdx new file mode 100644 index 000000000..b0f696ba9 --- /dev/null +++ b/docs/documentation/platform/pki/k8s-cert-manager.mdx @@ -0,0 +1,267 @@ +--- +title: "Kubernetes cert-manager" +description: "Learn how to automatically provision and manage TLS certificates in Kubernetes using Infisical" +--- + +## Concept + +This guide demonstrates how to use Infisical to issue TLS certificates back to your Kubernetes environment using [cert-manager](https://cert-manager.io/). + +It uses the [ACME issuer type](https://cert-manager.io/docs/configuration/acme/) to request and renew certificates automatically from Infisical +using the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme) configured on a [certificate profile](/documentation/platform/pki/certificates/profiles). The issuer is perfect at obtaining X.509 certificates for Ingresses and other Kubernetes resources and can automatically renew them before expiration. + +The typical workflow involves installing `cert-manager` and configuring resources that represent the connection details to Infisical as well as the certificates you want to issue. +Each issued certificate and its corresponding private key are stored in a Kubernetes `Secret`. + +We recommend reading the official [cert-manager documentation](https://cert-manager.io/docs/) for a complete overview. +For the ACME-specific configuration, refer to the [ACME section](https://cert-manager.io/docs/configuration/acme/). + +## Workflow + +A typical workflow for using cert-manager with Infisical via ACME consists of the following steps: + +1. Create a [certificate profile](/documentation/platform/pki/certificates/profiles) in Infisical with the [ACME enrollment method](/documentation/platform/pki/enrollment-methods/acme) configured on it. +2. Install `cert-manager` in your Kubernetes cluster. +3. Create a Kubernetes `Secret` containing the EAB (External Account Binding) credentials for the ACME certificate profile. +4. Create an `Issuer` or `ClusterIssuer` resource that connects to the desired Infisical [certificate profile](/documentation/platform/pki/certificates/profiles). +5. Create a `Certificate` resource defining the certificate you wish to issue and the target `Secret` where the certificate and private key will be stored. +6. Use the resulting Kubernetes `Secret` in your Ingresses or other resources. + +## Guide + +The following steps show how to install cert-manager (using `kubectl`) and obtain certificates from Infisical. + + + + + Follow the instructions [here](/documentation/platform/pki/enrollment-methods/acme) to create a certificate profile that uses ACME enrollment. + + After completion, you will have the following values: + - **ACME Directory URL** + - **EAB Key ID (KID)** + - **EAB Secret** + + These will be needed in later steps. + + + Currently, the Infisical ACME enrollment method only supports authentication via dedicated EAB credentials generated per certificate profile. + + Support for [Kubernetes Auth](/documentation/platform/identities/kubernetes-auth) is planned for the near future. + + + + + + Install cert-manager in your Kubernetes cluster by following the official guide [here](https://cert-manager.io/docs/installation/) or by applying the manifest directly: + + ```bash + kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.19.1/cert-manager.yaml + ``` + + + + Create a Kubernetes `Secret` that contains the **EAB Secret (HMAC key)** obtained in step 1. + The cert-manager uses this secret to authenticate with the Infisical ACME server. + + + + ```bash + kubectl create secret generic infisical-acme-eab-secret \ + --namespace \ + --from-literal=eabSecret= + ``` + + + ```yaml acme-eab-secret.yaml + apiVersion: v1 + kind: Secret + metadata: + name: infisical-acme-eab-secret + namespace: + data: + eabSecret: + ``` + + ```bash + kubectl apply -f acme-eab-secret.yaml + ``` + + + + + + Next, create a cert-manager `Issuer` (or `ClusterIssuer`) by replacing the placeholders ``, ``, and `` in the configuration below and applying it. + This resource configures cert-manager to use your Infisical PKI collection's ACME server for certificate issuance. + + ```yaml issuer-infisical.yaml + apiVersion: cert-manager.io/v1 + kind: Issuer + metadata: + name: issuer-infisical + namespace: + spec: + acme: + # ACME server URL from your Infisical certificate profile (Step 1) + server: + # Email address for ACME account + # (any valid email works; currently ignored by Infisical) + email: + externalAccountBinding: + # EAB Key ID from Step 1 + keyID: + # Reference to the Kubernetes Secret containing the EAB + # HMAC key (created in Step 3) + keySecretRef: + name: infisical-acme-eab-secret + key: eabSecret + privateKeySecretRef: + name: issuer-infisical-account-key + solvers: + - http01: + ingress: + # Replace with your actual ingress class if different + className: nginx + ``` + + ``` + kubectl apply -f issuer-infisical.yaml + ``` + + You can check that the issuer was created successfully by running the following command: + + ```bash + kubectl get issuers.cert-manager.io -n -o wide + ``` + + ```bash + NAME AGE + issuer-infisical 21h + ``` + + + - Currently, the Infisical ACME server only supports the HTTP-01 challenge and requires successful challenge completion before issuing certificates. Support for optional challenges and DNS-01 is planned for a future release. + - An `Issuer` is namespace-scoped. Certificates can only be issued using an `Issuer` that exists in the same namespace as the `Certificate` resource. + - If you need to issue certificates across multiple namespaces with a single resource, create a `ClusterIssuer` instead. The configuration is identical except `kind: ClusterIssuer` and no `metadata.namespace`. + - More details: https://cert-manager.io/docs/configuration/acme/ + + + + + + Finally, request a certificate from Infisical ACME server by creating a cert-manager `Certificate` resource. + This configuration file specifies the details of the (end-entity/leaf) certificate to be issued. + + ```yaml certificate-issuer.yaml + apiVersion: cert-manager.io/v1 + kind: Certificate + metadata: + name: certificate-by-issuer + namespace: + spec: + dnsNames: + - certificate-by-issuer.example.com + # name of the resulting Kubernetes Secret + secretName: certificate-by-issuer + # total validity period of the certificate + duration: 48h + # cert-manager will attempt renewal 12 hours before expiry + renewBefore: 12h + privateKey: + algorithm: ECDSA + # uses NIST P-256 curve + size: 256 + issuerRef: + name: issuer-infisical + ``` + + The above sample configuration file specifies a certificate to be issued with the dns name `certificate-by-issuer.example.com` and ECDSA private key using the P-256 curve, valid for 48 hours; the certificate will be automatically renewed by `cert-manager` 12 hours before expiry. + The certificate is issued by the issuer `issuer-infisical` created in the previous step and the resulting certificate and private key will be stored in a secret named `certificate-by-issuer`. + + Note that the full list of the fields supported on the `Certificate` resource can be found in the API reference documentation [here](https://cert-manager.io/docs/reference/api-docs/#cert-manager.io/v1.CertificateSpec). + + You can check that the certificate was created successfully by running the following command: + + ```bash + kubectl get certificates -n -o wide + ``` + + ```bash + NAME READY SECRET ISSUER STATUS AGE + certificate-by-issuer True certificate-by-issuer issuer-infisical Certificate is up to date and has not expired 20h + ``` + + + + Since the actual certificate and private key are stored in a Kubernetes secret, we can check that the secret was created successfully by running the following command: + + ```bash + kubectl get secret certificate-by-issuer -n + ``` + + ```bash + NAME TYPE DATA AGE + certificate-by-issuer kubernetes.io/tls 2 26h + ``` + + We can `describe` the secret to get more information about it: + + ```bash + kubectl describe secret certificate-by-issuer -n default + ``` + + ```bash + Name: certificate-by-issuer + Namespace: default + Labels: controller.cert-manager.io/fao=true + Annotations: cert-manager.io/alt-names: + cert-manager.io/certificate-name: certificate-by-issuer + cert-manager.io/common-name: + cert-manager.io/alt-names: certificate-by-issuer.example.com + cert-manager.io/ip-sans: + cert-manager.io/issuer-group: cert-manager.io + cert-manager.io/issuer-kind: Issuer + cert-manager.io/issuer-name: issuer-infisical + cert-manager.io/uri-sans: + + Type: kubernetes.io/tls + + Data + ==== + ca.crt: 1306 bytes + tls.crt: 2380 bytes + tls.key: 227 bytes + ``` + + Here, `ca.crt` is the Root CA certificate, `tls.crt` is the requested certificate followed by the certificate chain, and `tls.key` is the private key for the certificate. + + We can decode the certificate and print it out using `openssl`: + + ```bash + kubectl get secret certificate-by-issuer -n default -o jsonpath='{.data.tls\.crt}' | base64 --decode | openssl x509 -text -noout + ``` + + In any case, the certificate is ready to be used as Kubernetes Secret by your Kubernetes resources. + + + + + +## FAQ + + + + The full list of the fields supported on the `Certificate` resource can be found in the API reference documentation [here](https://cert-manager.io/docs/reference/api-docs/#cert-manager.io/v1.CertificateSpec). + + + Currently, not all fields are supported by the Infisical PKI ACME server. + + + + + Yes. `cert-manager` will automatically renew certificates according to the `renewBefore` threshold of expiry as + specified in the corresponding `Certificate` resource. + + You can read more about the `renewBefore` field [here](https://cert-manager.io/docs/reference/api-docs/#cert-manager.io/v1.CertificateSpec). + + + diff --git a/docs/documentation/platform/pki/pki-issuer.mdx b/docs/documentation/platform/pki/pki-issuer.mdx deleted file mode 100644 index a1d07c98b..000000000 --- a/docs/documentation/platform/pki/pki-issuer.mdx +++ /dev/null @@ -1,305 +0,0 @@ ---- -title: "Kubernetes Issuer" -description: "Learn how to automatically provision and manage TLS certificates in Kubernetes using Infisical PKI" ---- - -## Concept - -The Infisical PKI Issuer is an installable Kubernetes [cert-manager](https://cert-manager.io/) controller that uses Infisical PKI to sign certificate requests. The issuer is perfect for getting X.509 certificates for ingresses and other Kubernetes resources and capable of automatically renewing certificates as needed. - -As part of the workflow, you install `cert-manager`, the Infisical PKI Issuer, and configure resources to represent the connection details to your Infisical PKI and the certificates you wish to issue. Each issued certificate and corresponding private key is made available in a Kubernetes secret. - -We recommend reading the [cert-manager documentation](https://cert-manager.io/docs/) for a fuller understanding of all the moving parts. - -## Workflow - -A typical workflow for using the Infisical PKI Issuer to issue certificates for your Kubernetes resources consists of the following steps: - -1. Creating a machine identity in Infisical. -2. Creating a Kubernetes secret to store the credentials of the machine identity. -3. Installing `cert-manager` into your Kubernetes cluster. -4. Installing the Infisical PKI Issuer controller into your Kubernetes cluster. -5. Creating an `Issuer` or `ClusterIssuer` resource in your Kubernetes cluster to represent the Infisical PKI issuer you wish to use. -6. Create the approver policy to accept certificate request. -7. Creating a `Certificate` resource in your Kubernetes cluster to represent a certificate you wish to issue. As part of this step, you specify the Kubernetes `Secret` to create and store the issued certificate and private key. -8. Consuming the issued certificate across your Kubernetes resources from the specified Kubernetes `Secret`. - -## Guide - -In the following steps, we explore how to install the Infisical PKI Issuer using [kubectl](https://github.com/kubernetes/kubectl) and use it to obtain certificates for your Kubernetes resources. - - - - - Follow the instructions [here](/documentation/platform/identities/universal-auth) to configure a [machine identity](/documentation/platform/identities/machine-identities) in Infisical with Universal Auth. - - By the end of this step, you should have a **Client ID** and **Client Secret** on hand as part of the Universal Auth configuration for the Infisical PKI Issuer to authenticate with Infisical; this will be useful in steps 4 and 5. - - - Currently, the Infisical PKI Issuer only supports authenticating with Infisical via the [Universal Auth](/documentation/platform/identities/universal-auth) authentication method. - - We're planning to add support for [Kubernetes Auth](/documentation/platform/identities/kubernetes-auth) in the near future. - - - - Install `cert-manager` into your Kubernetes cluster by following the instructions [here](https://cert-manager.io/docs/installation/) or by running the following command: - - ```bash - kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.15.3/cert-manager.yaml - ``` - - - Install the Infisical PKI Issuer controller into your Kubernetes cluster using one of the following methods: - - - - ```bash - helm repo add infisical-helm-charts 'https://dl.cloudsmith.io/public/infisical/helm-charts/helm/charts/' - helm install infisical-pki-issuer infisical-helm-charts/infisical-pki-issuer - ``` - - - ```bash - kubectl apply -f https://raw.githubusercontent.com/Infisical/infisical-issuer/main/build/install.yaml - ``` - - - - - Start by creating a Kubernetes `Secret` containing the **Client Secret** from step 1. As mentioned previously, this will be used by the Infisical PKI issuer to authenticate with Infisical. - - - - ```bash - kubectl create secret generic issuer-infisical-client-secret \ - --namespace \ - --from-literal=clientSecret= - ``` - - - ```yaml secret-issuer.yaml - apiVersion: v1 - kind: Secret - metadata: - name: issuer-infisical-client-secret - namespace: - data: - clientSecret: - ``` - - ```bash - kubectl apply -f secret-issuer.yaml - ``` - - - - - Next, create the Infisical PKI Issuer by filling out `url`, `clientId`, `projectId` or `certificateTemplateName`, and applying the following configuration file for the `Issuer` resource. - This configuration file specifies the connection details to your Infisical PKI CA to be used for issuing certificates. - - ```yaml infisical-issuer.yaml - apiVersion: infisical-issuer.infisical.com/v1alpha1 - kind: Issuer - metadata: - name: issuer-infisical - namespace: - spec: - url: "https://app.infisical.com" # the URL of your Infisical instance - projectId: # the ID of the project you want to use to issue certificates - certificateTemplateName: # the name of the certificate template you want to use to issue certificates against - authentication: - universalAuth: - clientId: # the Client ID from step 1 - secretRef: # reference to the Secret created in step 4 - name: "issuer-infisical-client-secret" - key: "clientSecret" - ``` - - ``` - kubectl apply -f infisical-issuer.yaml - ``` - - You can check that the issuer was created successfully by running the following command: - - ```bash - kubectl get issuers.infisical-issuer.infisical.com -n -o wide - ``` - - ```bash - NAME AGE - issuer-infisical 21h - ``` - - - An `Issuer` is a namespaced resource, and it is not possible to issue certificates from an `Issuer` in a different namespace. - This means you will need to create an `Issuer` in each namespace you wish to obtain `Certificates` in. - - If you want to create a single `Issuer` that can be consumed in multiple namespaces, you should consider creating a `ClusterIssuer` resource. This is almost identical to the `Issuer` resource, however is non-namespaced so it can be used to issue `Certificates` across all namespaces. - - You can read more about the `Issuer` and `ClusterIssuer` resources [here](https://cert-manager.io/docs/configuration/). - - - - If you create a `CertificateRequest` now, you'll notice it's neither approved nor denied. This is expected because by default cert-manager approver controller requires an approver-policy. - - To enable approval, create the following YAML file and apply it: - - ```yaml infisical-approver-policy.yaml - apiVersion: rbac.authorization.k8s.io/v1 - kind: ClusterRole - metadata: - name: infisical-issuer-approver - rules: - # Permission to approve or deny CertificateRequests for signers in cert-manager.io API group - - apiGroups: ['cert-manager.io'] - resources: ['signers'] - verbs: ['approve'] - resourceNames: - # Grant approval permissions for namespaced issuers - - "issuers.infisical-issuer.infisical.com/default.issuer-infisical" - # Grant approval permissions for cluster-scoped issuers - - "clusterissuers.infisical-issuer.infisical.com/clusterissuer-infisical" - --- - # Bind the cert-manager service account to the new role - apiVersion: rbac.authorization.k8s.io/v1 - kind: ClusterRoleBinding - metadata: - name: infisical-issuer-approver-binding - subjects: - - kind: ServiceAccount - name: cert-manager - namespace: cert-manager - roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: infisical-issuer-approver - ``` - - ``` - kubectl apply -f infisical-approver-policy.yaml - ``` - - This configuration creates a `ClusterRole` named `infisical-issuer-approver` that grants approval permissions for specific Infisical issuer types. It then binds this role to the cert-manager service account, allowing it to approve certificate requests from your Infisical issuers. - - For information, check out [cert manager approval policy doc](https://cert-manager.io/docs/policy/approval/approver-policy/). - - - - Finally, create a `Certificate` by applying the following configuration file. - This configuration file specifies the details of the (end-entity/leaf) certificate to be issued. - - ```yaml certificate-issuer.yaml - apiVersion: cert-manager.io/v1 - kind: Certificate - metadata: - name: certificate-by-issuer - namespace: - spec: - commonName: certificate-by-issuer.example.com # the common name for the certificate - secretName: certificate-by-issuer # the name of the Kubernetes Secret to create and store the certificate and private key in - issuerRef: - name: issuer-infisical - group: infisical-issuer.infisical.com - kind: Issuer - privateKey: # the algorithm and key size to use - algorithm: ECDSA - size: 256 - duration: 48h # the ttl for the certificate - renewBefore: 12h # the time before the certificate expiry that the certificate should be automatically renewed - ``` - - The above sample configuration file specifies a certificate to be issued with the common name `certificate-by-issuer.example.com` and ECDSA private key using the P-256 curve, valid for 48 hours; the certificate will be automatically renewed by `cert-manager` 12 hours before expiry. - The certificate is issued by the issuer `issuer-infisical` created in the previous step and the resulting certificate and private key will be stored in a secret named `certificate-by-issuer`. - - Note that the full list of the fields supported on the `Certificate` resource can be found in the API reference documentation [here](https://cert-manager.io/docs/reference/api-docs/#cert-manager.io/v1.CertificateSpec). - - You can check that the certificate was created successfully by running the following command: - - ```bash - kubectl get certificates -n -o wide - ``` - - ```bash - NAME READY SECRET ISSUER STATUS AGE - certificate-by-issuer True certificate-by-issuer issuer-infisical Certificate is up to date and has not expired 20h - ``` - - - Since the actual certificate and private key are stored in a Kubernetes secret, we can check that the secret was created successfully by running the following command: - - ```bash - kubectl get secret certificate-by-issuer -n - ``` - - ```bash - NAME TYPE DATA AGE - certificate-by-issuer kubernetes.io/tls 2 26h - ``` - - We can `describe` the secret to get more information about it: - - ```bash - kubectl describe secret certificate-by-issuer -n default - ``` - - ```bash - Name: certificate-by-issuer - Namespace: default - Labels: controller.cert-manager.io/fao=true - Annotations: cert-manager.io/alt-names: - cert-manager.io/certificate-name: certificate-by-issuer - cert-manager.io/common-name: certificate-by-issuer.example.com - cert-manager.io/ip-sans: - cert-manager.io/issuer-group: infisical-issuer.infisical.com - cert-manager.io/issuer-kind: Issuer - cert-manager.io/issuer-name: issuer-infisical - cert-manager.io/uri-sans: - - Type: kubernetes.io/tls - - Data - ==== - ca.crt: 1306 bytes - tls.crt: 2380 bytes - tls.key: 227 bytes - ``` - - Here, `ca.crt` is the Root CA certificate, `tls.crt` is the requested certificate followed by the certificate chain, and `tls.key` is the private key for the certificate. - - We can decode the certificate and print it out using `openssl`: - - ```bash - kubectl get secret certificate-by-issuer -n default -o jsonpath='{.data.tls\.crt}' | base64 --decode | openssl x509 -text -noout - ``` - - In any case, the certificate is ready to be used as Kubernetes Secret by your Kubernetes resources. - - - - -## FAQ - - - - The full list of the fields supported on the `Certificate` resource can be found in the API reference documentation [here](https://cert-manager.io/docs/reference/api-docs/#cert-manager.io/v1.CertificateSpec). - - - Currently, not all fields are supported by the Infisical PKI Issuer. - - - - - Yes. `cert-manager` will automatically renew certificates according to the `renewBefore` threshold of expiry as - specified in the corresponding `Certificate` resource. - - You can read more about the `renewBefore` field [here](https://cert-manager.io/docs/reference/api-docs/#cert-manager.io/v1.CertificateSpec). - - - - If you see log messages similar to: - ``` - "CertificateRequest has not been approved yet. Ignoring.","controller":"certificaterequest","controllerGroup":"cert-manager.io","controllerKind":"CertificateRequest","CertificateRequest":{"name":"skynet-infisical-rta-rsa2048-1","namespace":"infisical-system"},"namespace":"infisical-system","name":"skynet-infisical-rta-rsa2048-1","reconcileID":"bfb7cad9-d867-45b5-b3a3-0139e731b7a6"} - ``` - This indicates that the `CertificateRequest` has been created, but `cert-manager` has not yet approved it. This typically occurs because a necessary approver policy is missing. Refer to the documentation above to create an approver policy. - - diff --git a/docs/images/app-connections/dns-made-easy/copy-api-credentials.png b/docs/images/app-connections/dns-made-easy/copy-api-credentials.png new file mode 100644 index 000000000..557085294 Binary files /dev/null and b/docs/images/app-connections/dns-made-easy/copy-api-credentials.png differ diff --git a/docs/images/app-connections/dns-made-easy/dns-made-easy-app-connection-created.png b/docs/images/app-connections/dns-made-easy/dns-made-easy-app-connection-created.png new file mode 100644 index 000000000..41d6fd90d Binary files /dev/null and b/docs/images/app-connections/dns-made-easy/dns-made-easy-app-connection-created.png differ diff --git a/docs/images/app-connections/dns-made-easy/dns-made-easy-app-connection-form.png b/docs/images/app-connections/dns-made-easy/dns-made-easy-app-connection-form.png new file mode 100644 index 000000000..0e6599231 Binary files /dev/null and b/docs/images/app-connections/dns-made-easy/dns-made-easy-app-connection-form.png differ diff --git a/docs/images/app-connections/dns-made-easy/dns-made-easy-app-connection-select.png b/docs/images/app-connections/dns-made-easy/dns-made-easy-app-connection-select.png new file mode 100644 index 000000000..28f67fcf2 Binary files /dev/null and b/docs/images/app-connections/dns-made-easy/dns-made-easy-app-connection-select.png differ diff --git a/docs/images/app-connections/dns-made-easy/generate-new-api-credentials.png b/docs/images/app-connections/dns-made-easy/generate-new-api-credentials.png new file mode 100644 index 000000000..de56f75fd Binary files /dev/null and b/docs/images/app-connections/dns-made-easy/generate-new-api-credentials.png differ diff --git a/docs/images/app-connections/dns-made-easy/nav-to-account-info.png b/docs/images/app-connections/dns-made-easy/nav-to-account-info.png new file mode 100644 index 000000000..56094c55e Binary files /dev/null and b/docs/images/app-connections/dns-made-easy/nav-to-account-info.png differ diff --git a/docs/images/integrations/octopus-deploy/integrations-octopus-deploy-create-team.png b/docs/images/integrations/octopus-deploy/integrations-octopus-deploy-create-team.png index 9cb703e12..3a33e4351 100644 Binary files a/docs/images/integrations/octopus-deploy/integrations-octopus-deploy-create-team.png and b/docs/images/integrations/octopus-deploy/integrations-octopus-deploy-create-team.png differ diff --git a/docs/images/platform/pki/certificate/cert-profile-modal.png b/docs/images/platform/pki/certificate/cert-profile-modal.png index 29280d01c..961ad466a 100644 Binary files a/docs/images/platform/pki/certificate/cert-profile-modal.png and b/docs/images/platform/pki/certificate/cert-profile-modal.png differ diff --git a/docs/integrations/app-connections/dns-made-easy.mdx b/docs/integrations/app-connections/dns-made-easy.mdx new file mode 100644 index 000000000..f2fe297bf --- /dev/null +++ b/docs/integrations/app-connections/dns-made-easy.mdx @@ -0,0 +1,59 @@ +--- +title: "DNS Made Easy" +description: "Learn how to configure a DNS Made Easy Connection for Infisical." +--- + +Infisical supports connecting to DNS Made Easy using API key and secret key for secure access to your DNS Made Easy service. + +## Configure API key and secret Key for Infisical + + + + Navigate to your DNS Made Easy dashboard and go to **Account Information** under the **Config** top menu. + + ![Navigate to Account Information](/images/app-connections/dns-made-easy/nav-to-account-info.png) + + If your **API Key** and **Secret Key** are already available, proceed to step 2. + + Otherwise, check the **Generate New API Credentials** then click the **Save** button to generate the new API credentials. + + ![Generate API Credentials](/images/app-connections/dns-made-easy/generate-new-api-credentials.png) + + + + After creation, copy your API key and secret key. + + ![Generated API Token](/images/app-connections/dns-made-easy/copy-api-credentials.png) + + + Keep your API key and secret key secure and do not share it. + Anyone with access to this token can manage your DNS Made Easy resources. + + + + + +## Setup DNS Made Easy Connection in Infisical + + + + Navigate to the **App Connections** page in the desired project. ![App + Connections Tab](/images/app-connections/general/add-connection.png) + + + Select the **DNS Made Easy Connection** option from the connection options + modal. ![Select DNS Made Easy + Connection](/images/app-connections/dns-made-easy/dns-made-easy-app-connection-select.png) + + + Enter your DNS Made Easy API key and secret key in the provided fields and + click **Connect to DNS Made Easy** to establish the connection. ![Connect to + DNS Made + Easy](/images/app-connections/dns-made-easy/dns-made-easy-app-connection-form.png) + + + Your **DNS Made Easy Connection** is now available for use in your Infisical + projects. ![DNS Made Easy Connection + Created](/images/app-connections/dns-made-easy/dns-made-easy-app-connection-created.png) + + diff --git a/docs/integrations/cicd/octopus-deploy.mdx b/docs/integrations/cicd/octopus-deploy.mdx index 90f06e09a..e5de57747 100644 --- a/docs/integrations/cicd/octopus-deploy.mdx +++ b/docs/integrations/cicd/octopus-deploy.mdx @@ -50,6 +50,11 @@ Prerequisites: ![integrations octopus deploy add team](/images/integrations/octopus-deploy/integrations-octopus-deploy-create-team.png) + + If you need to sync only one space, assign the service account to that space. Otherwise, allow access to all spaces for multi-space syncs. + + + On the **Members** tab, click on the **Add Member** button, add your **Infisical Service Account** and click on the **Add** button. ![integrations octopus deploy add service account to team](/images/integrations/octopus-deploy/integrations-octopus-deploy-add-to-team.png) diff --git a/docs/integrations/platforms/aws/lambda.mdx b/docs/integrations/platforms/aws/lambda.mdx new file mode 100644 index 000000000..8376e98c2 --- /dev/null +++ b/docs/integrations/platforms/aws/lambda.mdx @@ -0,0 +1,98 @@ +--- +title: "AWS Lambda" +sidebarTitle: "AWS Lambda" +description: "How to use Infisical secrets in AWS Lambda" +--- + +Learn how to sync Infisical secrets to AWS Lambda regardless of how you deploy your function. This guide covers the following strategies: + +- Infisical SDKs +- AWS Secrets Manager integration +- AWS Systems Manager Parameter Store integration +- AWS CLI + +## Choose your sync strategy + +### 1. Fetch secrets at runtime with Infisical SDKs + +If you control the Lambda code, the simplest method is to fetch secrets directly from Infisical using one of our SDKs. +You can read more about the Infisical SDKs [here](/sdks/overview). + +### 2. Push via secret sync + +Configure a secret sync from your Infisical project, and Infisical will keep your Secrets Manager or Parameter Store values up to date. Your Lambda function can then reference those secrets directly. +Learn more about the [AWS Secrets Manager integration](/integrations/secret-syncs/aws-secrets-manager) and the [AWS Parameter Store integration](/integrations/secret-syncs/aws-parameter-store). + +### 3. Push environment variables directly using the AWS CLI + +For straightforward workflows or quick rotations, you can push Infisical secrets directly into Lambda environment variables using the AWS CLI. + +## Prerequisites + +- AWS CLI v2 installed and authenticated +- `jq` installed locally +- An IAM principal with `lambda:UpdateFunctionConfiguration` +- Infisical CLI (`infisical`) configured + +### IAM permissions + +Attach a policy like the one below to the IAM user or role responsible for updating Lambda configuration: + +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "LambdaConfig", + "Effect": "Allow", + "Action": ["lambda:UpdateFunctionConfiguration"], + "Resource": "*" + } + ] +} +``` + + + {" "} + Replacing Lambda environment variables using the AWS CLI overwrites the entire + `Variables` object. Make sure to export your current values so you can import them + into Infisical.{" "} + + +#### Push secrets to Lambda + +Use the Infisical CLI to export secrets as JSON and pass them to the AWS CLI. +The example below targets a project by ID, but you can also use the `--project` and `--env` flags. +Learn more about `infisical export` [here](/cli/commands/export#infisical-export). + +```bash +FUNCTION_NAME=infisical-env-test +REGION=us-east-1 +PROJECT_ID=1234567890 + +aws lambda update-function-configuration \ + --function-name "$FUNCTION_NAME" \ + --region "$REGION" \ + --environment "$( + infisical export \ + --format=json \ + --projectId="$PROJECT_ID" \ + | jq 'map({(.key): .value}) | add | {Variables: .}' + )" +``` + +On success, the updated `Environment.Variables` block will be returned. +Verify the values in the Lambda console or by invoking the function. + + + Automate this step in CI/CD. Run `infisical export` using an Infisical Token + scoped to your project and environment, and trigger the sync as part of your + deployment workflow. Learn more about the [Infisical + Token](/cli/commands/export#infisical-export:infisical-token). + + + + We recommend using automatic secret syncs to AWS Secrets Manager or AWS + Parameter Store to keep your secrets continuously in sync and avoid manually + updating the Lambda configuration. + diff --git a/docs/integrations/platforms/infisical-agent.mdx b/docs/integrations/platforms/infisical-agent.mdx index 43d322faa..883d248ae 100644 --- a/docs/integrations/platforms/infisical-agent.mdx +++ b/docs/integrations/platforms/infisical-agent.mdx @@ -8,12 +8,12 @@ It eliminates the need to modify application logic by enabling clients to decide ![agent diagram](/images/agent/infisical-agent-diagram.png) -### Key features: +## Key Features -- Token renewal: Automatically authenticates with Infisical and deposits renewed access tokens at specified path for applications to consume -- Templating: Renders secrets via user provided templates to desired formats for applications to consume +- **Token lifecycle management**: Automatically authenticates with Infisical and deposits renewed access tokens at specified path for applications to consume +- **Templating**: Renders secrets and dynamic secret leases via user provided templates to desired formats for applications to consume -### Token renewal +## Token Renewal The Infisical agent can help manage the life cycle of access tokens. The token renewal process is split into two main components: a `Method`, which is the authentication process suitable for your current setup, and `Sinks`, which are the places where the agent deposits the new access token whenever it receives updates. @@ -28,7 +28,7 @@ Every time the agent successfully retrieves a new access token, it writes the ne to retrieve secrets from Infisical -### Templating +## Templating The Infisical agent can help deliver formatted secrets to your application in a variety of environments. To achieve this, the agent will retrieve secrets from Infisical, format them using a specified template, and then save these formatted secrets to a designated file path. @@ -40,31 +40,203 @@ If this initial attempt is unsuccessful, the agent will momentarily pauses befor Once the agent successfully obtains a valid access token, the agent proceeds to fetch the secrets from Infisical using it. It then formats these secrets using the user provided templates and writes the formatted data to configured file paths. + +### Available secret template functions + +The secret template functions is what you will use to fetch resources such as static secrets and dynamic secret leases from Infisical. Below is a list of the available secret template functions that you can use in your templates. + + + + + ```bash + secret "" "environment-slug" "" "" + ``` + ```bash example-template-usage-1 + {{- with secret "6553ccb2b7da580d7f6e7260" "dev" "/" `{"recursive": false, "expandSecretReferences": true}` }} + {{- range . }} + {{ .Key }}={{ .Value }} + {{- end }} + {{- end }} + ``` + ```bash example-template-usage-2 + {{- with secret "da8056c8-01e2-4d24-b39f-cb4e004b8d44" "staging" "/" `{"recursive": true, "expandSecretReferences": true}` }} + {{- range . }} + {{- if eq .SecretPath "/"}} + {{ .Key }}={{ .Value }} + {{- else}} + {{ .SecretPath }}/{{ .Key }}={{ .Value }} + {{- end}} + {{- end }} + {{- end }} + ``` + + + + **Function name**: `secret` + + **Description**: This function can be used to render the full list of secrets within a given project, environment and secret path. + + An optional JSON argument is also available. It includes the properties `recursive`, which defaults to false, and `expandSecretReferences`, which defaults to true and expands the returned secrets. + + + **Returns**: A single secret object with the following keys `Key, WorkspaceId, Value, SecretPath, Type, ID, and Comment` + + + + + ```bash + getSecretByName "" "" "" "" + ``` + + ```bash example-template-usage + {{ with getSecretByName "d821f21d-aa90-453b-8448-8c78c1160a0e" "dev" "/" "POSTHOG_HOST"}} + {{ if .Value }} + password = "{{ .Value }}" + {{ end }} + {{ end }} + ``` + + **Function name**: `getSecretByName` + + **Description**: This function can be used to render a single secret by it's name. + + **Returns**: A list of secret objects with the following keys `Key, WorkspaceId, Value, Type, ID, and Comment` + + + + + ```bash + dynamic_secret "" "" "" "" "" + ``` + + ```bash example-redis-dynamic-secret + {{ with dynamic_secret "aaa-o7en-s5qm" "dev" "/" "redis" "1m" }} + {{ .DB_USERNAME }}={{ .DB_PASSWORD }} + {{- end }} + + ``` + + **Function Name**: `dynamic_secret` + + **Description**: This function can be used to render a dynamic secret lease credentials. The credentials are automatically renewed before they expire, ensuring that the rendered credentials are always up-to-date. + + **Returns**: An object with keys corresponding to the dynamic secret lease credentials. + + + Note that if you have multiple dynamic secret templates with identical configurations, only one lease will be created in Infisical for those templates, and the same lease will be written to your specified destination paths. + + + + + +## Caching + +The Infisical Agent supports clientside caching of Dynamic Secret leases. If the cache is enabled, the agent will persist the dynamic secret leases to the cache across restarts of the agent. + +### Persistent Caching + +The Agent currently only supports persistent caching. To utilize persistent caching, you must be within a Kubernetes environment. We recommend using the [Infisical Agent Injector](/integrations/platforms/kubernetes-injector) to inject the agent into pods within your Kubernetes cluster on demand. + +### Cache eviction + +Cache eviction is the process of removing cached data from the cache. The Agent will automatically evict cached data when the cache is full during a garbage collection cycle which is triggered every 10 minutes. + +The cache will also automatically evict cached data that has gone stale or is about to go stale. For dynamic resources (such as dynamic secret leases), there's a TTL (Time-to-Live) associated with each lease which is used to determine if the lease is stale or about to go stale. +If a stale dynamic secret lease is detected, it will be automatically evicted from the cache and replaced with a new up-to-date lease. + + +### Cache Configuration + +Configuring the cache is done through the agent configuration file. The following fields are available to configure the cache: + + + + + The type of persistent caching to use. Currently only `kubernetes` is available, and will only work within Kubernetes environments. + + + The path to where your persistent cache will be stored. + + + + Persistent caching is only supported within kubernetes environments at the moment. Please refer to the [Infisical Agent Injector](/integrations/platforms/kubernetes-injector) documentation for more information on how to use persistent caching within Kubernetes environments. + + + ```yaml example-agent-config-file.yaml + cache: + persistent: + type: "kubernetes" + path: "/home/infisical/cache" + service-account-token-path: "/var/run/secrets/kubernetes.io/serviceaccount/token" + ``` + + + + +## Retrying mechanism + +The agent will automatically attempt to retry failed API requests such as authentication, secrets retrieval, dynamic secret lease provisioning, etc. +By default, the agent will retry up to 3 times with a base delay of 200ms and a maximum delay of 5s. + +You can configure the retrying mechanism through the agent configuration file. The following fields are available to configure the retrying mechanism: + + + + How many times to retry failed API requests such as authentication, secret retrieval, etc. Defaults to `3` retries. + + + The maximum delay between retries. Defaults to `5s` (5 seconds). + + + The base delay between retries. Defaults to `200ms` (200 milliseconds). + + +```yaml example-agent-config-file.yaml +infisical: + address: "https://app.infisical.com" + retry-strategy: + max-retries: 3 + max-delay: "5s" + base-delay: "200ms" + +# ... rest of the agent configuration file +``` + + + ## Agent configuration file To set up the authentication method for token renewal and to define secret templates, the Infisical agent requires a YAML configuration file containing properties defined below. While specifying an authentication method is mandatory to start the agent, configuring sinks and secret templates are optional. -| Field | Description | -| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `infisical.address` | The URL of the Infisical service. Default: `"https://app.infisical.com"`. | -| `infisical.exit-after-auth` | Whether to exit the agent after authentication and first secret render. Default: `"false"`. | -| `infisical.revoke-credentials-on-shutdown` | Whether to revoke all managed dynamic secret leases and identity access tokens on shutdown. Default: `"false"`. | -| `auth.type` | The type of authentication method used. Available options: `universal-auth`, `kubernetes`, `azure`, `gcp-id-token`, `gcp-iam`, `aws-iam` | -| `auth.config.identity-id` | The file path where the machine identity id is stored

This field is required when using any of the following auth types: `kubernetes`, `azure`, `gcp-id-token`, `gcp-iam`, or `aws-iam`. | -| `auth.config.service-account-token` | Path to the Kubernetes service account token to use (optional)

Default: `/var/run/secrets/kubernetes.io/serviceaccount/token` | -| `auth.config.service-account-key` | Path to your GCP service account key file. This field is required when using `gcp-iam` auth type.

Please note that the file should be in JSON format. | -| `auth.config.client-id` | The file path where the universal-auth client id is stored. | -| `auth.config.client-secret` | The file path where the universal-auth client secret is stored. | -| `auth.config.remove_client_secret_on_read` | This will instruct the agent to remove the client secret from disk. | -| `sinks[].type` | The type of sink in a list of sinks. Each item specifies a sink type. Currently, only `"file"` type is available. | -| `sinks[].config.path` | The file path where the access token should be stored for each sink in the list. | -| `templates[].source-path` | The path to the template file that should be used to render secrets. | -| `templates[].template-content` | The inline secret template to be used for rendering the secrets. | -| `templates[].destination-path` | The path where the rendered secrets from the source template will be saved to. | -| `templates[].config.polling-interval` | How frequently to check for secret changes. Default: `5 minutes` (optional) | -| `templates[].config.execute.command` | The command to execute when secret change is detected (optional) | -| `templates[].config.execute.timeout` | How long in seconds to wait for command to execute before timing out (optional) | + + +| Field | Description | +| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `infisical.address` | The URL of the Infisical service. Default: `"https://app.infisical.com"`. | +| `infisical.exit-after-auth` | Whether to exit the agent after authentication and first secret render. Default: `"false"`. | +| `infisical.revoke-credentials-on-shutdown` | Whether to revoke all managed dynamic secret leases and identity access tokens on shutdown. Default: `"false"`. | +| `infisical.retry-strategy.max-retries` | How many times to retry failed API requests such as authentication, secret retrieval, etc. Defaults to `3` retries. | +| `infisical.retry-strategy.max-delay` | The maximum delay between retries. Defaults to `5s` (5 seconds). | +| `infisical.retry-strategy.base-delay` | The base delay between retries. Defaults to `200ms` (200 milliseconds). | +| `auth.type` | The type of authentication method used. Available options: `universal-auth`, `kubernetes`, `azure`, `gcp-id-token`, `gcp-iam`, `aws-iam` | +| `auth.config.identity-id` | The file path where the machine identity id is stored

This field is required when using any of the following auth types: `kubernetes`, `azure`, `gcp-id-token`, `gcp-iam`, or `aws-iam`. | +| `auth.config.service-account-token` | Path to the Kubernetes service account token to use (optional)

Default: `/var/run/secrets/kubernetes.io/serviceaccount/token` | +| `auth.config.service-account-key` | Path to your GCP service account key file. This field is required when using `gcp-iam` auth type.

Please note that the file should be in JSON format. | +| `auth.config.client-id` | The file path where the universal-auth client id is stored. | +| `auth.config.client-secret` | The file path where the universal-auth client secret is stored. | +| `auth.config.remove_client_secret_on_read` | This will instruct the agent to remove the client secret from disk. | +| `sinks[].type` | The type of sink in a list of sinks. Each item specifies a sink type. Currently, only `"file"` type is available. | +| `sinks[].config.path` | The file path where the access token should be stored for each sink in the list. | +| `cache.persistent.type` | The type of persistent caching to use. Currently only `kubernetes` is available, and will only work within Kubernetes environments. | +| `cache.persistent.path` | The path to where your persistent cache will be stored. | +| `cache.persistent.service-account-token-path` | The path to the Kubernetes service account token to use for encrypting the persistent cache. Required when using `kubernetes` cache type. Defaults to `/var/run/secrets/kubernetes.io/serviceaccount/token` | +| `templates[].source-path` | The path to the template file that should be used to render secrets. | +| `templates[].template-content` | The inline secret template to be used for rendering the secrets. | +| `templates[].destination-path` | The path where the rendered secrets from the source template will be saved to. | +| `templates[].config.polling-interval` | How frequently to check for secret changes. Default: `5m` (5 minutes) (optional) | +| `templates[].config.execute.command` | The command to execute when secret change is detected (optional) | +| `templates[].config.execute.timeout` | How long in seconds to wait for command to execute before timing out (optional) | ## Authentication @@ -308,81 +480,4 @@ After defining the agent configuration file, run the command below pointing to t ```bash infisical agent --config example-agent-config-file.yaml -``` - -### Available secret template functions - - - ```bash - listSecrets "" "environment-slug" "" "" - ``` - ```bash example-template-usage-1 - {{- with listSecrets "6553ccb2b7da580d7f6e7260" "dev" "/" `{"recursive": false, "expandSecretReferences": true}` }} - {{- range . }} - {{ .Key }}={{ .Value }} - {{- end }} - {{- end }} - ``` - ```bash example-template-usage-2 -{{- with secret "da8056c8-01e2-4d24-b39f-cb4e004b8d44" "staging" "/" `{"recursive": true, "expandSecretReferences": true}` }} -{{- range . }} -{{- if eq .SecretPath "/"}} -{{ .Key }}={{ .Value }} -{{- else}} -{{ .SecretPath }}/{{ .Key }}={{ .Value }} -{{- end}} -{{- end }} -{{- end }} - ``` - - - -**Function name**: listSecrets - -**Description**: This function can be used to render the full list of secrets within a given project, environment and secret path. - -An optional JSON argument is also available. It includes the properties `recursive`, which defaults to false, and `expandSecretReferences`, which defaults to true and expands the returned secrets. - - -**Returns**: A single secret object with the following keys `Key, WorkspaceId, Value, SecretPath, Type, ID, and Comment` - - - - - ```bash - getSecretByName "" "" "" "" - ``` - -```bash example-template-usage -{{ with getSecretByName "d821f21d-aa90-453b-8448-8c78c1160a0e" "dev" "/" "POSTHOG_HOST"}} -{{ if .Value }} -password = "{{ .Value }}" -{{ end }} -{{ end }} -``` - -**Function name**: getSecretByName - -**Description**: This function can be used to render a single secret by it's name. - -**Returns**: A list of secret objects with the following keys `Key, WorkspaceId, Value, Type, ID, and Comment` - - - - - ```bash - dynamic_secret "" "" "" "" "" - ``` - - ```bash example-redis-dynamic-secret - {{ with dynamic_secret "aaa-o7en-s5qm" "dev" "/" "redis" "1m" }} - {{ .DB_USERNAME }}={{ .DB_PASSWORD }} - {{- end }} - - **Function Name**: dynamic_secret - - **Description**: This function can be used to render a dynamic secret lease credentials. The credentials are automatically renewed before they expire, ensuring that the rendered credentials are always up-to-date. - - **Returns**: An object with keys corresponding to the dynamic secret lease credentials. - ``` - \ No newline at end of file +``` \ No newline at end of file diff --git a/docs/integrations/platforms/kubernetes-injector.mdx b/docs/integrations/platforms/kubernetes-injector.mdx index 9903dcbc3..f51a96ab2 100644 --- a/docs/integrations/platforms/kubernetes-injector.mdx +++ b/docs/integrations/platforms/kubernetes-injector.mdx @@ -120,19 +120,83 @@ You will need to set the `nodeSelector.kubernetes.io/os` label to `windows` and The Infisical Agent Injector supports the following annotations: - - The inject annotation is used to enable the injector on a pod. Set the value to `true` and the pod will be patched with an Infisical Agent container on update or create. - - - The inject mode annotation is used to specify the mode to use to inject the secrets into the pod. + + + The inject annotation is used to enable the injector on a pod. Set the value to `true` and the pod will be patched with an Infisical Agent container on update or create. + + + The inject mode annotation is used to specify the mode to use to inject the secrets into the pod. - - `init`: The init method will create an init container for the pod that will render the secrets into a shared volume mount within the pod. The agent init container will run before any other containers in the pod runs, including other init containers. - - `sidecar`: The sidecar method will create a sidecar container for the pod that will render the secrets into a shared volume mount within the pod. The agent sidecar container will run alongside the main container in the pod. This means that the secrets rendered will always be in sync with your Infisical secrets. - - `sidecar-init`: The sidecar-init method will create the init container and the sidecar container from the other two methods. The init container will run before any other container and fetch the secrets from the start and the sidecar container will keep the secrets in sync throughout the lifecycle of the deployment. - - - The agent config map annotation is used to specify the name of the config map that contains the configuration for the injector. The config map must be in the same namespace as the pod. - + - `init`: The init method will create an init container for the pod that will render the secrets into a shared volume mount within the pod. The agent init container will run before any other containers in the pod runs, including other init containers. + - `sidecar`: The sidecar method will create a sidecar container for the pod that will render the secrets into a shared volume mount within the pod. The agent sidecar container will run alongside the main container in the pod. This means that the secrets rendered will always be in sync with your Infisical secrets. + - `sidecar-init`: The sidecar-init method will create the init container and the sidecar container from the other two methods. The init container will run before any other container and fetch the secrets from the start and the sidecar container will keep the secrets in sync throughout the lifecycle of the deployment. + + + The agent config map annotation is used to specify the name of the config map that contains the configuration for the injector. The config map must be in the same namespace as the pod. + + + + Whether to enable client-side caching of dynamic secret leases. Defaults to `false`. If you set this to `true`, the agent will persist any dynamic secret leases across restarts of the agent. This is especially useful when using the `sidecar-init` inject mode, to pass the dynamic secret leases created in the init container to the sidecar container. + This will ensure that no new leases are created except those initially created in the init container. The sidecar container will register the leases created in the init container and start managing them from that point onwards. + + + + Whether to revoke all managed dynamic secret leases and machine identity access tokens on shutdown. Defaults to `false`. + + If you set this to `true`, all managed dynamic secret leases and machine identity access tokens will be revoked when a `SIGTERM` signal is sent to the agents container _(such as when a pod is terminated or when the pod is restarted)_. + + **Note:** In disaster events such as cluster power outages, a `SIGTERM` signal won't be sent to the agents container, and the credentials will not be revoked. + + + + How many times to retry failed API requests such as authentication, secret retrieval, etc. Defaults to `3` retries. Refer to the [Retrying mechanism](/integrations/platforms/infisical-agent#retrying-mechanism) documentation for more information on how to configure the retry strategy. + + + + The maximum delay between retries. Defaults to `5s` (5 seconds). Refer to the [Retrying mechanism](/integrations/platforms/infisical-agent#retrying-mechanism) documentation for more information on how to configure the retry strategy. + + + + The base delay between retries. Defaults to `200ms` (200 milliseconds). Refer to the [Retrying mechanism](/integrations/platforms/infisical-agent#retrying-mechanism) documentation for more information on how to configure the retry strategy. + + + + The maximum CPU limit for the agent containers. + + Linux Pods: Defaults to `500m` (500 milliCPUs). + Windows Pods: Defaults to `500m` (500 milliCPUs). + + + + The minimum CPU request for the agent containers. + + Linux Pods: Defaults to `100m` (100 milliCPUs). + Windows Pods: Defaults to `100m` (100 milliCPUs). + + + + The maximum memory limit for the agent containers. + + Linux Pods: Defaults to `128Mi` (128 megabytes). + Windows Pods: Defaults to `512Mi` (512 megabytes). + + + + The minimum memory request for the agent containers. + + Linux Pods: Defaults to `64Mi` (64 megabytes). + Windows Pods: Defaults to `256Mi` (256 megabytes). + + + + The maximum ephemeral storage limit for the agent containers. Doesn't have an explicit default value. The default value will conform to the default ephemeral storage limit for the pod. + + + + The minimum ephemeral storage request for the agent containers. Doesn't have an explicit default value. The default value will conform to the default ephemeral storage request for the pod. + + + ## ConfigMap Configuration @@ -141,18 +205,22 @@ The Infisical Agent Injector supports the following annotations: When you are configuring a pod to use the injector, you must create a config map in the same namespace as the pod you want to inject secrets into. The entire config needs to be of string format and needs to be assigned to the `config.yaml` key in the config map. You can find a full example of the config at the end of this section. + The address of your Infisical instance. This field is optional and will default to `https://app.infisical.com` if not provided. - Whether to revoke all managed dynamic secret leases and identity access tokens on shutdown. Default: `"false"`. + Whether to revoke all managed dynamic secret leases and machine identity access tokens on shutdown. Default: `"false"`. - If this is set to `true`, all managed dynamic secret leases and identity access tokens will be revoked when a `SIGTERM` signal is sent to the agents container _(such as when a pod is terminated or when the pod is restarted)_. + If this is set to `true`, all managed dynamic secret leases and machine identity access tokens will be revoked when a `SIGTERM` signal is sent to the agents container _(such as when a pod is terminated or when the pod is restarted)_. + **Note:** In disaster events such as cluster power outages, a `SIGTERM` signal won't be sent to the agents container, and the credentials will not be revoked. - Note that this is currently unsupported on Windows-based pods, and will only work when injecting into Linux-based pods. + This is currently unsupported on Windows-based pods, and will only work when injecting into Linux-based pods. + + It's recommended to use the annotation `org.infisical.com/agent-revoke-on-shutdown: "true"` instead of configuring the revoke on shutdown on the config map. Refer to the [Supported annotations](/integrations/platforms/kubernetes-injector#supported-annotations) documentation for more information on how to configure the revoke on shutdown through annotations. @@ -162,8 +230,59 @@ The entire config needs to be of string format and needs to be assigned to the ` Please note that the pod's default service account will be used to authenticate with Infisical. + - The ID of the machine identity to use to connect to Infisical. This field is required if the `infisical.auth.type` is set to `kubernetes`. + The ID of the machine identity to use for Kubernetes or LDAP authentication. This field is required if the `infisical.auth.type` is set to `kubernetes`. + + + + The LDAP username to use for LDAP authentication. + This field is required if the `infisical.auth.type` is set to `ldap-auth`. + + + + The LDAP password to use for LDAP authentication. + This field is required if the `infisical.auth.type` is set to `ldap-auth`. + + + + How many times to retry failed API requests such as authentication, secret retrieval, etc. Defaults to `3` retries. Refer to the [Retrying mechanism](/integrations/platforms/infisical-agent#retrying-mechanism) documentation for more information on how to configure the retry strategy. + + + You can also configure the max retries through annotations. Refer to the [Supported annotations](/integrations/platforms/kubernetes-injector#supported-annotations) documentation for more information on how to configure the max retries through annotations. + + + + + The maximum delay between retries. Defaults to `5s` (5 seconds). Refer to the [Retrying mechanism](/integrations/platforms/infisical-agent#retrying-mechanism) documentation for more information on how to configure the retry strategy. + + + You can also configure the max delay through annotations. Refer to the [Supported annotations](/integrations/platforms/kubernetes-injector#supported-annotations) documentation for more information on how to configure the max delay through annotations. + + + + + The base delay between retries. Defaults to `200ms` (200 milliseconds). Refer to the [Retrying mechanism](/integrations/platforms/infisical-agent#retrying-mechanism) documentation for more information on how to configure the retry strategy. + + + You can also configure the base delay through annotations. Refer to the [Supported annotations](/integrations/platforms/kubernetes-injector#supported-annotations) documentation for more information on how to configure the base delay through annotations. + + + + + The type of persistent caching to use. Currently only `kubernetes` is available, and will only work within Kubernetes environments. + + + It is recommended to use the annotation `org.infisical.com/agent-cache-enabled: "true"` instead of configuring the cache on the config map. Refer to the [Supported annotations](/integrations/platforms/kubernetes-injector#supported-annotations) documentation for more information on how to configure the cache through annotations. + + + + + The path to the Kubernetes service account token to use for encrypting the persistent cache. Required when using `kubernetes` cache type. Defaults to `/var/run/secrets/kubernetes.io/serviceaccount/token`. + + + It is recommended to use the annotation `org.infisical.com/agent-cache-enabled: "true"` instead of configuring the cache on the config map. Refer to the [Supported annotations](/integrations/platforms/kubernetes-injector#supported-annotations) documentation for more information on how to configure the cache through annotations. + @@ -180,6 +299,7 @@ The templates hold an array of templates that will be rendered and injected into This will be rendered as a [Go Template](https://pkg.go.dev/text/template) and will have access to the following variables. It follows the templating format and supports the same functions as the [Infisical Agent](/integrations/platforms/infisical-agent#quick-start-infisical-agent) + ### Authentication @@ -271,7 +391,7 @@ The Infisical Agent Injector supports Machine Identity [Kubernetes Auth](/docume -To use the config map in your pod, you will need to add the `org.infisical.com/agent-config-map` annotation to your pod's deployment. The value of the annotation is the name of the config map you created above. +To use the config map in your pod, you will need to add the `org.infisical.com/agent-config-map` annotation to your pod's deployment. The value of the annotation is the name of the config map you created above. The config map must be in the same namespace as the pod you're injecting into. ```yaml apiVersion: v1 kind: Pod diff --git a/docs/snippets/AppConnectionsBrowser.jsx b/docs/snippets/AppConnectionsBrowser.jsx index dfe574547..d7001d6eb 100644 --- a/docs/snippets/AppConnectionsBrowser.jsx +++ b/docs/snippets/AppConnectionsBrowser.jsx @@ -1,70 +1,388 @@ -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo } from "react"; export const AppConnectionsBrowser = () => { - const [searchTerm, setSearchTerm] = useState(''); - const [selectedCategory, setSelectedCategory] = useState('All'); + const [searchTerm, setSearchTerm] = useState(""); + const [selectedCategory, setSelectedCategory] = useState("All"); - const categories = ['All', 'Cloud Providers', 'Databases', 'CI/CD', 'Monitoring', 'Directory Services', 'Identity & Auth', 'Data Analytics', 'Hosting', 'DevOps Tools', 'Security']; + const categories = [ + "All", + "Cloud Providers", + "Databases", + "CI/CD", + "Monitoring", + "Directory Services", + "Identity & Auth", + "Data Analytics", + "Hosting", + "DevOps Tools", + "Security", + "Networking & DNS", + ]; const connections = [ - {"name": "AWS", "slug": "aws", "path": "/integrations/app-connections/aws", "description": "Learn how to connect your AWS applications to pull secrets from Infisical.", "category": "Cloud Providers"}, - {"name": "Azure Key Vault", "slug": "azure-key-vault", "path": "/integrations/app-connections/azure-key-vault", "description": "Learn how to connect your Azure Key Vault to pull secrets from Infisical.", "category": "Cloud Providers"}, - {"name": "Azure App Configuration", "slug": "azure-app-configuration", "path": "/integrations/app-connections/azure-app-configuration", "description": "Learn how to connect your Azure App Configuration to pull secrets from Infisical.", "category": "Cloud Providers"}, - {"name": "Azure Client Secrets", "slug": "azure-client-secrets", "path": "/integrations/app-connections/azure-client-secrets", "description": "Learn how to connect your Azure Client Secrets to pull secrets from Infisical.", "category": "Cloud Providers"}, - {"name": "Azure DevOps", "slug": "azure-devops", "path": "/integrations/app-connections/azure-devops", "description": "Learn how to connect your Azure DevOps to pull secrets from Infisical.", "category": "CI/CD"}, - {"name": "Azure ADCS", "slug": "azure-adcs", "path": "/integrations/app-connections/azure-adcs", "description": "Learn how to connect your Azure ADCS to pull secrets from Infisical.", "category": "Cloud Providers"}, - {"name": "GCP", "slug": "gcp", "path": "/integrations/app-connections/gcp", "description": "Learn how to connect your GCP applications to pull secrets from Infisical.", "category": "Cloud Providers"}, - {"name": "HashiCorp Vault", "slug": "hashicorp-vault", "path": "/integrations/app-connections/hashicorp-vault", "description": "Learn how to connect your HashiCorp Vault to pull secrets from Infisical.", "category": "Security"}, - {"name": "1Password", "slug": "1password", "path": "/integrations/app-connections/1password", "description": "Learn how to connect your 1Password to pull secrets from Infisical.", "category": "Security"}, - {"name": "Vercel", "slug": "vercel", "path": "/integrations/app-connections/vercel", "description": "Learn how to connect your Vercel application to pull secrets from Infisical.", "category": "Hosting"}, - {"name": "Netlify", "slug": "netlify", "path": "/integrations/app-connections/netlify", "description": "Learn how to connect your Netlify application to pull secrets from Infisical.", "category": "Hosting"}, - {"name": "Railway", "slug": "railway", "path": "/integrations/app-connections/railway", "description": "Learn how to connect your Railway application to pull secrets from Infisical.", "category": "Hosting"}, - {"name": "Fly.io", "slug": "flyio", "path": "/integrations/app-connections/flyio", "description": "Learn how to connect your Fly.io application to pull secrets from Infisical.", "category": "Hosting"}, - {"name": "Render", "slug": "render", "path": "/integrations/app-connections/render", "description": "Learn how to connect your Render application to pull secrets from Infisical.", "category": "Hosting"}, - {"name": "Heroku", "slug": "heroku", "path": "/integrations/app-connections/heroku", "description": "Learn how to connect your Heroku application to pull secrets from Infisical.", "category": "Hosting"}, - {"name": "DigitalOcean", "slug": "digital-ocean", "path": "/integrations/app-connections/digital-ocean", "description": "Learn how to connect your DigitalOcean application to pull secrets from Infisical.", "category": "Hosting"}, - {"name": "Supabase", "slug": "supabase", "path": "/integrations/app-connections/supabase", "description": "Learn how to connect your Supabase application to pull secrets from Infisical.", "category": "Databases"}, - {"name": "Checkly", "slug": "checkly", "path": "/integrations/app-connections/checkly", "description": "Learn how to connect your Checkly application to pull secrets from Infisical.", "category": "Monitoring"}, - {"name": "GitHub", "slug": "github", "path": "/integrations/app-connections/github", "description": "Learn how to connect your GitHub application to pull secrets from Infisical.", "category": "CI/CD"}, - {"name": "GitHub Radar", "slug": "github-radar", "path": "/integrations/app-connections/github-radar", "description": "Learn how to connect your GitHub Radar to pull secrets from Infisical.", "category": "CI/CD"}, - {"name": "GitLab", "slug": "gitlab", "path": "/integrations/app-connections/gitlab", "description": "Learn how to connect your GitLab application to pull secrets from Infisical.", "category": "CI/CD"}, - {"name": "TeamCity", "slug": "teamcity", "path": "/integrations/app-connections/teamcity", "description": "Learn how to connect your TeamCity to pull secrets from Infisical.", "category": "CI/CD"}, - {"name": "Bitbucket", "slug": "bitbucket", "path": "/integrations/app-connections/bitbucket", "description": "Learn how to connect your Bitbucket to pull secrets from Infisical.", "category": "CI/CD"}, - {"name": "Terraform Cloud", "slug": "terraform-cloud", "path": "/integrations/app-connections/terraform-cloud", "description": "Learn how to connect your Terraform Cloud to pull secrets from Infisical.", "category": "DevOps Tools"}, - {"name": "Cloudflare", "slug": "cloudflare", "path": "/integrations/app-connections/cloudflare", "description": "Learn how to connect your Cloudflare application to pull secrets from Infisical.", "category": "Cloud Providers"}, - {"name": "Databricks", "slug": "databricks", "path": "/integrations/app-connections/databricks", "description": "Learn how to connect your Databricks to pull secrets from Infisical.", "category": "Data Analytics"}, - {"name": "Windmill", "slug": "windmill", "path": "/integrations/app-connections/windmill", "description": "Learn how to connect your Windmill to pull secrets from Infisical.", "category": "DevOps Tools"}, - {"name": "Camunda", "slug": "camunda", "path": "/integrations/app-connections/camunda", "description": "Learn how to connect your Camunda to pull secrets from Infisical.", "category": "DevOps Tools"}, - {"name": "Humanitec", "slug": "humanitec", "path": "/integrations/app-connections/humanitec", "description": "Learn how to connect your Humanitec to pull secrets from Infisical.", "category": "DevOps Tools"}, - {"name": "OCI", "slug": "oci", "path": "/integrations/app-connections/oci", "description": "Learn how to connect your OCI applications to pull secrets from Infisical.", "category": "Cloud Providers"}, - {"name": "Zabbix", "slug": "zabbix", "path": "/integrations/app-connections/zabbix", "description": "Learn how to connect your Zabbix to pull secrets from Infisical.", "category": "Monitoring"}, - {"name": "MySQL", "slug": "mysql", "path": "/integrations/app-connections/mysql", "description": "Learn how to connect your MySQL database to pull secrets from Infisical.", "category": "Databases"}, - {"name": "PostgreSQL", "slug": "postgres", "path": "/integrations/app-connections/postgres", "description": "Learn how to connect your PostgreSQL database to pull secrets from Infisical.", "category": "Databases"}, - {"name": "Microsoft SQL Server", "slug": "mssql", "path": "/integrations/app-connections/mssql", "description": "Learn how to connect your SQL Server database to pull secrets from Infisical.", "category": "Databases"}, - {"name": "Oracle Database", "slug": "oracledb", "path": "/integrations/app-connections/oracledb", "description": "Learn how to connect your Oracle database to pull secrets from Infisical.", "category": "Databases"}, - {"name": "Redis", "slug": "redis", "path": "/integrations/app-connections/redis", "description": "Learn how to connect Redis to pull secrets from Infisical.", "category": "Databases"}, - {"name": "LDAP", "slug": "ldap", "path": "/integrations/app-connections/ldap", "description": "Learn how to connect your LDAP to pull secrets from Infisical.", "category": "Directory Services"}, - {"name": "Auth0", "slug": "auth0", "path": "/integrations/app-connections/auth0", "description": "Learn how to connect your Auth0 to pull secrets from Infisical.", "category": "Identity & Auth"}, - {"name": "Okta", "slug": "okta", "path": "/integrations/app-connections/okta", "description": "Learn how to connect your Okta to pull secrets from Infisical.", "category": "Identity & Auth"}, - {"name": "Laravel Forge", "slug": "laravel-forge", "path": "/integrations/app-connections/laravel-forge", "description": "Learn how to connect your Laravel Forge to pull secrets from Infisical.", "category": "Hosting"}, - {"name": "Chef", "slug": "chef", "path": "/integrations/app-connections/chef", "description": "Learn how to connect your Chef to pull secrets from Infisical.", "category": "DevOps Tools"}, - {"name": "Northflank", "slug": "northflank", "path": "/integrations/app-connections/northflank", "description": "Learn how to connect your Northflank projects to pull secrets from Infisical.", "category": "Hosting"} - ].sort(function(a, b) { - return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + { + name: "AWS", + slug: "aws", + path: "/integrations/app-connections/aws", + description: + "Learn how to connect your AWS applications to pull secrets from Infisical.", + category: "Cloud Providers", + }, + { + name: "Azure Key Vault", + slug: "azure-key-vault", + path: "/integrations/app-connections/azure-key-vault", + description: + "Learn how to connect your Azure Key Vault to pull secrets from Infisical.", + category: "Cloud Providers", + }, + { + name: "Azure App Configuration", + slug: "azure-app-configuration", + path: "/integrations/app-connections/azure-app-configuration", + description: + "Learn how to connect your Azure App Configuration to pull secrets from Infisical.", + category: "Cloud Providers", + }, + { + name: "Azure Client Secrets", + slug: "azure-client-secrets", + path: "/integrations/app-connections/azure-client-secrets", + description: + "Learn how to connect your Azure Client Secrets to pull secrets from Infisical.", + category: "Cloud Providers", + }, + { + name: "Azure DevOps", + slug: "azure-devops", + path: "/integrations/app-connections/azure-devops", + description: + "Learn how to connect your Azure DevOps to pull secrets from Infisical.", + category: "CI/CD", + }, + { + name: "Azure ADCS", + slug: "azure-adcs", + path: "/integrations/app-connections/azure-adcs", + description: + "Learn how to connect your Azure ADCS to pull secrets from Infisical.", + category: "Cloud Providers", + }, + { + name: "GCP", + slug: "gcp", + path: "/integrations/app-connections/gcp", + description: + "Learn how to connect your GCP applications to pull secrets from Infisical.", + category: "Cloud Providers", + }, + { + name: "HashiCorp Vault", + slug: "hashicorp-vault", + path: "/integrations/app-connections/hashicorp-vault", + description: + "Learn how to connect your HashiCorp Vault to pull secrets from Infisical.", + category: "Security", + }, + { + name: "1Password", + slug: "1password", + path: "/integrations/app-connections/1password", + description: + "Learn how to connect your 1Password to pull secrets from Infisical.", + category: "Security", + }, + { + name: "Vercel", + slug: "vercel", + path: "/integrations/app-connections/vercel", + description: + "Learn how to connect your Vercel application to pull secrets from Infisical.", + category: "Hosting", + }, + { + name: "Netlify", + slug: "netlify", + path: "/integrations/app-connections/netlify", + description: + "Learn how to connect your Netlify application to pull secrets from Infisical.", + category: "Hosting", + }, + { + name: "Railway", + slug: "railway", + path: "/integrations/app-connections/railway", + description: + "Learn how to connect your Railway application to pull secrets from Infisical.", + category: "Hosting", + }, + { + name: "Fly.io", + slug: "flyio", + path: "/integrations/app-connections/flyio", + description: + "Learn how to connect your Fly.io application to pull secrets from Infisical.", + category: "Hosting", + }, + { + name: "Render", + slug: "render", + path: "/integrations/app-connections/render", + description: + "Learn how to connect your Render application to pull secrets from Infisical.", + category: "Hosting", + }, + { + name: "Heroku", + slug: "heroku", + path: "/integrations/app-connections/heroku", + description: + "Learn how to connect your Heroku application to pull secrets from Infisical.", + category: "Hosting", + }, + { + name: "DigitalOcean", + slug: "digital-ocean", + path: "/integrations/app-connections/digital-ocean", + description: + "Learn how to connect your DigitalOcean application to pull secrets from Infisical.", + category: "Hosting", + }, + { + name: "Supabase", + slug: "supabase", + path: "/integrations/app-connections/supabase", + description: + "Learn how to connect your Supabase application to pull secrets from Infisical.", + category: "Databases", + }, + { + name: "Checkly", + slug: "checkly", + path: "/integrations/app-connections/checkly", + description: + "Learn how to connect your Checkly application to pull secrets from Infisical.", + category: "Monitoring", + }, + { + name: "GitHub", + slug: "github", + path: "/integrations/app-connections/github", + description: + "Learn how to connect your GitHub application to pull secrets from Infisical.", + category: "CI/CD", + }, + { + name: "GitHub Radar", + slug: "github-radar", + path: "/integrations/app-connections/github-radar", + description: + "Learn how to connect your GitHub Radar to pull secrets from Infisical.", + category: "CI/CD", + }, + { + name: "GitLab", + slug: "gitlab", + path: "/integrations/app-connections/gitlab", + description: + "Learn how to connect your GitLab application to pull secrets from Infisical.", + category: "CI/CD", + }, + { + name: "TeamCity", + slug: "teamcity", + path: "/integrations/app-connections/teamcity", + description: + "Learn how to connect your TeamCity to pull secrets from Infisical.", + category: "CI/CD", + }, + { + name: "Bitbucket", + slug: "bitbucket", + path: "/integrations/app-connections/bitbucket", + description: + "Learn how to connect your Bitbucket to pull secrets from Infisical.", + category: "CI/CD", + }, + { + name: "Terraform Cloud", + slug: "terraform-cloud", + path: "/integrations/app-connections/terraform-cloud", + description: + "Learn how to connect your Terraform Cloud to pull secrets from Infisical.", + category: "DevOps Tools", + }, + { + name: "Cloudflare", + slug: "cloudflare", + path: "/integrations/app-connections/cloudflare", + description: + "Learn how to connect your Cloudflare application to pull secrets from Infisical.", + category: "Cloud Providers", + }, + { + name: "Databricks", + slug: "databricks", + path: "/integrations/app-connections/databricks", + description: + "Learn how to connect your Databricks to pull secrets from Infisical.", + category: "Data Analytics", + }, + { + name: "DNS Made Easy", + slug: "dns-made-easy", + path: "/integrations/app-connections/dns-made-easy", + description: "Learn how to connect Infisical to DNS Made Easy.", + category: "Networking & DNS", + }, + { + name: "Windmill", + slug: "windmill", + path: "/integrations/app-connections/windmill", + description: + "Learn how to connect your Windmill to pull secrets from Infisical.", + category: "DevOps Tools", + }, + { + name: "Camunda", + slug: "camunda", + path: "/integrations/app-connections/camunda", + description: + "Learn how to connect your Camunda to pull secrets from Infisical.", + category: "DevOps Tools", + }, + { + name: "Humanitec", + slug: "humanitec", + path: "/integrations/app-connections/humanitec", + description: + "Learn how to connect your Humanitec to pull secrets from Infisical.", + category: "DevOps Tools", + }, + { + name: "OCI", + slug: "oci", + path: "/integrations/app-connections/oci", + description: + "Learn how to connect your OCI applications to pull secrets from Infisical.", + category: "Cloud Providers", + }, + { + name: "Zabbix", + slug: "zabbix", + path: "/integrations/app-connections/zabbix", + description: + "Learn how to connect your Zabbix to pull secrets from Infisical.", + category: "Monitoring", + }, + { + name: "MySQL", + slug: "mysql", + path: "/integrations/app-connections/mysql", + description: + "Learn how to connect your MySQL database to pull secrets from Infisical.", + category: "Databases", + }, + { + name: "PostgreSQL", + slug: "postgres", + path: "/integrations/app-connections/postgres", + description: + "Learn how to connect your PostgreSQL database to pull secrets from Infisical.", + category: "Databases", + }, + { + name: "Microsoft SQL Server", + slug: "mssql", + path: "/integrations/app-connections/mssql", + description: + "Learn how to connect your SQL Server database to pull secrets from Infisical.", + category: "Databases", + }, + { + name: "Oracle Database", + slug: "oracledb", + path: "/integrations/app-connections/oracledb", + description: + "Learn how to connect your Oracle database to pull secrets from Infisical.", + category: "Databases", + }, + { + name: "Redis", + slug: "redis", + path: "/integrations/app-connections/redis", + description: "Learn how to connect Redis to pull secrets from Infisical.", + category: "Databases", + }, + { + name: "LDAP", + slug: "ldap", + path: "/integrations/app-connections/ldap", + description: + "Learn how to connect your LDAP to pull secrets from Infisical.", + category: "Directory Services", + }, + { + name: "Auth0", + slug: "auth0", + path: "/integrations/app-connections/auth0", + description: + "Learn how to connect your Auth0 to pull secrets from Infisical.", + category: "Identity & Auth", + }, + { + name: "Okta", + slug: "okta", + path: "/integrations/app-connections/okta", + description: + "Learn how to connect your Okta to pull secrets from Infisical.", + category: "Identity & Auth", + }, + { + name: "Laravel Forge", + slug: "laravel-forge", + path: "/integrations/app-connections/laravel-forge", + description: + "Learn how to connect your Laravel Forge to pull secrets from Infisical.", + category: "Hosting", + }, + { + name: "Chef", + slug: "chef", + path: "/integrations/app-connections/chef", + description: + "Learn how to connect your Chef to pull secrets from Infisical.", + category: "DevOps Tools", + }, + { + name: "Northflank", + slug: "northflank", + path: "/integrations/app-connections/northflank", + description: + "Learn how to connect your Northflank projects to pull secrets from Infisical.", + category: "Hosting", + }, + ].sort(function (a, b) { + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); }); const filteredConnections = useMemo(() => { let filtered = connections; - - if (selectedCategory !== 'All') { - filtered = filtered.filter(connection => connection.category === selectedCategory); + + if (selectedCategory !== "All") { + filtered = filtered.filter( + (connection) => connection.category === selectedCategory + ); } if (searchTerm) { - filtered = filtered.filter(connection => - connection.name.toLowerCase().includes(searchTerm.toLowerCase()) || - connection.description.toLowerCase().includes(searchTerm.toLowerCase()) || - connection.category.toLowerCase().includes(searchTerm.toLowerCase()) + filtered = filtered.filter( + (connection) => + connection.name.toLowerCase().includes(searchTerm.toLowerCase()) || + connection.description + .toLowerCase() + .includes(searchTerm.toLowerCase()) || + connection.category.toLowerCase().includes(searchTerm.toLowerCase()) ); } @@ -77,8 +395,18 @@ export const AppConnectionsBrowser = () => {
- - + +
{ {/* Category Filter */}
- {categories.map(category => ( + {categories.map((category) => (
); -}; \ No newline at end of file +}; diff --git a/frontend/public/images/integrations/DNSMadeEasy.svg b/frontend/public/images/integrations/DNSMadeEasy.svg new file mode 100644 index 000000000..be77b9840 --- /dev/null +++ b/frontend/public/images/integrations/DNSMadeEasy.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/locales/en/translations.json b/frontend/public/locales/en/translations.json index fa23b9630..a290a28a3 100644 --- a/frontend/public/locales/en/translations.json +++ b/frontend/public/locales/en/translations.json @@ -289,7 +289,7 @@ } }, "project": { - "title": "Settings", + "title": "Project Settings", "description": "These settings only apply to the currently selected Project.", "danger-zone": "Danger Zone", "delete-project": "Delete Project", diff --git a/frontend/src/components/permissions/AccessTree/utils/createFolderNode.ts b/frontend/src/components/permissions/AccessTree/utils/createFolderNode.ts index a40398ba7..b05ea1c52 100644 --- a/frontend/src/components/permissions/AccessTree/utils/createFolderNode.ts +++ b/frontend/src/components/permissions/AccessTree/utils/createFolderNode.ts @@ -87,17 +87,24 @@ const shouldShowConditionalAccess = ( folderPath: string, conditionalFields: string[] ): boolean => { - return actionRuleMap.some((rule) => { + // Find all rules that apply to this environment/path + const applicableRules = actionRuleMap.filter((rule) => { const ruleConditions = rule[action]?.conditions; if (!ruleConditions) return false; - - // Check if any of the conditional fields are present - const hasConditionalField = conditionalFields.some((field) => ruleConditions[field]); - if (!hasConditionalField) return false; - - // Check if base conditions (environment and secretPath) apply return doBaseConditionsApply(ruleConditions, environment, folderPath); }); + + // If no rules apply, don't show conditional + if (applicableRules.length === 0) return false; + + // Check if ALL applicable rules have conditional fields and if at least one rule applies without conditional fields, show full access + const allRulesHaveConditionalFields = applicableRules.every((rule) => { + const ruleConditions = rule[action]?.conditions; + if (!ruleConditions) return false; + return conditionalFields.some((field) => ruleConditions[field]); + }); + + return allRulesHaveConditionalFields; }; const determineAccessLevel = ( diff --git a/frontend/src/components/v2/PageHeader/PageHeader.tsx b/frontend/src/components/v2/PageHeader/PageHeader.tsx index f84edfcd2..5a2ba2e12 100644 --- a/frontend/src/components/v2/PageHeader/PageHeader.tsx +++ b/frontend/src/components/v2/PageHeader/PageHeader.tsx @@ -3,14 +3,7 @@ import { ReactNode } from "@tanstack/react-router"; import { LucideIcon } from "lucide-react"; import { twMerge } from "tailwind-merge"; -import { - Badge, - InstanceIcon, - OrgIcon, - ProjectIcon, - SubOrgIcon, - TBadgeProps -} from "@app/components/v3"; +import { InstanceIcon, OrgIcon, ProjectIcon, SubOrgIcon } from "@app/components/v3"; import { ProjectType } from "@app/hooks/api/projects/types"; type Props = { @@ -21,41 +14,40 @@ type Props = { scope: "org" | "namespace" | "instance" | ProjectType | null; }; -const SCOPE_NAME: Record, { label: string; icon: LucideIcon }> = { - org: { label: "Organization", icon: OrgIcon }, - [ProjectType.SecretManager]: { label: "Project", icon: ProjectIcon }, - [ProjectType.CertificateManager]: { label: "Project", icon: ProjectIcon }, - [ProjectType.SSH]: { label: "Project", icon: ProjectIcon }, - [ProjectType.KMS]: { label: "Project", icon: ProjectIcon }, - [ProjectType.PAM]: { label: "Project", icon: ProjectIcon }, - [ProjectType.SecretScanning]: { label: "Project", icon: ProjectIcon }, - namespace: { label: "Sub-Organization", icon: SubOrgIcon }, - instance: { label: "Server", icon: InstanceIcon } -}; - -const SCOPE_VARIANT: Record, TBadgeProps["variant"]> = { - org: "org", - [ProjectType.SecretManager]: "project", - [ProjectType.CertificateManager]: "project", - [ProjectType.SSH]: "project", - [ProjectType.KMS]: "project", - [ProjectType.PAM]: "project", - [ProjectType.SecretScanning]: "project", - namespace: "sub-org", - instance: "neutral" +const SCOPE_BADGE: Record, { icon: LucideIcon; className: string }> = { + org: { className: "text-org", icon: OrgIcon }, + [ProjectType.SecretManager]: { className: "text-project", icon: ProjectIcon }, + [ProjectType.CertificateManager]: { className: "text-project", icon: ProjectIcon }, + [ProjectType.SSH]: { className: "text-project", icon: ProjectIcon }, + [ProjectType.KMS]: { className: "text-project", icon: ProjectIcon }, + [ProjectType.PAM]: { className: "text-project", icon: ProjectIcon }, + [ProjectType.SecretScanning]: { className: "text-project", icon: ProjectIcon }, + namespace: { className: "text-sub-org", icon: SubOrgIcon }, + instance: { className: "text-neutral", icon: InstanceIcon } }; export const PageHeader = ({ title, description, children, className, scope }: Props) => (
-

{title}

- {scope && ( - - {createElement(SCOPE_NAME[scope].icon)} - {SCOPE_NAME[scope].label} - - )} +

+ {scope && + createElement(SCOPE_BADGE[scope].icon, { + size: 26, + className: twMerge(SCOPE_BADGE[scope].className, "mr-3 mb-1 inline-block") + })} + {title} +

{children}
diff --git a/frontend/src/components/v2/Tabs/Tabs.tsx b/frontend/src/components/v2/Tabs/Tabs.tsx index ebe8eb2ed..47b3d08cd 100644 --- a/frontend/src/components/v2/Tabs/Tabs.tsx +++ b/frontend/src/components/v2/Tabs/Tabs.tsx @@ -47,8 +47,8 @@ export const Tab = ({ }) => ( + [...dnsMadeEasyConnectionKeys.all, "zones", connectionId] as const +}; + +export const useDNSMadeEasyConnectionListZones = ( + connectionId: string, + options?: Omit< + UseQueryOptions< + TDNSMadeEasyZone[], + unknown, + TDNSMadeEasyZone[], + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: dnsMadeEasyConnectionKeys.listZones(connectionId), + queryFn: async () => { + const { data } = await apiRequest.get( + `/api/v1/app-connections/dns-made-easy/${connectionId}/dns-made-easy-zones` + ); + + return data; + }, + ...options + }); +}; diff --git a/frontend/src/hooks/api/appConnections/dns-made-easy/types.ts b/frontend/src/hooks/api/appConnections/dns-made-easy/types.ts new file mode 100644 index 000000000..dcf66c1dd --- /dev/null +++ b/frontend/src/hooks/api/appConnections/dns-made-easy/types.ts @@ -0,0 +1,4 @@ +export type TDNSMadeEasyZone = { + id: string; + name: string; +}; diff --git a/frontend/src/hooks/api/appConnections/enums.ts b/frontend/src/hooks/api/appConnections/enums.ts index fba0cbb4b..9535e8348 100644 --- a/frontend/src/hooks/api/appConnections/enums.ts +++ b/frontend/src/hooks/api/appConnections/enums.ts @@ -29,6 +29,7 @@ export enum AppConnection { Flyio = "flyio", GitLab = "gitlab", Cloudflare = "cloudflare", + DNSMadeEasy = "dns-made-easy", Bitbucket = "bitbucket", Zabbix = "zabbix", Railway = "railway", diff --git a/frontend/src/hooks/api/appConnections/types/app-options.ts b/frontend/src/hooks/api/appConnections/types/app-options.ts index 1f553f605..d1c991f34 100644 --- a/frontend/src/hooks/api/appConnections/types/app-options.ts +++ b/frontend/src/hooks/api/appConnections/types/app-options.ts @@ -184,6 +184,10 @@ export type TRedisConnectionOption = TAppConnectionOptionBase & { app: AppConnection.Redis; }; +export type TDNSMadeEasyConnectionOption = TAppConnectionOptionBase & { + app: AppConnection.DNSMadeEasy; +}; + export type TAppConnectionOption = | TAwsConnectionOption | TGitHubConnectionOption @@ -225,7 +229,8 @@ export type TAppConnectionOption = | TOktaConnectionOption | TAzureAdCsConnectionOption | TLaravelForgeConnectionOption - | TChefConnectionOption; + | TChefConnectionOption + | TDNSMadeEasyConnectionOption; export type TAppConnectionOptionMap = { [AppConnection.AWS]: TAwsConnectionOption; @@ -257,6 +262,7 @@ export type TAppConnectionOptionMap = { [AppConnection.Flyio]: TFlyioConnectionOption; [AppConnection.GitLab]: TGitlabConnectionOption; [AppConnection.Cloudflare]: TCloudflareConnectionOption; + [AppConnection.DNSMadeEasy]: TDNSMadeEasyConnectionOption; [AppConnection.Bitbucket]: TBitbucketConnectionOption; [AppConnection.Zabbix]: TZabbixConnectionOption; [AppConnection.Railway]: TRailwayConnectionOption; diff --git a/frontend/src/hooks/api/appConnections/types/dns-made-easy-connection.ts b/frontend/src/hooks/api/appConnections/types/dns-made-easy-connection.ts new file mode 100644 index 000000000..fd4dc098b --- /dev/null +++ b/frontend/src/hooks/api/appConnections/types/dns-made-easy-connection.ts @@ -0,0 +1,14 @@ +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { TRootAppConnection } from "@app/hooks/api/appConnections/types/root-connection"; + +export enum DNSMadeEasyConnectionMethod { + APIKeySecret = "api-key-secret" +} + +export type TDNSMadeEasyConnection = TRootAppConnection & { app: AppConnection.DNSMadeEasy } & { + method: DNSMadeEasyConnectionMethod.APIKeySecret; + credentials: { + apiKey: string; + secretKey: string; + }; +}; diff --git a/frontend/src/hooks/api/appConnections/types/index.ts b/frontend/src/hooks/api/appConnections/types/index.ts index 3e62031b3..a272b48cd 100644 --- a/frontend/src/hooks/api/appConnections/types/index.ts +++ b/frontend/src/hooks/api/appConnections/types/index.ts @@ -15,6 +15,7 @@ import { TChefConnection } from "./chef-connection"; import { TCloudflareConnection } from "./cloudflare-connection"; import { TDatabricksConnection } from "./databricks-connection"; import { TDigitalOceanConnection } from "./digital-ocean"; +import { TDNSMadeEasyConnection } from "./dns-made-easy-connection"; import { TFlyioConnection } from "./flyio-connection"; import { TGcpConnection } from "./gcp-connection"; import { TGitHubConnection } from "./github-connection"; @@ -57,6 +58,7 @@ export * from "./checkly-connection"; export * from "./chef-connection"; export * from "./cloudflare-connection"; export * from "./databricks-connection"; +export * from "./dns-made-easy-connection"; export * from "./flyio-connection"; export * from "./gcp-connection"; export * from "./github-connection"; @@ -127,7 +129,8 @@ export type TAppConnection = | TNorthflankConnection | TOktaConnection | TRedisConnection - | TChefConnection; + | TChefConnection + | TDNSMadeEasyConnection; export type TAvailableAppConnection = Pick; diff --git a/frontend/src/hooks/api/ca/constants.tsx b/frontend/src/hooks/api/ca/constants.tsx index 16a350dbd..740d52994 100644 --- a/frontend/src/hooks/api/ca/constants.tsx +++ b/frontend/src/hooks/api/ca/constants.tsx @@ -16,12 +16,14 @@ export const caStatusToNameMap: { [K in CaStatus]: string } = { export const ACME_DNS_PROVIDER_NAME_MAP: Record = { [AcmeDnsProvider.ROUTE53]: "Route53", - [AcmeDnsProvider.Cloudflare]: "Cloudflare" + [AcmeDnsProvider.Cloudflare]: "Cloudflare", + [AcmeDnsProvider.DNSMadeEasy]: "DNS Made Easy" }; export const ACME_DNS_PROVIDER_APP_CONNECTION_MAP: Record = { [AcmeDnsProvider.ROUTE53]: AppConnection.AWS, - [AcmeDnsProvider.Cloudflare]: AppConnection.Cloudflare + [AcmeDnsProvider.Cloudflare]: AppConnection.Cloudflare, + [AcmeDnsProvider.DNSMadeEasy]: AppConnection.DNSMadeEasy }; export const CA_TYPE_CAPABILITIES_MAP: Record = { diff --git a/frontend/src/hooks/api/ca/enums.tsx b/frontend/src/hooks/api/ca/enums.tsx index a68f3e862..acd8ce069 100644 --- a/frontend/src/hooks/api/ca/enums.tsx +++ b/frontend/src/hooks/api/ca/enums.tsx @@ -21,7 +21,8 @@ export enum CaRenewalType { export enum AcmeDnsProvider { ROUTE53 = "route53", - Cloudflare = "cloudflare" + Cloudflare = "cloudflare", + DNSMadeEasy = "dns-made-easy" } export enum CaCapability { diff --git a/frontend/src/hooks/api/certificateProfiles/index.ts b/frontend/src/hooks/api/certificateProfiles/index.ts index e12e066c4..27e73c15a 100644 --- a/frontend/src/hooks/api/certificateProfiles/index.ts +++ b/frontend/src/hooks/api/certificateProfiles/index.ts @@ -10,4 +10,4 @@ export { useGetProfileCertificates, useListCertificateProfiles } from "./queries"; -export type * from "./types"; +export * from "./types"; diff --git a/frontend/src/hooks/api/certificateProfiles/types.ts b/frontend/src/hooks/api/certificateProfiles/types.ts index c2b38e8e8..176a5dd9e 100644 --- a/frontend/src/hooks/api/certificateProfiles/types.ts +++ b/frontend/src/hooks/api/certificateProfiles/types.ts @@ -1,11 +1,23 @@ +export enum EnrollmentType { + API = "api", + EST = "est", + ACME = "acme" +} + +export enum IssuerType { + CA = "ca", + SELF_SIGNED = "self-signed" +} + export type TCertificateProfile = { id: string; projectId: string; - caId: string; + caId: string | null; certificateTemplateId: string; slug: string; description?: string; - enrollmentType: "api" | "est" | "acme"; + enrollmentType: EnrollmentType; + issuerType: IssuerType; estConfigId?: string; apiConfigId?: string; createdAt: string; @@ -44,11 +56,12 @@ export type TCertificateProfileWithDetails = TCertificateProfile & { export type TCreateCertificateProfileDTO = { projectId: string; - caId: string; + caId?: string; certificateTemplateId: string; slug: string; description?: string; - enrollmentType: "api" | "est" | "acme"; + enrollmentType: EnrollmentType; + issuerType: IssuerType; estConfig?: { disableBootstrapCaValidation?: boolean; passphrase: string; @@ -65,6 +78,8 @@ export type TUpdateCertificateProfileDTO = { profileId: string; slug?: string; description?: string; + enrollmentType?: EnrollmentType; + issuerType?: IssuerType; estConfig?: { disableBootstrapCaValidation?: boolean; passphrase?: string; @@ -87,7 +102,9 @@ export type TListCertificateProfilesDTO = { offset?: number; search?: string; includeConfigs?: boolean; - enrollmentType?: "api" | "est" | "acme"; + enrollmentType?: EnrollmentType; + issuerType?: IssuerType; + caId?: string; }; export type TGetCertificateProfileByIdDTO = { diff --git a/frontend/src/index.css b/frontend/src/index.css index fdedc2f68..baa613b36 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,9 +1,5 @@ @import "tailwindcss"; -@import "@fontsource/inter/400.css" layer(base); -@import "@fontsource/inter/500.css" layer(base); -@import "@fontsource/inter/700.css" layer(base); - @source not "../public"; /* diff --git a/frontend/src/layouts/KmsLayout/KmsLayout.tsx b/frontend/src/layouts/KmsLayout/KmsLayout.tsx index 9a5dc5aee..8ffe0da44 100644 --- a/frontend/src/layouts/KmsLayout/KmsLayout.tsx +++ b/frontend/src/layouts/KmsLayout/KmsLayout.tsx @@ -14,8 +14,8 @@ export const KmsLayout = () => { const location = useLocation(); return ( -
-
+
+
{ } }, [subscription, isBillingPage, isModalIntrusive]); - const matches = useRouterState({ select: (s) => s.matches.at(-1)?.context }); - const breadcrumbs = matches && "breadcrumbs" in matches ? matches.breadcrumbs : undefined; - const handleOrgChange = async (orgId: string, onSuccess?: () => void | Promise) => { if (orgId === currentOrg.id) return; @@ -316,7 +312,9 @@ export const Navbar = () => { const isServerAdminPanel = location.pathname.startsWith("/admin"); - const isProjectScope = location.pathname.startsWith(`/organizations/${currentOrg.id}/projects`); + const isProjectScope = + location.pathname.startsWith(`/organizations/${currentOrg.id}/projects`) && + location.pathname !== `/organizations/${currentOrg.id}/projects`; const handleOrgNav = async (org: Organization) => { if (currentOrg?.id === org.id) return; @@ -346,61 +344,59 @@ export const Navbar = () => { }; return ( -
-
-
+
+
+
infisical logo
-

/

+ {isServerAdminPanel ? ( - <> - - -
Server Console
- -

/

- {breadcrumbs ? ( - // scott: remove /admin as we show server console above - - ) : null} - + + +
Server Console
+ ) : ( <> -
+
+ {/* scott: the below is used to hide the top border from the org nav bar */} + {!isProjectScope && !isSubOrganization && ( +
+
+
+ )} -
- svg]:!text-org" - )} +
+ - -
- {getPlan(subscription)} -
+ + {rootOrg?.name} + + Organization + + {subscription.cardDeclined && ( { )} {isProjectScope && ( <> -

/

- {breadcrumbs ? ( - - ) : null} + + )} )}
- {subscription && subscription.slug === "starter" && !subscription.has_used_trial && ( + + {subscription && subscription.slug === "starter" && !subscription.has_used_trial ? ( + ) : ( +
+ {getPlan(subscription)} +
)} {/* eslint-disable-next-line no-nested-ternary */} {!location.pathname.startsWith("/admin") ? ( @@ -653,7 +649,7 @@ export const Navbar = () => { }} > - Invite Members + Invite Users ) : null } @@ -758,7 +754,7 @@ export const Navbar = () => { }} > }> - Invite Members + Invite Users ) : null diff --git a/frontend/src/layouts/OrganizationLayout/components/OrgNavBar/OrgNavBar.tsx b/frontend/src/layouts/OrganizationLayout/components/OrgNavBar/OrgNavBar.tsx index 24f4acafe..e56c02cee 100644 --- a/frontend/src/layouts/OrganizationLayout/components/OrgNavBar/OrgNavBar.tsx +++ b/frontend/src/layouts/OrganizationLayout/components/OrgNavBar/OrgNavBar.tsx @@ -19,9 +19,9 @@ export const OrgNavBar = ({ isHidden }: Props) => { const variant = isRootOrganization ? "org" : "namespace"; return ( - <> +
{!isHidden && ( -
+
{ isOpen={popUp?.createOrg?.isOpen} onClose={() => handlePopUpToggle("createOrg", false)} /> - +
); }; diff --git a/frontend/src/layouts/PamLayout/PamLayout.tsx b/frontend/src/layouts/PamLayout/PamLayout.tsx index f04357a88..365d3c3eb 100644 --- a/frontend/src/layouts/PamLayout/PamLayout.tsx +++ b/frontend/src/layouts/PamLayout/PamLayout.tsx @@ -29,8 +29,8 @@ export const PamLayout = () => { return ( <> -
-
+
+
{ const location = useLocation(); return ( -
-
+
+
= { [ProjectType.SecretScanning]: "Secret Scanning" }; -export const ProjectSelect = () => { +const ProjectSelectInner = () => { const [searchProject, setSearchProject] = useState(""); const { currentProject: currentWorkspace } = useProject(); const { currentOrg } = useOrganization(); @@ -92,7 +92,11 @@ export const ProjectSelect = () => { }, [projects, projectFavorites, currentWorkspace]); return ( -
+
+ {/* scott: the below is used to hide the top border from the org nav bar */} +
+
+
{ projectId: currentWorkspace.id, orgId: currentWorkspace.orgId }} - className="group flex cursor-pointer items-center gap-x-1.5 overflow-hidden hover:text-white" + className="group flex cursor-pointer items-center gap-x-2 overflow-hidden pt-0.5 text-sm text-white" > -

- {currentWorkspace?.name} -

- - - - {currentWorkspace.type ? PROJECT_TYPE_NAME[currentWorkspace.type] : "Project"} - + + {currentWorkspace?.name} + + {currentWorkspace.type ? PROJECT_TYPE_NAME[currentWorkspace.type] : "Project"} @@ -118,7 +118,7 @@ export const ProjectSelect = () => { variant="plain" colorSchema="secondary" ariaLabel="switch-project" - className="px-2 py-1" + className="top-px px-2 py-1" > @@ -238,3 +238,14 @@ export const ProjectSelect = () => {
); }; + +export const ProjectSelect = () => { + const params = useParams({ strict: false }); + + // Return null during navigation when projectId is not available + if (!params.projectId) { + return null; + } + + return ; +}; diff --git a/frontend/src/layouts/SecretManagerLayout/SecretManagerLayout.tsx b/frontend/src/layouts/SecretManagerLayout/SecretManagerLayout.tsx index 162260959..0a8d3adbc 100644 --- a/frontend/src/layouts/SecretManagerLayout/SecretManagerLayout.tsx +++ b/frontend/src/layouts/SecretManagerLayout/SecretManagerLayout.tsx @@ -39,8 +39,8 @@ export const SecretManagerLayout = () => { (secretApprovalReqCount?.open || 0) + (accessApprovalRequestCount?.pendingCount || 0); return ( -
-
+
+
{ ); return ( -
-
+
+
{ const location = useLocation(); return ( -
-
+
+
{
diff --git a/frontend/src/pages/admin/ResourceOverviewPage/ResourceOverviewPage.tsx b/frontend/src/pages/admin/ResourceOverviewPage/ResourceOverviewPage.tsx index eab91ef04..199fcb1bd 100644 --- a/frontend/src/pages/admin/ResourceOverviewPage/ResourceOverviewPage.tsx +++ b/frontend/src/pages/admin/ResourceOverviewPage/ResourceOverviewPage.tsx @@ -29,7 +29,7 @@ export const ResourceOverviewPage = () => { Users - Identities + Machine Identities diff --git a/frontend/src/pages/admin/ResourceOverviewPage/components/MachineIdentitiesTable.tsx b/frontend/src/pages/admin/ResourceOverviewPage/components/MachineIdentitiesTable.tsx index d17786712..0347fbcf9 100644 --- a/frontend/src/pages/admin/ResourceOverviewPage/components/MachineIdentitiesTable.tsx +++ b/frontend/src/pages/admin/ResourceOverviewPage/components/MachineIdentitiesTable.tsx @@ -90,7 +90,7 @@ const IdentityPanelTable = ({ value={searchIdentityFilter} onChange={(e) => setSearchIdentityFilter(e.target.value)} leftIcon={} - placeholder="Search identities by name..." + placeholder="Search machine identities by name..." className="flex-1" />
diff --git a/frontend/src/pages/admin/ResourceOverviewPage/components/OrganizationsTable.tsx b/frontend/src/pages/admin/ResourceOverviewPage/components/OrganizationsTable.tsx index 93d0253ff..0a619889b 100644 --- a/frontend/src/pages/admin/ResourceOverviewPage/components/OrganizationsTable.tsx +++ b/frontend/src/pages/admin/ResourceOverviewPage/components/OrganizationsTable.tsx @@ -350,8 +350,8 @@ const ViewMembersModalContent = ({ className="my-auto bg-mineshaft-700" title={ members.length - ? "No organization members match search..." - : "No organization members found" + ? "No organization users match search..." + : "No organization users found" } icon={faUsers} /> diff --git a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx index 1fb338a25..58aa2ba53 100644 --- a/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx +++ b/frontend/src/pages/cert-manager/CertificateAuthoritiesPage/components/ExternalCaModal.tsx @@ -27,6 +27,10 @@ import { TCloudflareZone, useCloudflareConnectionListZones } from "@app/hooks/api/appConnections/cloudflare"; +import { + TDNSMadeEasyZone, + useDNSMadeEasyConnectionListZones +} from "@app/hooks/api/appConnections/dns-made-easy"; import { AppConnection } from "@app/hooks/api/appConnections/enums"; import { AcmeDnsProvider, @@ -211,6 +215,11 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { enabled: caType === CaType.ACME }); + const { data: availableDNSMadeEasyConnections, isPending: isDNSMadeEasyPending } = + useListAvailableAppConnections(AppConnection.DNSMadeEasy, currentProject.id, { + enabled: caType === CaType.ACME + }); + const { data: availableAzureConnections, isPending: isAzurePending } = useListAvailableAppConnections(AppConnection.AzureADCS, currentProject.id, { enabled: caType === CaType.AZURE_AD_CS @@ -220,16 +229,24 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { if (caType === CaType.AZURE_AD_CS) { return availableAzureConnections || []; } - return [...(availableRoute53Connections || []), ...(availableCloudflareConnections || [])]; + return [ + ...(availableRoute53Connections || []), + ...(availableCloudflareConnections || []), + ...(availableDNSMadeEasyConnections || []) + ]; }, [ caType, availableRoute53Connections, availableCloudflareConnections, + availableDNSMadeEasyConnections, availableAzureConnections ]); const isPending = - isRoute53Pending || isCloudflarePending || (isAzurePending && caType === CaType.AZURE_AD_CS); + isRoute53Pending || + isCloudflarePending || + isDNSMadeEasyPending || + (isAzurePending && caType === CaType.AZURE_AD_CS); const dnsAppConnection = caType === CaType.ACME && configuration && "dnsAppConnection" in configuration @@ -241,6 +258,11 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { enabled: dnsProvider === AcmeDnsProvider.Cloudflare && !!dnsAppConnection.id }); + const { data: dnsMadeEasyZones = [], isPending: isDNSMadeEasyZonesPending } = + useDNSMadeEasyConnectionListZones(dnsAppConnection.id, { + enabled: dnsProvider === AcmeDnsProvider.DNSMadeEasy && !!dnsAppConnection.id + }); + // Populate form with CA data when editing useEffect(() => { if (ca && !isCaLoading) { @@ -500,6 +522,32 @@ export const ExternalCaModal = ({ popUp, handlePopUpToggle }: Props) => { )} /> )} + {dnsProvider === AcmeDnsProvider.DNSMadeEasy && ( + ( + + zone.id === value)} + onChange={(option) => { + onChange((option as SingleValue)?.id ?? null); + }} + options={dnsMadeEasyZones} + placeholder="Select a zone..." + getOptionLabel={(option) => option.name} + getOptionValue={(option) => option.id} + /> + + )} + /> + )} { {/* Manual renewal action for profile-issued certificates that are not revoked/expired (including failed ones) */} {(() => { const canRenew = - certificate.profileId && + (certificate.profileId || certificate.caId) && certificate.hasPrivateKey !== false && !certificate.renewedByCertificateId && !isRevoked && diff --git a/frontend/src/pages/cert-manager/IntegrationsListPage/IntegrationsListPage.tsx b/frontend/src/pages/cert-manager/IntegrationsListPage/IntegrationsListPage.tsx index 9387074eb..8cf23c725 100644 --- a/frontend/src/pages/cert-manager/IntegrationsListPage/IntegrationsListPage.tsx +++ b/frontend/src/pages/cert-manager/IntegrationsListPage/IntegrationsListPage.tsx @@ -49,7 +49,7 @@ export const IntegrationsListPage = () => {
diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx index 516b64288..430ceaadf 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/CreateProfileModal.tsx @@ -21,6 +21,8 @@ import { import { useProject, useSubscription } from "@app/context"; import { useListCasByProjectId } from "@app/hooks/api/ca/queries"; import { + EnrollmentType, + IssuerType, TCertificateProfileWithDetails, TCreateCertificateProfileDTO, TUpdateCertificateProfileDTO, @@ -46,8 +48,9 @@ const createSchema = z .trim() .max(1000, "Description must be less than 1000 characters") .optional(), - enrollmentType: z.enum(["api", "est", "acme"]), - certificateAuthorityId: z.string().min(1, "Certificate Authority is required"), + enrollmentType: z.nativeEnum(EnrollmentType), + issuerType: z.nativeEnum(IssuerType), + certificateAuthorityId: z.string().nullable().optional(), certificateTemplateId: z.string().min(1, "Certificate Template is required"), estConfig: z .object({ @@ -78,19 +81,101 @@ const createSchema = z }) .refine( (data) => { - if (data.enrollmentType === "est" && !data.estConfig) { - return false; - } - if (data.enrollmentType === "api" && !data.apiConfig) { - return false; - } - if (data.enrollmentType === "acme" && !data.acmeConfig) { - return false; + if (data.enrollmentType === EnrollmentType.EST) { + return !!data.estConfig; } return true; }, { - message: "Configuration is required for selected enrollment type" + message: "EST enrollment type requires EST configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.API) { + return !!data.apiConfig; + } + return true; + }, + { + message: "API enrollment type requires API configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.ACME) { + return !!data.acmeConfig; + } + return true; + }, + { + message: "ACME enrollment type requires ACME configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.EST) { + return !data.apiConfig && !data.acmeConfig; + } + return true; + }, + { + message: "EST enrollment type cannot have API or ACME configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.API) { + return !data.estConfig && !data.acmeConfig; + } + return true; + }, + { + message: "API enrollment type cannot have EST or ACME configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.ACME) { + return !data.estConfig && !data.apiConfig; + } + return true; + }, + { + message: "ACME enrollment type cannot have EST or API configuration" + } + ) + .refine( + (data) => { + if (data.issuerType === IssuerType.CA) { + return !!data.certificateAuthorityId; + } + return true; + }, + { + message: "CA issuer type requires a certificate authority" + } + ) + .refine( + (data) => { + if (data.issuerType === IssuerType.SELF_SIGNED) { + return !data.certificateAuthorityId; + } + return true; + }, + { + message: "Self-signed issuer type cannot have a certificate authority" + } + ) + .refine( + (data) => { + if (data.issuerType === IssuerType.SELF_SIGNED) { + return data.enrollmentType === EnrollmentType.API; + } + return true; + }, + { + message: "Self-signed issuer type only supports API enrollment" } ); @@ -110,8 +195,9 @@ const editSchema = z .trim() .max(1000, "Description must be less than 1000 characters") .optional(), - enrollmentType: z.enum(["api", "est", "acme"]), - certificateAuthorityId: z.string().optional(), + enrollmentType: z.nativeEnum(EnrollmentType), + issuerType: z.nativeEnum(IssuerType), + certificateAuthorityId: z.string().nullable().optional(), certificateTemplateId: z.string().optional(), estConfig: z .object({ @@ -130,19 +216,101 @@ const editSchema = z }) .refine( (data) => { - if (data.enrollmentType === "est" && !data.estConfig) { - return false; - } - if (data.enrollmentType === "api" && !data.apiConfig) { - return false; - } - if (data.enrollmentType === "acme" && !data.acmeConfig) { - return false; + if (data.enrollmentType === EnrollmentType.EST) { + return !!data.estConfig; } return true; }, { - message: "Configuration is required for selected enrollment type" + message: "EST enrollment type requires EST configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.API) { + return !!data.apiConfig; + } + return true; + }, + { + message: "API enrollment type requires API configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.ACME) { + return !!data.acmeConfig; + } + return true; + }, + { + message: "ACME enrollment type requires ACME configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.EST) { + return !data.apiConfig && !data.acmeConfig; + } + return true; + }, + { + message: "EST enrollment type cannot have API or ACME configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.API) { + return !data.estConfig && !data.acmeConfig; + } + return true; + }, + { + message: "API enrollment type cannot have EST or ACME configuration" + } + ) + .refine( + (data) => { + if (data.enrollmentType === EnrollmentType.ACME) { + return !data.estConfig && !data.apiConfig; + } + return true; + }, + { + message: "ACME enrollment type cannot have EST or API configuration" + } + ) + .refine( + (data) => { + if (data.issuerType === IssuerType.CA) { + return !!data.certificateAuthorityId; + } + return true; + }, + { + message: "CA issuer type requires a certificate authority" + } + ) + .refine( + (data) => { + if (data.issuerType === IssuerType.SELF_SIGNED) { + return !data.certificateAuthorityId; + } + return true; + }, + { + message: "Self-signed issuer type cannot have a certificate authority" + } + ) + .refine( + (data) => { + if (data.issuerType === IssuerType.SELF_SIGNED) { + return data.enrollmentType === EnrollmentType.API; + } + return true; + }, + { + message: "Self-signed issuer type only supports API enrollment" } ); @@ -193,10 +361,11 @@ export const CreateProfileModal = ({ slug: profile.slug, description: profile.description || "", enrollmentType: profile.enrollmentType, - certificateAuthorityId: profile.caId, + issuerType: profile.issuerType, + certificateAuthorityId: profile.caId || undefined, certificateTemplateId: profile.certificateTemplateId, estConfig: - profile.enrollmentType === "est" + profile.enrollmentType === EnrollmentType.EST ? { disableBootstrapCaValidation: profile.estConfig?.disableBootstrapCaValidation || false, @@ -205,18 +374,19 @@ export const CreateProfileModal = ({ } : undefined, apiConfig: - profile.enrollmentType === "api" + profile.enrollmentType === EnrollmentType.API ? { autoRenew: profile.apiConfig?.autoRenew || false, renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30 } : undefined, - acmeConfig: profile.enrollmentType === "acme" ? {} : undefined + acmeConfig: profile.enrollmentType === EnrollmentType.ACME ? {} : undefined } : { slug: "", description: "", - enrollmentType: "api", + enrollmentType: EnrollmentType.API, + issuerType: IssuerType.CA, certificateAuthorityId: "", certificateTemplateId: "", apiConfig: { @@ -228,6 +398,7 @@ export const CreateProfileModal = ({ }); const watchedEnrollmentType = watch("enrollmentType"); + const watchedIssuerType = watch("issuerType"); const watchedDisableBootstrapValidation = watch("estConfig.disableBootstrapCaValidation"); const watchedAutoRenew = watch("apiConfig.autoRenew"); @@ -237,7 +408,8 @@ export const CreateProfileModal = ({ slug: profile.slug, description: profile.description || "", enrollmentType: profile.enrollmentType, - certificateAuthorityId: profile.caId, + issuerType: profile.issuerType, + certificateAuthorityId: profile.caId || undefined, certificateTemplateId: profile.certificateTemplateId, estConfig: profile.enrollmentType === "est" @@ -255,13 +427,13 @@ export const CreateProfileModal = ({ renewBeforeDays: profile.apiConfig?.renewBeforeDays || 30 } : undefined, - acmeConfig: profile.enrollmentType === "acme" ? {} : undefined + acmeConfig: profile.enrollmentType === EnrollmentType.ACME ? {} : undefined }); } }, [isEdit, profile, reset]); const onFormSubmit = async (data: FormData) => { - if (!isEdit && !subscription?.pkiAcme && data.enrollmentType === "acme") { + if (!isEdit && !subscription?.pkiAcme && data.enrollmentType === EnrollmentType.ACME) { reset(); onClose(); handlePopUpOpen("upgradePlan", { @@ -276,14 +448,15 @@ export const CreateProfileModal = ({ const updateData: TUpdateCertificateProfileDTO = { profileId: profile.id, slug: data.slug, - description: data.description + description: data.description, + issuerType: data.issuerType }; - if (data.enrollmentType === "est" && data.estConfig) { + if (data.enrollmentType === EnrollmentType.EST && data.estConfig) { updateData.estConfig = data.estConfig; - } else if (data.enrollmentType === "api" && data.apiConfig) { + } else if (data.enrollmentType === EnrollmentType.API && data.apiConfig) { updateData.apiConfig = data.apiConfig; - } else if (data.enrollmentType === "acme" && data.acmeConfig) { + } else if (data.enrollmentType === EnrollmentType.ACME && data.acmeConfig) { updateData.acmeConfig = data.acmeConfig; } @@ -298,19 +471,23 @@ export const CreateProfileModal = ({ slug: data.slug, description: data.description, enrollmentType: data.enrollmentType, - caId: data.certificateAuthorityId, + issuerType: data.issuerType, + caId: + data.issuerType === IssuerType.SELF_SIGNED + ? undefined + : data.certificateAuthorityId || undefined, certificateTemplateId: data.certificateTemplateId }; - if (data.enrollmentType === "est" && data.estConfig) { + if (data.enrollmentType === EnrollmentType.EST && data.estConfig) { createData.estConfig = { passphrase: data.estConfig.passphrase, caChain: data.estConfig.caChain || undefined, disableBootstrapCaValidation: data.estConfig.disableBootstrapCaValidation }; - } else if (data.enrollmentType === "api" && data.apiConfig) { + } else if (data.enrollmentType === EnrollmentType.API && data.apiConfig) { createData.apiConfig = data.apiConfig; - } else if (data.enrollmentType === "acme" && data.acmeConfig) { + } else if (data.enrollmentType === EnrollmentType.ACME && data.acmeConfig) { createData.acmeConfig = data.acmeConfig; } @@ -372,34 +549,73 @@ export const CreateProfileModal = ({ ( )} /> + {watchedIssuerType === "ca" && ( + ( + + + + )} + /> + )} + API - EST - ACME + {watchedIssuerType !== IssuerType.SELF_SIGNED && ( + EST + )} + {watchedIssuerType !== IssuerType.SELF_SIGNED && ( + ACME + )} )} diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileList.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileList.tsx index 7b1ac187a..0ba217ce9 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileList.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileList.tsx @@ -54,7 +54,7 @@ export const ProfileList = ({ - + @@ -77,10 +77,10 @@ export const ProfileList = ({ - {isLoading && } + {isLoading && } {!isLoading && (!profiles || profiles.length === 0) && ( - + diff --git a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx index 009ebe1a4..c2ec7b688 100644 --- a/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx +++ b/frontend/src/pages/cert-manager/PoliciesPage/components/CertificateProfilesTab/ProfileRow.tsx @@ -30,7 +30,7 @@ import { } from "@app/context/ProjectPermissionContext/types"; import { usePopUp, useToggle } from "@app/hooks"; import { useGetCaById } from "@app/hooks/api/ca/queries"; -import { TCertificateProfile } from "@app/hooks/api/certificateProfiles"; +import { IssuerType, TCertificateProfile } from "@app/hooks/api/certificateProfiles"; import { useGetCertificateTemplateV2ById } from "@app/hooks/api/certificateTemplates/queries"; import { CertificateIssuanceModal } from "@app/pages/cert-manager/CertificatesPage/components/CertificateIssuanceModal"; @@ -49,7 +49,7 @@ export const ProfileRow = ({ }: Props) => { const { permission } = useProjectPermission(); - const { data: caData } = useGetCaById(profile.caId); + const { data: caData } = useGetCaById(profile.caId ?? ""); const { popUp, handlePopUpToggle } = usePopUp(["issueCertificate"] as const); @@ -121,7 +121,9 @@ export const ProfileRow = ({ {getEnrollmentTypeBadge(profile.enrollmentType)} - {caData?.friendlyName || caData?.commonName || profile.caId} + {profile.issuerType === IssuerType.SELF_SIGNED + ? "Self-signed" + : caData?.friendlyName || caData?.commonName || profile.caId} diff --git a/frontend/src/pages/cert-manager/SettingsPage/SettingsPage.tsx b/frontend/src/pages/cert-manager/SettingsPage/SettingsPage.tsx index b75ab2afc..81eb4e170 100644 --- a/frontend/src/pages/cert-manager/SettingsPage/SettingsPage.tsx +++ b/frontend/src/pages/cert-manager/SettingsPage/SettingsPage.tsx @@ -1,7 +1,10 @@ import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; +import { Link } from "@tanstack/react-router"; +import { InfoIcon } from "lucide-react"; import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; +import { useOrganization } from "@app/context"; import { ProjectType } from "@app/hooks/api/projects/types"; import { ProjectGeneralTab } from "@app/pages/project/SettingsPage/components/ProjectGeneralTab"; @@ -15,6 +18,7 @@ const tabs = [ export const SettingsPage = () => { const { t } = useTranslation(); + const { currentOrg } = useOrganization(); return (
@@ -22,7 +26,17 @@ export const SettingsPage = () => { {t("common.head-title", { title: t("settings.project.title") })}
- + + + Looking for organization settings? + + {tabs.map((tab) => ( diff --git a/frontend/src/pages/kms/OverviewPage/OverviewPage.tsx b/frontend/src/pages/kms/OverviewPage/OverviewPage.tsx index be3be8286..3069772a1 100644 --- a/frontend/src/pages/kms/OverviewPage/OverviewPage.tsx +++ b/frontend/src/pages/kms/OverviewPage/OverviewPage.tsx @@ -20,7 +20,7 @@ export const OverviewPage = () => {
{ const { t } = useTranslation(); + const { currentOrg } = useOrganization(); + return (
@@ -24,9 +29,19 @@ export const SettingsPage = () => {
+ > + + Looking for organization settings? + + {tabs.map((tab) => ( diff --git a/frontend/src/pages/organization/AccessManagementPage/AccessManagementPage.tsx b/frontend/src/pages/organization/AccessManagementPage/AccessManagementPage.tsx index d3e93bcea..0d5456f6d 100644 --- a/frontend/src/pages/organization/AccessManagementPage/AccessManagementPage.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/AccessManagementPage.tsx @@ -58,7 +58,7 @@ export const AccessManagementPage = () => { }, { key: OrgAccessControlTabSections.Identities, - label: "Identities", + label: "Machine Identities", isHidden: permission.cannot( OrgPermissionIdentityActions.Read, OrgPermissionSubjects.Identity @@ -83,8 +83,8 @@ export const AccessManagementPage = () => {
{!currentOrg.shouldUseNewPrivilegeSystem && (
diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsSection.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsSection.tsx index a5e2e113c..2f66c6644 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsSection.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsSection.tsx @@ -51,19 +51,19 @@ export const OrgGroupsSection = () => {
-

Groups

+

Organization Groups

{(isAllowed) => ( )} diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsTable.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsTable.tsx index 5b4698ec9..e0cd09d83 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsTable.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupsTable.tsx @@ -159,7 +159,7 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => { value={search} onChange={(e) => setSearch(e.target.value)} leftIcon={} - placeholder="Search groups..." + placeholder="Search organization groups..." /> @@ -205,7 +205,7 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
- Role + Organization Role
-

Identities

+

+ Organization Machine Identities +

@@ -116,7 +118,7 @@ export const IdentitySection = withPermission( if (!isMoreIdentitiesAllowed && !isEnterprise) { handlePopUpOpen("upgradePlan", { description: - "You can add more identities if you upgrade your Infisical Pro plan." + "You can add more machine identities if you upgrade your Infisical Pro plan." }); return; } @@ -129,7 +131,9 @@ export const IdentitySection = withPermission( }} isDisabled={!isAllowed} > - Create Identity + {isSubOrganization + ? "Add Machine Identity to Sub-Organization" + : "Create Organization Machine Identity"} )} @@ -141,7 +145,9 @@ export const IdentitySection = withPermission(
-

Identity Auth Templates

+

+ Machine Identity Auth Templates +

{(isAllowed) => (
-
Assign Existing Identity
+
Assign Existing Machine Identity
- Assign an existing identity from your parent organization. The identity will - continue to be managed at its original scope. + Assign an existing machine identity from your parent organization. The machine + identity will continue to be managed at its original scope.
diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx index 0c8cfd6bb..b94846f07 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/IdentityTable.tsx @@ -152,7 +152,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { }); createNotification({ - text: "Successfully updated identity role", + text: "Successfully updated machine identity role", type: "success" }); }; @@ -178,7 +178,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { { - Apply Roles to Filter Identities + Filter Organization Machine Identities by Role {roles?.map(({ id, slug, name }) => ( { value={search} onChange={(e) => setSearch(e.target.value)} leftIcon={} - placeholder="Search identities by name..." + placeholder="Search machine identities by name..." />
@@ -258,7 +258,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
- Role + Organization Role { }} isDisabled={!isAllowed} > - Edit Identity {isSubOrgIdentity ? "" : "Membership"} + Edit Machine Identity {isSubOrgIdentity ? "" : "Membership"} )} @@ -428,7 +428,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { icon={} > {isSubOrgIdentity - ? "Delete Identity" + ? "Delete Machine Identity" : "Remove From Sub-Organization"} )} @@ -455,8 +455,8 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => { 0 || filter.roles?.length > 0 - ? "No identities match search filter" - : "No identities have been created in this organization" + ? "No machine identities match search filter" + : "No machine identities have been created in this organization" } icon={faServer} /> diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/OrgIdentityLinkForm.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/OrgIdentityLinkForm.tsx index dde0588de..9f7141802 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/OrgIdentityLinkForm.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/OrgIdentityLinkForm.tsx @@ -78,11 +78,11 @@ export const OrgIdentityLinkForm = ({ onClose }: Props) => { control={control} name="identity" render={({ field: { onChange, value }, fieldState: { error } }) => ( - + option.id} diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/OrgIdentityModal.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/OrgIdentityModal.tsx index dfc496ca8..737c454d1 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/OrgIdentityModal.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgIdentityTab/components/IdentitySection/OrgIdentityModal.tsx @@ -164,7 +164,7 @@ export const OrgIdentityModal = ({ popUp, handlePopUpToggle }: Props) => { } createNotification({ - text: `Successfully ${popUp?.identity?.data ? "updated" : "created"} identity`, + text: `Successfully ${popUp?.identity?.data ? "updated" : "created"} machine identity`, type: "success" }); @@ -255,9 +255,7 @@ export const OrgIdentityModal = ({ popUp, handlePopUpToggle }: Props) => { />
- {i === 0 && ( - - )} + {i === 0 && } 0) { + setCompleteInviteLinks(data.completeInviteLinks); + } // only show this notification when email is configured. // A [completeInviteLink] will not be sent if smtp is configured - if (!data.completeInviteLinks) { + if (!data.completeInviteLinks?.length) { createNotification({ - text: "Successfully invited user to the organization.", + text: `Successfully invited user${usernames.length > 1 ? "s" : ""} to the organization.`, type: "success" }); } diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx index 10b0cd07d..6a4d93141 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersSection.tsx @@ -205,13 +205,13 @@ export const OrgMembersSection = () => {
-

Users

+

Organization Users

{(isAllowed) => ( )} diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx index d81c0a3eb..55b593ffe 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgMembersTab/components/OrgMembersSection/OrgMembersTable.tsx @@ -336,7 +336,7 @@ export const OrgMembersTable = ({ - Apply Roles to Filter Users + Filter Organization Users by Role {roles?.map(({ id, slug, name }) => ( setSearch(e.target.value)} leftIcon={} - placeholder="Search members..." + placeholder="Search organization users..." />
@@ -434,7 +434,7 @@ export const OrgMembersTable = ({
- Role + Organization Role diff --git a/frontend/src/pages/organization/AccessManagementPage/components/OrgRoleTabSection/OrgRoleTable.tsx b/frontend/src/pages/organization/AccessManagementPage/components/OrgRoleTabSection/OrgRoleTable.tsx index ee3c536e8..63b4284f1 100644 --- a/frontend/src/pages/organization/AccessManagementPage/components/OrgRoleTabSection/OrgRoleTable.tsx +++ b/frontend/src/pages/organization/AccessManagementPage/components/OrgRoleTabSection/OrgRoleTable.tsx @@ -199,7 +199,7 @@ export const OrgRoleTable = () => { {(isAllowed) => ( )} @@ -216,7 +216,7 @@ export const OrgRoleTable = () => { value={search} onChange={(e) => setSearch(e.target.value)} leftIcon={} - placeholder="Search roles..." + placeholder="Search organization roles..." className="flex-1" containerClassName="mb-4" /> @@ -441,7 +441,7 @@ export const OrgRoleTable = () => {
diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx index aca33ffa2..af26a940d 100644 --- a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/AppConnectionForm.tsx @@ -24,6 +24,7 @@ import { ChefConnectionForm } from "./ChefConnectionForm"; import { CloudflareConnectionForm } from "./CloudflareConnectionForm"; import { DatabricksConnectionForm } from "./DatabricksConnectionForm"; import { DigitalOceanConnectionForm } from "./DigitalOceanConnectionForm"; +import { DNSMadeEasyConnectionForm } from "./DNSMadeEasyConnectionForm"; import { FlyioConnectionForm } from "./FlyioConnectionForm"; import { GcpConnectionForm } from "./GcpConnectionForm"; import { GitHubConnectionForm } from "./GitHubConnectionForm"; @@ -148,6 +149,8 @@ const CreateForm = ({ app, onComplete, projectId }: CreateFormProps) => { return ; case AppConnection.Cloudflare: return ; + case AppConnection.DNSMadeEasy: + return ; case AppConnection.Bitbucket: return ; case AppConnection.Zabbix: @@ -306,6 +309,8 @@ const UpdateForm = ({ appConnection, onComplete }: UpdateFormProps) => { ); case AppConnection.Cloudflare: return ; + case AppConnection.DNSMadeEasy: + return ; case AppConnection.Bitbucket: return ; case AppConnection.Zabbix: diff --git a/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/DNSMadeEasyConnectionForm.tsx b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/DNSMadeEasyConnectionForm.tsx new file mode 100644 index 000000000..9d3c23743 --- /dev/null +++ b/frontend/src/pages/organization/AppConnections/AppConnectionsPage/components/AppConnectionForm/DNSMadeEasyConnectionForm.tsx @@ -0,0 +1,157 @@ +import { Controller, FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; + +import { + Button, + FormControl, + Input, + ModalClose, + SecretInput, + Select, + SelectItem +} from "@app/components/v2"; +import { APP_CONNECTION_MAP, getAppConnectionMethodDetails } from "@app/helpers/appConnections"; +import { TDNSMadeEasyConnection } from "@app/hooks/api/appConnections"; +import { AppConnection } from "@app/hooks/api/appConnections/enums"; +import { DNSMadeEasyConnectionMethod } from "@app/hooks/api/appConnections/types/dns-made-easy-connection"; + +import { + genericAppConnectionFieldsSchema, + GenericAppConnectionsFields +} from "./GenericAppConnectionFields"; + +type Props = { + appConnection?: TDNSMadeEasyConnection; + onSubmit: (formData: FormData) => Promise; +}; + +const rootSchema = genericAppConnectionFieldsSchema.extend({ + app: z.literal(AppConnection.DNSMadeEasy) +}); + +const formSchema = z.discriminatedUnion("method", [ + rootSchema.extend({ + method: z.literal(DNSMadeEasyConnectionMethod.APIKeySecret), + credentials: z.object({ + apiKey: z.string().trim().min(1, "API Key required"), + secretKey: z.string().trim().min(1, "Secret Key required") + }) + }) +]); + +type FormData = z.infer; + +export const DNSMadeEasyConnectionForm = ({ appConnection, onSubmit }: Props) => { + const isUpdate = Boolean(appConnection); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: appConnection ?? { + app: AppConnection.DNSMadeEasy, + method: DNSMadeEasyConnectionMethod.APIKeySecret, + credentials: { + apiKey: "", + secretKey: "" + } + } + }); + + const { + handleSubmit, + control, + formState: { isSubmitting, isDirty } + } = form; + + return ( + +
+ {!isUpdate && } + ( + + + + )} + /> + ( + + onChange(e.target.value)} + placeholder="af1b628f-3272-46aa-9cde-837d0c59155d" + /> + + )} + /> + ( + + onChange(e.target.value)} + /> + + )} + /> +
+ + + + +
+ +
+ ); +}; diff --git a/frontend/src/pages/organization/AuditLogsPage/AuditLogsPage.tsx b/frontend/src/pages/organization/AuditLogsPage/AuditLogsPage.tsx index 37d73125c..5f2290ab3 100644 --- a/frontend/src/pages/organization/AuditLogsPage/AuditLogsPage.tsx +++ b/frontend/src/pages/organization/AuditLogsPage/AuditLogsPage.tsx @@ -19,7 +19,7 @@ export const AuditLogsPage = () => {
diff --git a/frontend/src/pages/organization/GroupDetailsByIDPage/GroupDetailsByIDPage.tsx b/frontend/src/pages/organization/GroupDetailsByIDPage/GroupDetailsByIDPage.tsx index 055adfedc..8c606f07c 100644 --- a/frontend/src/pages/organization/GroupDetailsByIDPage/GroupDetailsByIDPage.tsx +++ b/frontend/src/pages/organization/GroupDetailsByIDPage/GroupDetailsByIDPage.tsx @@ -88,7 +88,7 @@ const Page = () => { className="mb-4 flex items-center gap-x-2 text-sm text-mineshaft-400" > - Groups + Organization Groups { }); createNotification({ - text: "Successfully deleted identity", + text: "Successfully deleted machine identity", type: "success" }); @@ -82,11 +82,11 @@ const Page = () => { className="mb-4 flex items-center gap-x-2 text-sm text-mineshaft-400" > - Identities + Organization Machine Identities
@@ -111,7 +111,7 @@ const Page = () => { }) } > - Unlink Identity + Unlink Machine Identity )} @@ -142,7 +142,7 @@ const Page = () => { > diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityDetailsSection.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityDetailsSection.tsx index b19901bef..f9bbdce9d 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityDetailsSection.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityDetailsSection.tsx @@ -45,7 +45,7 @@ export const IdentityDetailsSection = ({ identityId, handlePopUpOpen, isOrgIdent return data ? (
-

Identity Details

+

Details

-

Identity ID

+

Machine Identity ID

{data.identity.id}

diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityProjectsSection/IdentityAddToProjectModal.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityProjectsSection/IdentityAddToProjectModal.tsx index 5c4dcd6dd..fb11c758e 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityProjectsSection/IdentityAddToProjectModal.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityProjectsSection/IdentityAddToProjectModal.tsx @@ -167,7 +167,7 @@ export const IdentityAddToProjectModal = ({ identityId, popUp, handlePopUpToggle handlePopUpToggle("addIdentityToProject", isOpen); }} > - + diff --git a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityProjectsSection/IdentityProjectsTable.tsx b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityProjectsSection/IdentityProjectsTable.tsx index 4dcc1996e..daf40c21b 100644 --- a/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityProjectsSection/IdentityProjectsTable.tsx +++ b/frontend/src/pages/organization/IdentityDetailsByIDPage/components/IdentityProjectsSection/IdentityProjectsTable.tsx @@ -151,7 +151,7 @@ export const IdentityProjectsTable = ({ identityId, handlePopUpOpen }: Props) => title={ projectMemberships.length ? "No projects match search..." - : "This identity has not been assigned to any projects" + : "This machine identity has not been assigned to any projects" } icon={projectMemberships.length ? faSearch : faFolder} /> diff --git a/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/GatewayCliDeploymentMethod.tsx b/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/GatewayCliDeploymentMethod.tsx index 4942bf2e2..8ea1807cb 100644 --- a/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/GatewayCliDeploymentMethod.tsx +++ b/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/GatewayCliDeploymentMethod.tsx @@ -55,10 +55,10 @@ const formSchemaWithIdentity = baseFormSchema.extend({ id: z.string(), name: z.string() }, - { required_error: "Identity is required" } + { required_error: "Machine identity is required" } ) .nullable() - .refine((val) => val !== null, { message: "Identity is required" }) + .refine((val) => val !== null, { message: "Machine identity is required" }) }); const formSchemaWithToken = baseFormSchema.extend({ @@ -275,8 +275,8 @@ export const GatewayCliDeploymentMethod = () => { {canCreateToken && autogenerateToken ? ( <> { ) } isLoading={isIdentitiesLoading} - placeholder="Select identity..." + placeholder="Select machine identity..." options={identityMembershipOrgs.map((membership) => membership.identity)} getOptionValue={(option) => option.id} getOptionLabel={(option) => option.name} @@ -300,14 +300,14 @@ export const GatewayCliDeploymentMethod = () => { ) : ( <> setIdentityToken(e.target.value)} - placeholder="Enter identity token..." + placeholder="Enter machine identity token..." isError={Boolean(errors.identityToken)} /> {errors.identityToken &&

{errors.identityToken}

} @@ -325,15 +325,15 @@ export const GatewayCliDeploymentMethod = () => { className="mr-2" >
- Automatically enable token auth and generate a token for identity + Automatically enable token auth and generate a token for machine identity - Token authentication will be automatically enabled for the selected identity if - it isn't already configured. By default, it will be configured to allow all - IP addresses with a token TTL of 30 days. You can manage these settings in - Access Control. + Token authentication will be automatically enabled for the selected machine + identity if it isn't already configured. By default, it will be configured + to allow all IP addresses with a token TTL of 30 days. You can manage these + settings in Access Control.

A token will automatically be generated to be used with the CLI command. diff --git a/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/GatewayCliSystemdDeploymentMethod.tsx b/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/GatewayCliSystemdDeploymentMethod.tsx index 860590ffa..1603f3bdd 100644 --- a/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/GatewayCliSystemdDeploymentMethod.tsx +++ b/frontend/src/pages/organization/NetworkingPage/components/GatewayTab/components/GatewayCliSystemdDeploymentMethod.tsx @@ -55,10 +55,10 @@ const formSchemaWithIdentity = baseFormSchema.extend({ id: z.string(), name: z.string() }, - { required_error: "Identity is required" } + { required_error: "Machine identity is required" } ) .nullable() - .refine((val) => val !== null, { message: "Identity is required" }) + .refine((val) => val !== null, { message: "Machine identity is required" }) }); const formSchemaWithToken = baseFormSchema.extend({ @@ -297,8 +297,8 @@ export const GatewayCliSystemdDeploymentMethod = () => { {canCreateToken && autogenerateToken ? ( <> { ) } isLoading={isIdentitiesLoading} - placeholder="Select identity..." + placeholder="Select machine identity..." options={identityMembershipOrgs.map((membership) => membership.identity)} getOptionValue={(option) => option.id} getOptionLabel={(option) => option.name} @@ -322,14 +322,14 @@ export const GatewayCliSystemdDeploymentMethod = () => { ) : ( <> setIdentityToken(e.target.value)} - placeholder="Enter identity token..." + placeholder="Enter machine identity token..." isError={Boolean(errors.identityToken)} /> {errors.identityToken &&

{errors.identityToken}

} @@ -347,15 +347,15 @@ export const GatewayCliSystemdDeploymentMethod = () => { className="mr-2" >
- Automatically enable token auth and generate a token for identity + Automatically enable token auth and generate a token for machine identity - Token authentication will be automatically enabled for the selected identity if - it isn't already configured. By default, it will be configured to allow all - IP addresses with a token TTL of 30 days. You can manage these settings in - Access Control. + Token authentication will be automatically enabled for the selected machine + identity if it isn't already configured. By default, it will be configured + to allow all IP addresses with a token TTL of 30 days. You can manage these + settings in Access Control.

A token will automatically be generated to be used with the CLI command. diff --git a/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayCliDeploymentMethod.tsx b/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayCliDeploymentMethod.tsx index 87f2ed75b..ce8e988c0 100644 --- a/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayCliDeploymentMethod.tsx +++ b/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayCliDeploymentMethod.tsx @@ -41,10 +41,10 @@ const formSchemaWithIdentity = baseFormSchema.extend({ id: z.string(), name: z.string() }, - { required_error: "Identity is required" } + { required_error: "Machine identity is required" } ) .nullable() - .refine((val) => val !== null, { message: "Identity is required" }) + .refine((val) => val !== null, { message: "Machine identity is required" }) }); const formSchemaWithToken = baseFormSchema.extend({ @@ -229,8 +229,8 @@ export const RelayCliDeploymentMethod = () => { {canCreateToken && autogenerateToken ? ( <> { ) } isLoading={isIdentitiesLoading} - placeholder="Select identity..." + placeholder="Select machine identity..." options={identityMembershipOrgs.map((membership) => membership.identity)} getOptionValue={(option) => option.id} getOptionLabel={(option) => option.name} @@ -254,14 +254,14 @@ export const RelayCliDeploymentMethod = () => { ) : ( <> setIdentityToken(e.target.value)} - placeholder="Enter identity token..." + placeholder="Enter machine identity token..." isError={Boolean(errors.identityToken)} /> {errors.identityToken &&

{errors.identityToken}

} @@ -279,15 +279,15 @@ export const RelayCliDeploymentMethod = () => { className="mr-2" >
- Automatically enable token auth and generate a token for identity + Automatically enable token auth and generate a token for machine identity - Token authentication will be automatically enabled for the selected identity if - it isn't already configured. By default, it will be configured to allow all - IP addresses with a token TTL of 30 days. You can manage these settings in - Access Control. + Token authentication will be automatically enabled for the selected machine + identity if it isn't already configured. By default, it will be configured + to allow all IP addresses with a token TTL of 30 days. You can manage these + settings in Access Control.

A token will automatically be generated to be used with the CLI command. diff --git a/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayCliSystemdDeploymentMethod.tsx b/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayCliSystemdDeploymentMethod.tsx index 10e4d9edd..635e07eba 100644 --- a/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayCliSystemdDeploymentMethod.tsx +++ b/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayCliSystemdDeploymentMethod.tsx @@ -41,10 +41,10 @@ const formSchemaWithIdentity = baseFormSchema.extend({ id: z.string(), name: z.string() }, - { required_error: "Identity is required" } + { required_error: "Machine identity is required" } ) .nullable() - .refine((val) => val !== null, { message: "Identity is required" }) + .refine((val) => val !== null, { message: "Machine identity is required" }) }); const formSchemaWithToken = baseFormSchema.extend({ @@ -270,8 +270,8 @@ export const RelayCliSystemdDeploymentMethod = () => { {canCreateToken && autogenerateToken ? ( <> { ) } isLoading={isIdentitiesLoading} - placeholder="Select identity..." + placeholder="Select machine identity..." options={identityMembershipOrgs.map((membership) => membership.identity)} getOptionValue={(option) => option.id} getOptionLabel={(option) => option.name} @@ -295,14 +295,14 @@ export const RelayCliSystemdDeploymentMethod = () => { ) : ( <> setIdentityToken(e.target.value)} - placeholder="Enter identity token..." + placeholder="Enter machine identity token..." isError={Boolean(errors.identityToken)} /> {errors.identityToken &&

{errors.identityToken}

} @@ -320,15 +320,15 @@ export const RelayCliSystemdDeploymentMethod = () => { className="mr-2" >
- Automatically enable token auth and generate a token for identity + Automatically enable token auth and generate a token for machine identity - Token authentication will be automatically enabled for the selected identity if - it isn't already configured. By default, it will be configured to allow all - IP addresses with a token TTL of 30 days. You can manage these settings in - Access Control. + Token authentication will be automatically enabled for the selected machine + identity if it isn't already configured. By default, it will be configured + to allow all IP addresses with a token TTL of 30 days. You can manage these + settings in Access Control.

A token will automatically be generated to be used with the CLI command. diff --git a/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayTerraformDeploymentMethod.tsx b/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayTerraformDeploymentMethod.tsx index d9c67d610..301930741 100644 --- a/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayTerraformDeploymentMethod.tsx +++ b/frontend/src/pages/organization/NetworkingPage/components/RelayTab/components/RelayTerraformDeploymentMethod.tsx @@ -42,10 +42,10 @@ const formSchemaWithIdentity = baseFormSchema.extend({ id: z.string(), name: z.string() }, - { required_error: "Identity is required" } + { required_error: "Machine identity is required" } ) .nullable() - .refine((val) => val !== null, { message: "Identity is required" }) + .refine((val) => val !== null, { message: "Machine identity is required" }) }); const formSchemaWithToken = baseFormSchema.extend({ @@ -349,8 +349,8 @@ resource "aws_eip_association" "eip_assoc" { {canCreateToken && autogenerateToken ? ( <> membership.identity)} getOptionValue={(option) => option.id} getOptionLabel={(option) => option.name} @@ -374,14 +374,14 @@ resource "aws_eip_association" "eip_assoc" { ) : ( <> setIdentityToken(e.target.value)} - placeholder="Enter identity token..." + placeholder="Enter machine identity token..." isError={Boolean(errors.identityToken)} /> {errors.identityToken &&

{errors.identityToken}

} @@ -399,15 +399,15 @@ resource "aws_eip_association" "eip_assoc" { className="mr-2" >
- Automatically enable token auth and generate a token for identity + Automatically enable token auth and generate a token for machine identity - Token authentication will be automatically enabled for the selected identity if - it isn't already configured. By default, it will be configured to allow all - IP addresses with a token TTL of 30 days. You can manage these settings in - Access Control. + Token authentication will be automatically enabled for the selected machine + identity if it isn't already configured. By default, it will be configured + to allow all IP addresses with a token TTL of 30 days. You can manage these + settings in Access Control.

A token will automatically be generated to be used with the CLI command. diff --git a/frontend/src/pages/organization/ProjectsPage/ProjectsPage.tsx b/frontend/src/pages/organization/ProjectsPage/ProjectsPage.tsx index 6e1bc1398..c0117b758 100644 --- a/frontend/src/pages/organization/ProjectsPage/ProjectsPage.tsx +++ b/frontend/src/pages/organization/ProjectsPage/ProjectsPage.tsx @@ -81,7 +81,7 @@ export const ProjectsPage = () => { {projectListView === ProjectListView.MyProjects ? ( diff --git a/frontend/src/pages/organization/SettingsPage/SettingsPage.tsx b/frontend/src/pages/organization/SettingsPage/SettingsPage.tsx index b3a7c1043..cec33b289 100644 --- a/frontend/src/pages/organization/SettingsPage/SettingsPage.tsx +++ b/frontend/src/pages/organization/SettingsPage/SettingsPage.tsx @@ -20,7 +20,7 @@ export const SettingsPage = () => {
diff --git a/frontend/src/pages/organization/UserDetailsByIDPage/UserDetailsByIDPage.tsx b/frontend/src/pages/organization/UserDetailsByIDPage/UserDetailsByIDPage.tsx index 3b26f80f0..608348029 100644 --- a/frontend/src/pages/organization/UserDetailsByIDPage/UserDetailsByIDPage.tsx +++ b/frontend/src/pages/organization/UserDetailsByIDPage/UserDetailsByIDPage.tsx @@ -112,7 +112,7 @@ const Page = withPermission( className="mb-4 flex items-center gap-x-2 text-sm text-mineshaft-400" > - Users + Organization Users { const { t } = useTranslation(); + const { currentOrg } = useOrganization(); + return (
@@ -16,9 +21,19 @@ export const SettingsPage = () => {
+ > + + Looking for organization settings? + + diff --git a/frontend/src/pages/project/AccessControlPage/AccessControlPage.tsx b/frontend/src/pages/project/AccessControlPage/AccessControlPage.tsx index 84a5376ab..83624c98d 100644 --- a/frontend/src/pages/project/AccessControlPage/AccessControlPage.tsx +++ b/frontend/src/pages/project/AccessControlPage/AccessControlPage.tsx @@ -1,6 +1,7 @@ import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; -import { useNavigate, useSearch } from "@tanstack/react-router"; +import { Link, useNavigate, useSearch } from "@tanstack/react-router"; +import { InfoIcon } from "lucide-react"; import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; import { useOrganization, useProject } from "@app/context"; @@ -43,9 +44,19 @@ const Page = () => {
+ title="Project Access Control" + description="Manage fine-grained access for users, groups, roles, and machine identities within your project resources." + > + + Looking for organization access control? + + @@ -55,7 +66,7 @@ const Page = () => { Groups - Identities + Machine Identities {isSecretManager && ( diff --git a/frontend/src/pages/project/AccessControlPage/components/GroupsTab/components/GroupsSection/GroupsSection.tsx b/frontend/src/pages/project/AccessControlPage/components/GroupsTab/components/GroupsSection/GroupsSection.tsx index 8415a5e88..b6c0574cc 100644 --- a/frontend/src/pages/project/AccessControlPage/components/GroupsTab/components/GroupsSection/GroupsSection.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/GroupsTab/components/GroupsSection/GroupsSection.tsx @@ -59,19 +59,19 @@ export const GroupsSection = () => {
-

User Groups

+

Project Groups

{(isAllowed) => ( )} diff --git a/frontend/src/pages/project/AccessControlPage/components/GroupsTab/components/GroupsSection/GroupsTable.tsx b/frontend/src/pages/project/AccessControlPage/components/GroupsTab/components/GroupsSection/GroupsTable.tsx index c3d850c6b..fd8f5fe3c 100644 --- a/frontend/src/pages/project/AccessControlPage/components/GroupsTab/components/GroupsSection/GroupsTable.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/GroupsTab/components/GroupsSection/GroupsTable.tsx @@ -122,7 +122,7 @@ export const GroupTable = ({ handlePopUpOpen }: Props) => { value={search} onChange={(e) => setSearch(e.target.value)} leftIcon={} - placeholder="Search members..." + placeholder="Search project groups..." /> @@ -143,7 +143,7 @@ export const GroupTable = ({ handlePopUpOpen }: Props) => { - + diff --git a/frontend/src/pages/project/AccessControlPage/components/IdentityTab/IdentityTab.tsx b/frontend/src/pages/project/AccessControlPage/components/IdentityTab/IdentityTab.tsx index 72bae83f2..14c402c6f 100644 --- a/frontend/src/pages/project/AccessControlPage/components/IdentityTab/IdentityTab.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/IdentityTab/IdentityTab.tsx @@ -158,7 +158,7 @@ export const IdentityTab = withProjectPermission( }); createNotification({ - text: "Successfully deleted project identity", + text: "Successfully deleted project machine identity", type: "success" }); } else { @@ -168,7 +168,7 @@ export const IdentityTab = withProjectPermission( }); createNotification({ - text: "Successfully removed identity from project", + text: "Successfully removed machine identity from project", type: "success" }); } @@ -197,7 +197,7 @@ export const IdentityTab = withProjectPermission(
-

Identities

+

Project Machine Identities

@@ -212,7 +212,7 @@ export const IdentityTab = withProjectPermission( onClick={() => handlePopUpOpen("createIdentity")} isDisabled={!isAllowed} > - Create Identity + Add Machine Identity to Project )} @@ -223,7 +223,7 @@ export const IdentityTab = withProjectPermission( value={search} onChange={(e) => setSearch(e.target.value)} leftIcon={} - placeholder="Search identities by name..." + placeholder="Search project machine identities by name..." />
RoleProject Role Added on
@@ -251,7 +251,7 @@ export const IdentityTab = withProjectPermission( - + @@ -454,7 +454,9 @@ export const IdentityTab = withProjectPermission( }); }} > - {identityProjectId ? "Delete Identity" : "Remove From Project"} + {identityProjectId + ? "Delete Machine Identity" + : "Remove From Project"} )} @@ -474,7 +476,7 @@ export const IdentityTab = withProjectPermission( @@ -497,8 +499,8 @@ export const IdentityTab = withProjectPermission( 0 - ? "No identities match search filter" - : "No identities have been added to this project" + ? "No machine identities match search filter" + : "No machine identities have been added to this project" } icon={faServer} /> @@ -513,8 +515,8 @@ export const IdentityTab = withProjectPermission( > {wizardStep === WizardSteps.SelectAction && ( @@ -538,11 +540,11 @@ export const IdentityTab = withProjectPermission( >
-
Create New Identity
+
Create Machine Identity
- Create a new machine identity specifically for this project. This identity - will be managed at the project-level. + Create a new machine identity specifically for this project. This machine + identity will be managed at the project-level.
-
Assign Existing Identity
+
Assign Existing Machine Identity
- Assign an existing identity from your organization. The identity will continue - to be managed at its original scope. + Assign an existing machine identity from your organization. The machine + identity will continue to be managed at its original scope.
diff --git a/frontend/src/pages/project/AccessControlPage/components/IdentityTab/components/ProjectIdentityModal.tsx b/frontend/src/pages/project/AccessControlPage/components/IdentityTab/components/ProjectIdentityModal.tsx index 327144733..7e1e98c54 100644 --- a/frontend/src/pages/project/AccessControlPage/components/IdentityTab/components/ProjectIdentityModal.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/IdentityTab/components/ProjectIdentityModal.tsx @@ -138,7 +138,7 @@ export const ProjectIdentityModal = ({ onClose, identity }: ContentProps) => { } createNotification({ - text: `Successfully ${isUpdate ? "updated" : "created"} project identity`, + text: `Successfully ${isUpdate ? "updated" : "created"} project machine identity`, type: "success" }); @@ -148,7 +148,7 @@ export const ProjectIdentityModal = ({ onClose, identity }: ContentProps) => { const error = err as any; const text = error?.response?.data?.message ?? - `Failed to ${isUpdate ? "update" : "create"} project identity`; + `Failed to ${isUpdate ? "update" : "create"} project machine identity`; createNotification({ text, @@ -233,9 +233,7 @@ export const ProjectIdentityModal = ({ onClose, identity }: ContentProps) => { />
- {i === 0 && ( - - )} + {i === 0 && } { }); createNotification({ - text: "Successfully added identity to project", + text: "Successfully added machine identity to project", type: "success" }); @@ -114,11 +114,11 @@ export const ProjectLinkIdentityModal = ({ handlePopUpToggle }: Props) => { control={control} name="identity" render={({ field: { onChange, value }, fieldState: { error } }) => ( - + ({ name: membership.name, diff --git a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MembersSection.tsx b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MembersSection.tsx index 38dc5e47e..580181235 100644 --- a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MembersSection.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MembersSection.tsx @@ -49,19 +49,19 @@ export const MembersSection = () => {
-

Users

+

Project Users

{(isAllowed) => ( )} diff --git a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MembersTable.tsx b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MembersTable.tsx index d554f12fb..df2983655 100644 --- a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MembersTable.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MembersTable.tsx @@ -208,7 +208,7 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => { - Apply Roles to Filter Users + Filter Project Users by Role {projectRoles?.map(({ id, slug, name }) => ( { value={search} onChange={(e) => setSearch(e.target.value)} leftIcon={} - placeholder="Search members..." + placeholder="Search project users..." />
@@ -282,7 +282,7 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => {
-
+ @@ -462,9 +462,7 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => { )} {!isMembersLoading && !filteredUsers?.length && ( )} diff --git a/frontend/src/pages/project/AccessControlPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx b/frontend/src/pages/project/AccessControlPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx index eeb29725f..cd76677f0 100644 --- a/frontend/src/pages/project/AccessControlPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/ProjectRoleListTab/components/ProjectRoleList/ProjectRoleList.tsx @@ -170,13 +170,13 @@ export const ProjectRoleList = () => { {(isAllowed) => ( )} diff --git a/frontend/src/pages/project/AccessControlPage/components/ServiceTokenTab/components/ServiceTokenSection/ServiceTokenSection.tsx b/frontend/src/pages/project/AccessControlPage/components/ServiceTokenTab/components/ServiceTokenSection/ServiceTokenSection.tsx index 6f2e82e02..5c019c5be 100644 --- a/frontend/src/pages/project/AccessControlPage/components/ServiceTokenTab/components/ServiceTokenSection/ServiceTokenSection.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/ServiceTokenTab/components/ServiceTokenSection/ServiceTokenSection.tsx @@ -57,7 +57,7 @@ export const ServiceTokenSection = withProjectPermission( > {(isAllowed) => ( )} @@ -219,7 +219,7 @@ const Page = () => { {!isProjectIdentity && ( - This identity is managed by your organization.{" "} + This machine identity is managed by your organization.{" "} { }} > - Click here to manage identity. + Click here to manage machine identity. ) : null @@ -286,15 +286,15 @@ const Page = () => { handlePopUpToggle("assumePrivileges", isOpen)} onConfirmed={handleAssumePrivileges} buttonText="Confirm" /> ) : ( - + )} ); diff --git a/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeSection.tsx b/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeSection.tsx index b11bd9dae..e43b80538 100644 --- a/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeSection.tsx +++ b/frontend/src/pages/project/IdentityDetailsByIDPage/components/IdentityProjectAdditionalPrivilegeSection/IdentityProjectAdditionalPrivilegeSection.tsx @@ -235,7 +235,10 @@ export const IdentityProjectAdditionalPrivilegeSection = ({ identityMembershipDe
RoleProject Role Managed by {isFetching ? : null}
RoleProject Role
{!isPending && !identityProjectPrivileges?.length && ( - + )}
diff --git a/frontend/src/pages/project/IdentityDetailsByIDPage/components/ProjectIdentityDetailsSection.tsx b/frontend/src/pages/project/IdentityDetailsByIDPage/components/ProjectIdentityDetailsSection.tsx index dae996489..e4ad97f9d 100644 --- a/frontend/src/pages/project/IdentityDetailsByIDPage/components/ProjectIdentityDetailsSection.tsx +++ b/frontend/src/pages/project/IdentityDetailsByIDPage/components/ProjectIdentityDetailsSection.tsx @@ -69,7 +69,7 @@ export const ProjectIdentityDetailsSection = ({ identity, isOrgIdentity, members } catch { createNotification({ type: "error", - text: "Failed to delete project identity" + text: "Failed to delete project machine identity" }); } }; @@ -77,7 +77,7 @@ export const ProjectIdentityDetailsSection = ({ identity, isOrgIdentity, members return (
-

Identity Details

+

Details

{!isOrgIdentity && ( @@ -114,7 +114,7 @@ export const ProjectIdentityDetailsSection = ({ identity, isOrgIdentity, members }} disabled={!isAllowed} > - Edit Identity + Edit Machine Identity )} @@ -137,7 +137,7 @@ export const ProjectIdentityDetailsSection = ({ identity, isOrgIdentity, members icon={} disabled={!isAllowed} > - Delete Identity + Delete Machine Identity )} @@ -146,7 +146,7 @@ export const ProjectIdentityDetailsSection = ({ identity, isOrgIdentity, members
-

Identity ID

+

Machine Identity ID

{identity.id}

diff --git a/frontend/src/pages/project/MemberDetailsByIDPage/MemberDetailsByIDPage.tsx b/frontend/src/pages/project/MemberDetailsByIDPage/MemberDetailsByIDPage.tsx index cfaf9933c..62b4e4c43 100644 --- a/frontend/src/pages/project/MemberDetailsByIDPage/MemberDetailsByIDPage.tsx +++ b/frontend/src/pages/project/MemberDetailsByIDPage/MemberDetailsByIDPage.tsx @@ -128,7 +128,7 @@ export const Page = () => { className="mb-4 flex items-center gap-x-2 text-sm text-mineshaft-400" > - Users + Project Users { className="mb-4 flex items-center gap-x-2 text-sm text-mineshaft-400" > - Roles + Project Roles {
diff --git a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx index f3abbec97..a7c9bf223 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx @@ -10,6 +10,8 @@ import { faArrowRight, faArrowRightToBracket, faArrowUp, + faCheck, + faCopy, faFilter, faFingerprint, faFolder, @@ -79,6 +81,7 @@ import { usePopUp, useResetPageHelper, useResizableHeaderHeight, + useTimedReset, useToggle } from "@app/hooks"; import { @@ -194,6 +197,15 @@ export const OverviewPage = () => { } }; + const [copiedSlug, , setCopiedSlug] = useTimedReset({ + initialState: "" + }); + + const copyToClipboard = (value: string, slug: string) => { + navigator.clipboard.writeText(value); + setCopiedSlug(slug); + }; + const [filter, setFilter] = useState(DEFAULT_FILTER_STATE); const [filterHistory, setFilterHistory] = useState< Map @@ -921,12 +933,12 @@ export const OverviewPage = () => {
Inject your secrets using { , { , { , and { > {name}

- ) : ( - "" - ) +
+ {collapseEnvironments ? ( +

{name}

+ ) : ( + "" + )} +
+

{slug}

+ copyToClipboard(slug, slug)} + > + + +
+
} side="bottom" - sideOffset={-1} - align="end" + sideOffset={5} + align="center" className="max-w-xl text-xs normal-case" rootProps={{ - disableHoverableContent: true + disableHoverableContent: false }} + key={`tooltip-${name}-${index + 1}`} >
{ const tableRef = useRef(null); const [isVisible, setIsVisible] = useState(false); + const [selectedDynamicSecretId, setSelectedDynamicSecretId] = useState( + routerQueryParams.dynamicSecretId || "" + ); const { isBatchMode, pendingChanges } = useBatchMode(); const { loadPendingChanges, setExistingKeys } = useBatchModeActions(); @@ -165,6 +168,28 @@ const Page = () => { if (isVisible) setIsVisible(false); }, [environment]); + useEffect(() => { + if (routerQueryParams.dynamicSecretId !== null) { + setSelectedDynamicSecretId(routerQueryParams.dynamicSecretId); + + navigate({ + search: (prev) => ({ + ...prev, + dynamicSecretId: undefined + }) + }); + + // if any of the router query params are changed, we have to clear the selected dynamic secret id to avoid re-rendering the lease modal when it suddendly becomes available + } else { + setSelectedDynamicSecretId(null); + } + }, [ + routerQueryParams.filterBy, + routerQueryParams.search, + routerQueryParams.secretPath, + routerQueryParams.tags + ]); + const canReadSecret = hasSecretReadValueOrDescribePermission( permission, ProjectPermissionSecretActions.DescribeSecret, @@ -1039,6 +1064,7 @@ const Page = () => { )} {canReadDynamicSecret && Boolean(dynamicSecrets?.length) && ( { + if ( + selectedDynamicSecretId && + dynamicSecrets.find((secret) => secret.id === selectedDynamicSecretId) + ) { + handlePopUpOpen("dynamicSecretLeases", selectedDynamicSecretId); + } + }, [selectedDynamicSecretId]); + return ( <> {dynamicSecrets.map((secret) => { @@ -231,7 +244,12 @@ export const DynamicSecretListView = ({
+

Dynamic secret leases

+ {secret.name} +
+ } subTitle="Revoke or renew your secret leases" className="max-w-3xl" > diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/route.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/route.tsx index f5796fd69..df054b6e2 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/route.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/route.tsx @@ -12,6 +12,7 @@ const SecretDashboardPageQueryParamsSchema = z.object({ search: z.string().catch(""), tags: z.string().catch(""), filterBy: z.string().catch(""), + dynamicSecretId: z.string().catch(""), connectionId: z.string().optional(), connectionName: z.string().optional() }); @@ -26,7 +27,8 @@ export const Route = createFileRoute( secretPath: "/", search: "", tags: "", - filterBy: "" + filterBy: "", + dynamicSecretId: "" }) ] }, diff --git a/frontend/src/pages/secret-manager/SettingsPage/SettingsPage.tsx b/frontend/src/pages/secret-manager/SettingsPage/SettingsPage.tsx index b2d984d75..6f437a738 100644 --- a/frontend/src/pages/secret-manager/SettingsPage/SettingsPage.tsx +++ b/frontend/src/pages/secret-manager/SettingsPage/SettingsPage.tsx @@ -1,5 +1,7 @@ import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; +import { Link } from "@tanstack/react-router"; +import { InfoIcon } from "lucide-react"; import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; import { useProject } from "@app/context"; @@ -43,9 +45,19 @@ export const SettingsPage = () => {
+ > + + Looking for organization settings? + + {tabs diff --git a/frontend/src/pages/secret-manager/SettingsPage/components/EnvironmentSection/AddEnvironmentModal.tsx b/frontend/src/pages/secret-manager/SettingsPage/components/EnvironmentSection/AddEnvironmentModal.tsx index 70c486616..5ff1a1535 100644 --- a/frontend/src/pages/secret-manager/SettingsPage/components/EnvironmentSection/AddEnvironmentModal.tsx +++ b/frontend/src/pages/secret-manager/SettingsPage/components/EnvironmentSection/AddEnvironmentModal.tsx @@ -1,5 +1,6 @@ import { Controller, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; +import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; @@ -31,7 +32,13 @@ type ContentProps = { const Content = ({ onComplete }: ContentProps) => { const { currentProject } = useProject(); const { mutateAsync, isPending } = useCreateWsEnvironment(); - const { control, handleSubmit } = useForm({ + const { + control, + handleSubmit, + setValue, + getValues, + formState: { dirtyFields } + } = useForm({ resolver: zodResolver(schema) }); @@ -52,15 +59,28 @@ const Content = ({ onComplete }: ContentProps) => { onComplete(env); }; + const handleEnvironmentNameChange = () => { + if (dirtyFields.environmentSlug) return; + + const value = getValues("environmentName"); + setValue("environmentSlug", slugify(value, { lowercase: true })); + }; + return (
( + render={({ field: { onChange, ...field }, fieldState: { error } }) => ( - + { + onChange(e); + handleEnvironmentNameChange(); + }} + /> )} /> diff --git a/frontend/src/pages/secret-scanning/SettingsPage/SettingsPage.tsx b/frontend/src/pages/secret-scanning/SettingsPage/SettingsPage.tsx index 183c6d285..c4b03d03b 100644 --- a/frontend/src/pages/secret-scanning/SettingsPage/SettingsPage.tsx +++ b/frontend/src/pages/secret-scanning/SettingsPage/SettingsPage.tsx @@ -1,9 +1,11 @@ import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; +import { Link } from "@tanstack/react-router"; +import { InfoIcon } from "lucide-react"; import { ProjectPermissionCan } from "@app/components/permissions"; import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; -import { ProjectPermissionSub } from "@app/context"; +import { ProjectPermissionSub, useOrganization } from "@app/context"; import { ProjectPermissionSecretScanningConfigActions } from "@app/context/ProjectPermissionContext/types"; import { ProjectType } from "@app/hooks/api/projects/types"; import { ProjectGeneralTab } from "@app/pages/project/SettingsPage/components/ProjectGeneralTab"; @@ -12,6 +14,7 @@ import { ProjectScanningConfigTab } from "./components/ProjectScanningConfigTab" export const SettingsPage = () => { const { t } = useTranslation(); + const { currentOrg } = useOrganization(); return (
@@ -21,9 +24,19 @@ export const SettingsPage = () => {
+ > + + Looking for organization settings? + + diff --git a/frontend/src/pages/ssh/SettingsPage/SettingsPage.tsx b/frontend/src/pages/ssh/SettingsPage/SettingsPage.tsx index a154a8391..b01dfdcf4 100644 --- a/frontend/src/pages/ssh/SettingsPage/SettingsPage.tsx +++ b/frontend/src/pages/ssh/SettingsPage/SettingsPage.tsx @@ -1,9 +1,11 @@ import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; +import { Link } from "@tanstack/react-router"; +import { InfoIcon } from "lucide-react"; import { ProjectPermissionCan } from "@app/components/permissions"; import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; +import { ProjectPermissionActions, ProjectPermissionSub, useOrganization } from "@app/context"; import { ProjectType } from "@app/hooks/api/projects/types"; import { ProjectGeneralTab } from "@app/pages/project/SettingsPage/components/ProjectGeneralTab"; @@ -12,6 +14,8 @@ import { ProjectSshTab } from "./components/ProjectSshTab"; export const SettingsPage = () => { const { t } = useTranslation(); + const { currentOrg } = useOrganization(); + return (
@@ -20,9 +24,19 @@ export const SettingsPage = () => {
+ > + + Looking for organization settings? + + diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 618dcb618..ace4852ac 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -23,6 +23,11 @@ const virtualRouteFileChangeReloadPlugin: PluginOption = { export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd()); const allowedHosts = env.VITE_ALLOWED_HOSTS?.split(",") ?? []; + const version = ( + env.INFISICAL_PLATFORM_VERSION || + env.VITE_INFISICAL_PLATFORM_VERSION || + "0.0.1" + ).replaceAll(".", "-"); return { server: { @@ -38,6 +43,15 @@ export default defineConfig(({ mode }) => { // } // } }, + build: { + rollupOptions: { + output: { + entryFileNames: `assets/[name]-${version}-[hash].js`, + chunkFileNames: `assets/[name]-${version}-[hash].js`, + assetFileNames: `assets/[name]-${version}-[hash].[ext]` + } + } + }, plugins: [ tsconfigPaths(), nodePolyfills({