diff --git a/.infisicalignore b/.infisicalignore index b935763c8..ec1cbfe16 100644 --- a/.infisicalignore +++ b/.infisicalignore @@ -52,3 +52,6 @@ docs/integrations/app-connections/railway.mdx:generic-api-key:156 .github/workflows/validate-db-schemas.yml:generic-api-key:21 k8-operator/config/samples/universalAuthIdentitySecret.yaml:generic-api-key:8 docs/integrations/app-connections/redis.mdx:generic-api-key:80 +backend/src/ee/services/app-connections/chef/chef-connection-fns.ts:private-key:42 +docs/documentation/platform/pki/enrollment-methods/api.mdx:generic-api-key:93 +docs/documentation/platform/pki/enrollment-methods/api.mdx:private-key:139 \ No newline at end of file diff --git a/backend/bdd/.env.example b/backend/bdd/.env.example new file mode 100644 index 000000000..8e59c033d --- /dev/null +++ b/backend/bdd/.env.example @@ -0,0 +1,14 @@ +# API URL to the Infisical server +INFISICAL_API_URL="http://localhost:8080" +# JWT token with admin permission for the cert projects +INFISICAL_TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdXRoTWV0aG9kIjoiZW1haWwiLCJhdXRoVG9rZW5UeXBlIjoiYWNjZXNzVG9rZW4iLCJ1c2VySWQiOiJkOWZlMzMwZi00OTQwLTQ3ZmYtYmE4Yy0zZGUxYTVlYjYzNGEiLCJ0b2tlblZlcnNpb25JZCI6ImM4MWZhODY1LTFjYTAtNGNmZS1iNjM5LThlMDI3M2E2N2JjYyIsImFjY2Vzc1ZlcnNpb24iOjEsIm9yZ2FuaXphdGlvbklkIjoiM2I5OTRkNTktMjE5Ny00MjcwLWE3MGMtOTczMzdmZjlmYTRkIiwiaWF0IjoxNzYyMjAzMjQwLCJleHAiOjE3NjMwNjcyNDB9.KFYeMYAv3Ceis0hp-pTa8fsLLWbT-JcqhuWyIY0DWU0" +# PKI project id +PROJECT_ID="c051e74c-48a7-4724-832c-d5b496698546" +# Certificate CA id +CERT_CA_ID="2f0d9820-e5a8-48bb-aac8-deed9d868a1e" +# Certificate template id +CERT_TEMPLATE_ID="4dbf6bb0-6e86-4ee6-8550-9171428c8e82" +# ACME profile ID +PROFILE_ID="108c6303-ab8c-4986-ab88-eefe11bb5553" +# ACME profile EAB secret +EAB_SECRET="JHYxJDEwJFJldE9tb3dkUU9XVnJLZWFia3IxVC94L1pIbHRoQnJsNVRKZWFoV1hpNTczVHpwMFNGZzU4OGtuU3NVK1crVGM" diff --git a/backend/bdd/.python-version b/backend/bdd/.python-version new file mode 100644 index 000000000..e4fba2183 --- /dev/null +++ b/backend/bdd/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/backend/bdd/README.md b/backend/bdd/README.md new file mode 100644 index 000000000..e69de29bb diff --git a/backend/bdd/features/environment.py b/backend/bdd/features/environment.py new file mode 100644 index 000000000..b355f98ae --- /dev/null +++ b/backend/bdd/features/environment.py @@ -0,0 +1,26 @@ +import os + +import httpx +from behave.runner import Context +from dotenv import load_dotenv + +load_dotenv() + +BASE_URL = os.environ.get("INFISICAL_API_URL", "http://localhost:8080") +PROJECT_ID = os.environ.get("PROJECT_ID") +CERT_CA_ID = os.environ.get("CERT_CA_ID") +CERT_TEMPLATE_ID = os.environ.get("CERT_TEMPLATE_ID") +AUTH_TOKEN = os.environ.get("INFISICAL_TOKEN") + + +def before_all(context: Context): + context.vars = { + "BASE_URL": BASE_URL, + "PROJECT_ID": PROJECT_ID, + "CERT_CA_ID": CERT_CA_ID, + "CERT_TEMPLATE_ID": CERT_TEMPLATE_ID, + "AUTH_TOKEN": AUTH_TOKEN, + } + context.http_client = httpx.Client( + base_url=BASE_URL, # headers={"Authorization": f"Bearer {AUTH_TOKEN}"} + ) diff --git a/backend/bdd/features/pki/acme/account.feature b/backend/bdd/features/pki/acme/account.feature new file mode 100644 index 000000000..7e1d67a93 --- /dev/null +++ b/backend/bdd/features/pki/acme/account.feature @@ -0,0 +1,6 @@ +Feature: Account + + Scenario: Create a new account + Given I have an ACME cert profile 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 diff --git a/backend/bdd/features/pki/acme/auth.feature b/backend/bdd/features/pki/acme/auth.feature new file mode 100644 index 000000000..4605e2eef --- /dev/null +++ b/backend/bdd/features/pki/acme/auth.feature @@ -0,0 +1,36 @@ +Feature: Authorization + + Scenario: Get authorization + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory +# # TODO: make it I have an account already instead? + Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account + When I create certificate signing request as csr + Then I add names to certificate signing request csr + """ + { + "COMMON_NAME": "localhost" + } + """ + Then I create a RSA private key pair as cert_key + Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format + Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order + Then the value order.authorizations[0].uri with jq "." should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/(.+) + Then the value order.authorizations[0].body with jq ".status" should be equal to "pending" + Then the value order.authorizations[0].body with jq ".challenges | map(pick(.type, .status)) | sort_by(.type)" should be equal to json + """ + [ + { + "type": "http-01", + "status": "pending" + } + ] + """ + Then the value order.authorizations[0].body with jq ".challenges | map(.status) | sort" should be equal to ["pending"] + Then the value order.authorizations[0].body with jq ".identifier" should be equal to json + """ + { + "type": "dns", + "value": "localhost" + } + """ diff --git a/backend/bdd/features/pki/acme/cert-profile.feature b/backend/bdd/features/pki/acme/cert-profile.feature new file mode 100644 index 000000000..7ce1ecc8c --- /dev/null +++ b/backend/bdd/features/pki/acme/cert-profile.feature @@ -0,0 +1,49 @@ +Feature: ACME Cert Profile + + Scenario: Create a cert profile + Given I make a random slug as profile_slug + Given I use AUTH_TOKEN for authentication + When I send a POST request to "/api/v1/pki/certificate-profiles" with JSON payload + """ + { + "projectId": "{PROJECT_ID}", + "slug": "{profile_slug}", + "description": "", + "enrollmentType": "acme", + "caId": "{CERT_CA_ID}", + "certificateTemplateId": "{CERT_TEMPLATE_ID}", + "acmeConfig": {} + } + """ + Then the value response.status_code should be equal to 200 + Then the value response with jq ".certificateProfile.id" should be present + Then the value response with jq ".certificateProfile.slug" should be equal to "{profile_slug}" + Then the value response with jq ".certificateProfile.caId" should be equal to "{CERT_CA_ID}" + Then the value response with jq ".certificateProfile.certificateTemplateId" should be equal to "{CERT_TEMPLATE_ID}" + Then the value response with jq ".certificateProfile.enrollmentType" should be equal to "acme" + + Scenario: Reveal EAB secret + Given I make a random slug as profile_slug + Given I use AUTH_TOKEN for authentication + When I send a POST request to "/api/v1/pki/certificate-profiles" with JSON payload + """ + { + "projectId": "{PROJECT_ID}", + "slug": "{profile_slug}", + "description": "", + "enrollmentType": "acme", + "caId": "{CERT_CA_ID}", + "certificateTemplateId": "{CERT_TEMPLATE_ID}", + "acmeConfig": {} + } + """ + Then the value response.status_code should be equal to 200 + And I memorize response with jq ".certificateProfile.id" as profile_id + When I send a GET request to "/api/v1/pki/certificate-profiles/{profile_id}/acme/eab-secret/reveal" + Then the value response.status_code should be equal to 200 + Then the value response with jq ".eabKid" should be equal to "{profile_id}" + Then the value response with jq ".eabSecret" should be present + And I memorize response with jq ".eabKid" as eab_kid + And I memorize response with jq ".eabSecret" as eab_secret + When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{profile_id}/directory + Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{eab_kid}" with secret "{eab_secret}" as acme_account diff --git a/backend/bdd/features/pki/acme/challenge.feature b/backend/bdd/features/pki/acme/challenge.feature new file mode 100644 index 000000000..ba9970e43 --- /dev/null +++ b/backend/bdd/features/pki/acme/challenge.feature @@ -0,0 +1,23 @@ +Feature: Challenge + + Scenario: Validate challenge + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory +# # TODO: make it I have an account already instead? + Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account + When I create certificate signing request as csr + Then I add names to certificate signing request csr + """ + { + "COMMON_NAME": "localhost" + } + """ + Then I create a RSA private key pair as cert_key + Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format + Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order + Then I select challenge with type http-01 for domain localhost from order at order as challenge + Then I serve challenge response for challenge at localhost + Then I tell ACME server that challenge is ready to be verified + Then I poll and finalize the ACME order order as finalized_order + Then the value finalized_order.body with jq ".status" should be equal to "valid" + # TODO: check the fullchain pem content of the order diff --git a/backend/bdd/features/pki/acme/dicrectory.feature b/backend/bdd/features/pki/acme/dicrectory.feature new file mode 100644 index 000000000..481a3337a --- /dev/null +++ b/backend/bdd/features/pki/acme/dicrectory.feature @@ -0,0 +1,14 @@ +Feature: Directory + + Scenario: Get the directory of ACME service urls + Given I have an ACME cert profile as "acme_profile" + When I send a GET request to "/api/v1/pki/acme/profiles/{acme_profile.id}/directory" + Then the response status code should be "200" + Then the response body should match JSON value + """ + { + "newNonce": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-nonce", + "newAccount": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-account", + "newOrder": "{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/new-order" + } + """ diff --git a/backend/bdd/features/pki/acme/nonce.feature b/backend/bdd/features/pki/acme/nonce.feature new file mode 100644 index 000000000..7bbeb3b9d --- /dev/null +++ b/backend/bdd/features/pki/acme/nonce.feature @@ -0,0 +1,7 @@ +Feature: Nonce + + Scenario: Generate a new nonce + Given I have an ACME cert profile as "acme_profile" + When I send a HEAD request to "/api/v1/pki/acme/profiles/{acme_profile.id}/new-nonce" + Then the response status code should be "200" + Then the response header "Replay-Nonce" should contains non-empty value diff --git a/backend/bdd/features/pki/acme/order.feature b/backend/bdd/features/pki/acme/order.feature new file mode 100644 index 000000000..046dcda55 --- /dev/null +++ b/backend/bdd/features/pki/acme/order.feature @@ -0,0 +1,74 @@ +Feature: Order + + Scenario: Create a new order + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory +# # TODO: make it I have an account already instead? + Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account + When I create certificate signing request as csr + Then I add names to certificate signing request csr + """ + { + "COMMON_NAME": "localhost" + } + """ + Then I create a RSA private key pair as cert_key + Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format + Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order + Then the value order.uri with jq "." should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+) + Then the value order.body with jq ".status" should be equal to "pending" + Then the value order.body with jq ".identifiers" should be equal to [{"type": "dns", "value": "localhost"}] + Then the value order.body with jq ".finalize" should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+)/finalize + Then the value order.body with jq "all(.authorizations[]; startswith("{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/"))" should be equal to true + + Scenario: Create a new order with SANs + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory +# # TODO: make it I have an account already instead? + Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account + When I create certificate signing request as csr + Then I add names to certificate signing request csr + """ + { + "COMMON_NAME": "localhost" + } + """ + Then I add subject alternative name to certificate signing request csr + """ + [ + "example.com", + "infisical.com" + ] + """ + Then I create a RSA private key pair as cert_key + Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format + Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order + Then the value order.body with jq ".identifiers | sort_by(.value)" should be equal to json + """ + [ + {"type": "dns", "value": "example.com"}, + {"type": "dns", "value": "infisical.com"}, + {"type": "dns", "value": "localhost"} + ] + """ + + Scenario: Fetch an order + Given I have an ACME cert profile as "acme_profile" + When I have an ACME client connecting to {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/directory +# # TODO: make it I have an account already instead? + Then I register a new ACME account with email fangpen@infisical.com and EAB key id "{acme_profile.eab_kid}" with secret "{acme_profile.eab_secret}" as acme_account + When I create certificate signing request as csr + Then I add names to certificate signing request csr + """ + { + "COMMON_NAME": "localhost" + } + """ + Then I create a RSA private key pair as cert_key + Then I sign the certificate signing request csr with private key cert_key and output it as csr_pem in PEM format + Then I submit the certificate signing request PEM csr_pem certificate order to the ACME server as order + Then I send an ACME post-as-get to order.uri as fetched_order + Then the value fetched_order with jq ".status" should be equal to "pending" + Then the value fetched_order with jq ".identifiers" should be equal to [{"type": "dns", "value": "localhost"}] + Then the value fetched_order with jq ".finalize" should match pattern {BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/orders/(.+)/finalize + Then the value fetched_order with jq "all(.authorizations[]; startswith("{BASE_URL}/api/v1/pki/acme/profiles/{acme_profile.id}/authorizations/"))" should be equal to true diff --git a/backend/bdd/features/steps/pki_acme.py b/backend/bdd/features/steps/pki_acme.py new file mode 100644 index 000000000..d0fa627cd --- /dev/null +++ b/backend/bdd/features/steps/pki_acme.py @@ -0,0 +1,486 @@ +import json +import logging +import os +import re +import threading + +import httpx +import jq +import requests +import glom +from faker import Faker +from acme import client +from acme import messages +from acme import standalone +from behave.runner import Context +from behave import given +from behave import when +from behave import then +from josepy.jwk import JWKRSA +from josepy import JSONObjectWithFields +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography import x509 +from cryptography.x509.oid import NameOID +from cryptography.hazmat.primitives import hashes + +ACC_KEY_BITS = 2048 +ACC_KEY_PUBLIC_EXPONENT = 65537 +logger = logging.getLogger(__name__) +faker = Faker() + + +class AcmeProfile: + def __init__(self, id: str, eab_kid: str, eab_secret: str): + self.id = id + self.eab_kid = eab_kid + self.eab_secret = eab_secret + + +def replace_vars(payload: dict | list | int | float | str, vars: dict): + if isinstance(payload, dict): + return { + replace_vars(key, vars): replace_vars(value, vars) + for key, value in payload.items() + } + elif isinstance(payload, list): + return [replace_vars(item, vars) for item in payload] + elif isinstance(payload, str): + return payload.format(**vars) + else: + return payload + + +def parse_glom_path(path_str: str) -> glom.Path: + """ + Parse a glom path string with 'attr[index]' syntax into a Path object. + + Examples: + >>> parse_glom_path('authorizations[0]') == Path('authorizations', 0) + True + >>> parse_glom_path('data.items[1].name') == Path('data', 'items', 1, 'name') + True + >>> parse_glom_path('user.addresses[0].street') == Path('user', 'addresses', 0, 'street') + True + """ + parts = [] + + # Split by dots, but preserve bracketed content + tokens = re.split(r"(? dict | None: + headers = {} + auth_token = getattr(context, "auth_token", None) + if auth_token is not None: + headers["authorization"] = "Bearer {}".format(auth_token) + if not headers: + return None + return headers + + +@given("I make a random {faker_type} as {var_name}") +def step_impl(context: Context, faker_type: str, var_name: str): + context.vars[var_name] = getattr(faker, faker_type)() + + +@given('I have an ACME cert profile as "{profile_var}"') +def step_impl(context: Context, profile_var: str): + # TODO: Fixed value for now, just to make test much easier, + # we should call infisical API to create such profile instead + # in the future + profile_id = os.getenv("PROFILE_ID") + kid = profile_id + secret = os.getenv("EAB_SECRET") + context.vars[profile_var] = AcmeProfile( + profile_id, + eab_kid=kid, + eab_secret=secret, + ) + + +@given("I use {token_var} for authentication") +def step_impl(context: Context, token_var: str): + context.auth_token = eval_var(context, token_var) + + +@when('I send a {method} request to "{url}"') +def step_impl(context: Context, method: str, url: str): + logger.debug("Sending %s request to %s", method, url) + response = context.http_client.request( + method, url.format(**context.vars), headers=prepare_headers(context) + ) + context.vars["response"] = response + logger.debug("Response status: %r", response.status_code) + try: + logger.debug("Response JSON payload: %r", response.json()) + except json.decoder.JSONDecodeError: + pass + + +@when('I send a {method} request to "{url}" with JSON payload') +def step_impl(context: Context, method: str, url: str): + json_payload = json.loads(context.text) + json_payload = replace_vars(json_payload, context.vars) + logger.debug( + "Sending %s request to %s with JSON payload: %s", + method, + url, + json.dumps(json_payload), + ) + response = context.http_client.request( + method, + url.format(**context.vars), + headers=prepare_headers(context), + json=json_payload, + ) + context.vars["response"] = response + logger.debug("Response status: %r", response.status_code) + logger.debug("Response JSON payload: %r", response.json()) + + +@when("I have an ACME client connecting to {url}") +def step_impl(context: Context, url: str): + private_key = rsa.generate_private_key( + public_exponent=ACC_KEY_PUBLIC_EXPONENT, key_size=ACC_KEY_BITS + ) + pem_bytes = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + acc_jwk = JWKRSA.load(pem_bytes) + net = client.ClientNetwork(acc_jwk) + directory_url = url.format(**context.vars) + directory = client.ClientV2.get_directory(directory_url, net) + context.acme_client = client.ClientV2(directory, net=net) + + +@then('the response status code should be "{expected_status_code:d}"') +def step_impl(context: Context, expected_status_code: int): + assert context.vars["response"].status_code == expected_status_code, ( + f"{context.vars['response'].status_code} != {expected_status_code}" + ) + + +@then('the response header "{header}" should contains non-empty value') +def step_impl(context: Context, header: str): + header_value = context.vars["response"].headers.get(header) + assert header_value is not None, f"Header {header} not found in response" + assert header_value, ( + f"Header {header} found in response, but value {header_value:!r} is empty" + ) + + +@then("the response body should match JSON value") +def step_impl(context: Context): + payload = context.vars["response"].json() + expected = json.loads(context.text) + replaced = replace_vars(expected, context.vars) + assert payload == replaced, f"{payload} != {replaced}" + + +@then( + 'I register a new ACME account with email {email} and EAB key id "{kid}" with secret "{secret}" as {account_var}' +) +def step_impl(context: Context, email: str, kid: str, secret: str, account_var: str): + acme_client = context.acme_client + account_public_key = acme_client.net.key.public_key() + eab = messages.ExternalAccountBinding.from_data( + account_public_key=account_public_key, + kid=replace_vars(kid, context.vars), + hmac_key=replace_vars(secret, context.vars), + directory=acme_client.directory, + hmac_alg="HS256", + ) + registration = messages.NewRegistration.from_data( + email=email, + external_account_binding=eab, + ) + context.vars[account_var] = acme_client.new_account(registration) + + +@then( + "I submit the certificate signing request PEM {pem_var} certificate order to the ACME server as {order_var}" +) +def step_impl(context: Context, pem_var: str, order_var: str): + context.vars[order_var] = context.acme_client.new_order(context.vars[pem_var]) + + +@then("I send an ACME post-as-get to {uri_path} as {res_var}") +def step_impl(context: Context, uri_path: str, res_var: str): + uri_value = eval_var(context, uri_path) + context.vars[res_var] = context.acme_client._post_as_get(uri_value) + + +@when("I create certificate signing request as {csr_var}") +def step_impl(context: Context, csr_var: str): + context.vars[csr_var] = x509.CertificateSigningRequestBuilder() + + +@then("I add names to certificate signing request {csr_var}") +def step_impl(context: Context, csr_var: str): + names = json.loads(context.text) + builder: x509.CertificateSigningRequestBuilder = context.vars[csr_var] + context.vars[csr_var] = builder.subject_name( + x509.Name( + [ + x509.NameAttribute(getattr(NameOID, name), value) + for name, value in names.items() + ] + ) + ) + + +@then("I add subject alternative name to certificate signing request {csr_var}") +def step_impl(context: Context, csr_var: str): + names = json.loads(context.text) + builder: x509.CertificateSigningRequestBuilder = context.vars[csr_var] + context.vars[csr_var] = builder.add_extension( + x509.SubjectAlternativeName([x509.DNSName(name) for name in names]), + critical=False, + ) + + +@then("I create a RSA private key pair as {rsa_key_var}") +def step_impl(context: Context, rsa_key_var: str): + context.vars[rsa_key_var] = rsa.generate_private_key( + # TODO: make them configurable if we need to + public_exponent=65537, + key_size=2048, + ) + + +@then( + "I sign the certificate signing request {csr_var} with private key {pk_var} and output it as {pem_var} in PEM format" +) +def step_impl(context: Context, csr_var: str, pk_var: str, pem_var: str): + context.vars[pem_var] = ( + context.vars[csr_var] + .sign(context.vars[pk_var], hashes.SHA256()) + .public_bytes(serialization.Encoding.PEM) + ) + + +@then("the value {var_path} should be true for jq {query}") +def step_impl(context: Context, var_path: str, query: str): + value = eval_var(context, var_path) + result = jq.compile(replace_vars(query, context.vars)).input_value(value).first() + assert result, f"{value} does not match {query}" + + +def apply_value_with_jq(context: Context, var_path: str, jq_query: str): + value = eval_var(context, var_path) + return value, jq.compile(replace_vars(jq_query, context.vars)).input_value( + value + ).first() + + +@then('the value {var_path} with jq "{jq_query}" should be equal to json') +def step_impl(context: Context, var_path: str, jq_query: str): + value, result = apply_value_with_jq( + context=context, + var_path=var_path, + jq_query=jq_query, + ) + expected_value = json.loads(context.text) + assert result == expected_value, ( + f"{json.dumps(value)!r} with jq {jq_query!r}, the result {json.dumps(result)!r} does not match {json.dumps(expected_value)!r}" + ) + + +@then('the value {var_path} with jq "{jq_query}" should be present') +def step_impl(context: Context, var_path: str, jq_query: str): + value, result = apply_value_with_jq( + context=context, + var_path=var_path, + jq_query=jq_query, + ) + assert result, ( + f"{json.dumps(value)!r} with jq {jq_query!r}, the result {json.dumps(result)!r} is not present" + ) + + +@then('the value {var_path} with jq "{jq_query}" should be equal to {expected}') +def step_impl(context: Context, var_path: str, jq_query: str, expected: str): + value, result = apply_value_with_jq( + context=context, + var_path=var_path, + jq_query=jq_query, + ) + expected_value = replace_vars(json.loads(expected), context.vars) + assert result == expected_value, ( + f"{json.dumps(value)!r} with jq {jq_query!r}, the result {json.dumps(result)!r} does not match {json.dumps(expected_value)!r}" + ) + + +@then('the value {var_path} with jq "{jq_query}" should match pattern {regex}') +def step_impl(context: Context, var_path: str, jq_query: str, regex: str): + value, result = apply_value_with_jq( + context=context, + var_path=var_path, + jq_query=jq_query, + ) + assert re.match(replace_vars(regex, context.vars), result), ( + f"{json.dumps(value)!r} with jq {jq_query!r}, the result {json.dumps(result)!r} does not match {regex!r}" + ) + + +@then("the value {var_path} should be equal to json") +def step_impl(context: Context, var_path: str): + value = eval_var(context, var_path) + expected_value = json.loads(context.text) + assert value == expected_value, f"{value!r} does not match {expected_value!r}" + + +@then("the value {var_path} should be equal to {expected}") +def step_impl(context: Context, var_path: str, expected: str): + value = eval_var(context, var_path) + expected_value = json.loads(expected) + assert value == expected_value, f"{value!r} does not match {expected_value!r}" + + +@then('I memorize {var_path} with jq "{jq_query}" as {var_name}') +def step_impl(context: Context, var_path: str, jq_query, var_name: str): + _, value = apply_value_with_jq( + context=context, + var_path=var_path, + jq_query=jq_query, + ) + context.vars[var_name] = value + + +@then("I memorize {var_path} as {var_name}") +def step_impl(context: Context, var_path: str, var_name: str): + value = eval_var(context, var_path) + context.vars[var_name] = value + + +@then("I print the value {var_path}") +def step_impl(context: Context, var_path: str): + value = eval_var(context, var_path) + print(json.dumps(value.json(), indent=2)) + + +@then( + "I select challenge with type {challenge_type} for domain {domain} from order at {var_path} as {challenge_var}" +) +def step_impl( + context: Context, + challenge_type: str, + domain: str, + var_path: str, + challenge_var: str, +): + order = eval_var(context, var_path, as_json=False) + if not isinstance(order, messages.OrderResource): + raise ValueError( + f"Expected OrderResource but got {type(order)!r} at {var_path!r}" + ) + auths = list( + filter(lambda o: o.body.identifier.value == domain, order.authorizations) + ) + if not auths: + raise ValueError( + f"Authorization for domain {domain!r} not found in {var_path!r}" + ) + if len(auths) > 1: + raise ValueError( + f"More than one order for domain {domain!r} found in {var_path!r}" + ) + auth = auths[0] + + challenges = list(filter(lambda a: a.typ == challenge_type, auth.body.challenges)) + if not challenges: + raise ValueError( + f"Authorization type {challenge_type!r} not found in {var_path!r}" + ) + if len(challenges) > 1: + raise ValueError( + f"More than one authorization for type {challenge_type!r} found in {var_path!r}" + ) + context.vars[challenge_var] = challenges[0] + + +@then("I serve challenge response for {var_path} at {hostname}") +def step_impl(context: Context, var_path: str, hostname: str): + if hostname != "localhost": + raise ValueError("Currently only localhost is supported") + challenge = eval_var(context, var_path, as_json=False) + response, validation = challenge.response_and_validation( + context.acme_client.net.key + ) + resource = standalone.HTTP01RequestHandler.HTTP01Resource( + chall=challenge.chall, response=response, validation=validation + ) + # TODO: make port configurable + servers = standalone.HTTP01DualNetworkedServers(("0.0.0.0", 8087), {resource}) + # Start client standalone web server. + web_server = threading.Thread(name="web_server", target=servers.serve_forever) + web_server.daemon = True + web_server.start() + context.web_server = web_server + + +@then("I tell ACME server that {var_path} is ready to be verified") +def step_impl(context: Context, var_path: str): + challenge = eval_var(context, var_path, as_json=False) + acme_client = context.acme_client + response, validation = challenge.response_and_validation(acme_client.net.key) + acme_client.answer_challenge(challenge, response) + + +@then("I poll and finalize the ACME order {var_path} as {finalized_var}") +def step_impl(context: Context, var_path: str, finalized_var: str): + order = eval_var(context, var_path, as_json=False) + acme_client = context.acme_client + finalized_order = acme_client.poll_and_finalize(order) + context.vars[finalized_var] = finalized_order diff --git a/backend/bdd/pyproject.toml b/backend/bdd/pyproject.toml new file mode 100644 index 000000000..4f19cb0de --- /dev/null +++ b/backend/bdd/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "bdd" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "acme>=5.1.0", + "behave>=1.3.3", + "dotenv>=0.9.9", + "faker>=37.12.0", + "glom>=24.11.0", + "httpx>=0.28.1", + "josepy>=2.2.0", + "jq>=1.10.0", +] diff --git a/backend/bdd/uv.lock b/backend/bdd/uv.lock new file mode 100644 index 000000000..8ae5b6c01 --- /dev/null +++ b/backend/bdd/uv.lock @@ -0,0 +1,558 @@ +version = 1 +revision = 2 +requires-python = ">=3.12" + +[[package]] +name = "acme" +version = "5.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "josepy" }, + { name = "pyopenssl" }, + { name = "pyrfc3339" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/f6/897be0abeb0e64f0e6136a8a6369a54d2a603a44cb7a411f6d77dbafb4ac/acme-5.1.0.tar.gz", hash = "sha256:7b97820857d9baffed98bca50ab82bb6a636e447865d7a013a7bdd7972f03cda", size = 89982, upload-time = "2025-10-07T17:30:38.579Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/0b/4d0421412bb063f4393ae7ebf3a9a6fde621aed187a1140ccf7f9e22b823/acme-5.1.0-py3-none-any.whl", hash = "sha256:80e9c315d82302bb97279f4516ff31230d29195ab9d4a6c9411ceec20481b61e", size = 94151, upload-time = "2025-10-07T17:30:15.994Z" }, +] + +[[package]] +name = "anyio" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" }, +] + +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + +[[package]] +name = "bdd" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "acme" }, + { name = "behave" }, + { name = "dotenv" }, + { name = "faker" }, + { name = "glom" }, + { name = "httpx" }, + { name = "josepy" }, + { name = "jq" }, +] + +[package.metadata] +requires-dist = [ + { name = "acme", specifier = ">=5.1.0" }, + { name = "behave", specifier = ">=1.3.3" }, + { name = "dotenv", specifier = ">=0.9.9" }, + { name = "faker", specifier = ">=37.12.0" }, + { name = "glom", specifier = ">=24.11.0" }, + { name = "httpx", specifier = ">=0.28.1" }, + { name = "josepy", specifier = ">=2.2.0" }, + { name = "jq", specifier = ">=1.10.0" }, +] + +[[package]] +name = "behave" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama" }, + { name = "cucumber-expressions" }, + { name = "cucumber-tag-expressions" }, + { name = "parse" }, + { name = "parse-type" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/51/f37442fe648b3e35ecf69bee803fa6db3f74c5b46d6c882d0bc5654185a2/behave-1.3.3.tar.gz", hash = "sha256:2b8f4b64ed2ea756a5a2a73e23defc1c4631e9e724c499e46661778453ebaf51", size = 892639, upload-time = "2025-09-04T12:12:02.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/71/06f74ffed6d74525c5cd6677c97bd2df0b7649e47a249cf6a0c2038083b2/behave-1.3.3-py2.py3-none-any.whl", hash = "sha256:89bdb62af8fb9f147ce245736a5de69f025e5edfb66f1fbe16c5007493f842c0", size = 223594, upload-time = "2025-09-04T12:12:00.3Z" }, +] + +[[package]] +name = "boltons" +version = "25.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/54/71a94d8e02da9a865587fb3fff100cb0fc7aa9f4d5ed9ed3a591216ddcc7/boltons-25.0.0.tar.gz", hash = "sha256:e110fbdc30b7b9868cb604e3f71d4722dd8f4dcb4a5ddd06028ba8f1ab0b5ace", size = 246294, upload-time = "2025-02-03T05:57:59.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/7f/0e961cf3908bc4c1c3e027de2794f867c6c89fb4916fc7dba295a0e80a2d/boltons-25.0.0-py3-none-any.whl", hash = "sha256:dc9fb38bf28985715497d1b54d00b62ea866eca3938938ea9043e254a3a6ca62", size = 194210, upload-time = "2025-02-03T05:57:56.705Z" }, +] + +[[package]] +name = "certifi" +version = "2025.10.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "46.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" }, + { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" }, + { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, + { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, + { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, + { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, + { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, + { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, + { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, + { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" }, + { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" }, + { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" }, + { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" }, + { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" }, + { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" }, + { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" }, + { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" }, + { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" }, + { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" }, + { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" }, + { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, + { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, + { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, + { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, + { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, + { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, + { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" }, + { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, +] + +[[package]] +name = "cucumber-expressions" +version = "18.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/7d/f4e231167b23b3d7348aa1c90117ce8854fae186d6984ad66d705df24061/cucumber_expressions-18.0.1.tar.gz", hash = "sha256:86ce41bf28ee520408416f38022e5a083d815edf04a0bd1dae46d474ca597c60", size = 22232, upload-time = "2024-10-28T11:38:48.672Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/e0/31ce90dad5234c3d52432bfce7562aa11cda4848aea90936a4be6c67d7ab/cucumber_expressions-18.0.1-py3-none-any.whl", hash = "sha256:86230d503cdda7ef35a1f2072a882d7d57c740aa4c163c82b07f039b6bc60c42", size = 20211, upload-time = "2024-10-28T11:38:47.101Z" }, +] + +[[package]] +name = "cucumber-tag-expressions" +version = "8.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/77/b8868653e9c7d432433d4d4d5e99d5923b309b89c8b08bc7f0cb5657ba0b/cucumber_tag_expressions-8.0.0.tar.gz", hash = "sha256:4af80282ff0349918c332428176089094019af6e2a381a2fd8f1c62a7a6bb7e8", size = 8427, upload-time = "2025-10-14T17:01:27.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/51/51ae3ab3b8553ec61f6558e9a0a9e8c500a9db844f9cf00a732b19c9a6ea/cucumber_tag_expressions-8.0.0-py3-none-any.whl", hash = "sha256:bfe552226f62a4462ee91c9643582f524af84ac84952643fb09057580cbb110a", size = 9726, upload-time = "2025-10-14T17:01:26.098Z" }, +] + +[[package]] +name = "dotenv" +version = "0.9.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dotenv" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892, upload-time = "2025-02-19T22:15:01.647Z" }, +] + +[[package]] +name = "face" +version = "24.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "boltons" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/79/2484075a8549cd64beae697a8f664dee69a5ccf3a7439ee40c8f93c1978a/face-24.0.0.tar.gz", hash = "sha256:611e29a01ac5970f0077f9c577e746d48c082588b411b33a0dd55c4d872949f6", size = 62732, upload-time = "2024-11-02T05:24:26.095Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/47/21867c2e5fd006c8d36a560df9e32cb4f1f566b20c5dd41f5f8a2124f7de/face-24.0.0-py3-none-any.whl", hash = "sha256:0e2c17b426fa4639a4e77d1de9580f74a98f4869ba4c7c8c175b810611622cd3", size = 54742, upload-time = "2024-11-02T05:24:24.939Z" }, +] + +[[package]] +name = "faker" +version = "37.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/84/e95acaa848b855e15c83331d0401ee5f84b2f60889255c2e055cb4fb6bdf/faker-37.12.0.tar.gz", hash = "sha256:7505e59a7e02fa9010f06c3e1e92f8250d4cfbb30632296140c2d6dbef09b0fa", size = 1935741, upload-time = "2025-10-24T15:19:58.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/98/2c050dec90e295a524c9b65c4cb9e7c302386a296b2938710448cbd267d5/faker-37.12.0-py3-none-any.whl", hash = "sha256:afe7ccc038da92f2fbae30d8e16d19d91e92e242f8401ce9caf44de892bab4c4", size = 1975461, upload-time = "2025-10-24T15:19:55.739Z" }, +] + +[[package]] +name = "glom" +version = "24.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "boltons" }, + { name = "face" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/89/b57cfbc448189426f2e01b244fbe9226b059ef5423a9d49c1d335a1f1026/glom-24.11.0.tar.gz", hash = "sha256:4325f96759a912044af7b6c6bd0dba44ad8c1eb6038aab057329661d2021bb27", size = 195120, upload-time = "2024-11-02T23:17:50.405Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/a2/75fd80784ec33da8d39cf885e8811a4fbc045a90db5e336b8e345e66dbb2/glom-24.11.0-py3-none-any.whl", hash = "sha256:991db7fcb4bfa9687010aa519b7b541bbe21111e70e58fdd2d7e34bbaa2c1fbd", size = 102690, upload-time = "2024-11-02T23:17:46.468Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "josepy" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/ad/6f520aee9cc9618d33430380741e9ef859b2c560b1e7915e755c084f6bc0/josepy-2.2.0.tar.gz", hash = "sha256:74c033151337c854f83efe5305a291686cef723b4b970c43cfe7270cf4a677a9", size = 56500, upload-time = "2025-10-14T14:54:42.108Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/b2/b5caed897fbb1cc286c62c01feca977e08d99a17230ff3055b9a98eccf1d/josepy-2.2.0-py3-none-any.whl", hash = "sha256:63e9dd116d4078778c25ca88f880cc5d95f1cab0099bebe3a34c2e299f65d10b", size = 29211, upload-time = "2025-10-14T14:54:41.144Z" }, +] + +[[package]] +name = "jq" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/86/6935afb6c1789d4c6ba5343607e2d2f473069eaac29fac555dbbd154c2d7/jq-1.10.0.tar.gz", hash = "sha256:fc38803075dbf1867e1b4ed268fef501feecb0c50f3555985a500faedfa70f08", size = 2031308, upload-time = "2025-07-14T18:54:53.679Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/d9/b9e2b7004a2cb646507c082ea5e975ac37e6265353ec4c24779a1701c54a/jq-1.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe636cfa95b7027e7b43da83ecfd61431c0de80c3e0aa4946534b087149dcb4c", size = 420103, upload-time = "2025-07-14T18:52:39.016Z" }, + { url = "https://files.pythonhosted.org/packages/75/ad/d6780c218040789ed3ddbfa3b1743aaf824f80be5ebd7d5f885224c5bb08/jq-1.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:947fc7e1baaa7e95833b950e5a66b3e13a5cff028bff2d009b8c320124d9e69b", size = 426325, upload-time = "2025-07-14T18:52:40.654Z" }, + { url = "https://files.pythonhosted.org/packages/e9/42/5cfc8de34e976112e1b835a83264c7a0bab2cf8f20dc703f1257aa9e07ea/jq-1.10.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9382f85a347623afa521c43f8f09439e68906fd5b3492016f969a29219796bb9", size = 738212, upload-time = "2025-07-14T18:52:42.637Z" }, + { url = "https://files.pythonhosted.org/packages/84/0a/eff78a2329967bda38a98580c6fb77c59696b2b7d589e97db232ca42f5c4/jq-1.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c376aab525d0a1debe403d3bc2f19fda9473696a1eda56bafc88248fc4ae6e7e", size = 757068, upload-time = "2025-07-14T18:52:44.709Z" }, + { url = "https://files.pythonhosted.org/packages/f3/62/353d4c0a9f363ccb2a9b5ea205f079a4ee43642622c25250d95c0fafb7ca/jq-1.10.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:206f230c67a46776f848858c66b9c377a8e40c2b16195552edd96fd7b45f9a52", size = 744259, upload-time = "2025-07-14T18:52:47.308Z" }, + { url = "https://files.pythonhosted.org/packages/4f/46/0faead425cc3a720c7cd999146f4b5f50aaf394800457efb27746c10832c/jq-1.10.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:06986456ebc95ccb9e9c2a1f0e842bc9d441225a554a9f9d4370ad95a19ac000", size = 740075, upload-time = "2025-07-14T18:52:50.038Z" }, + { url = "https://files.pythonhosted.org/packages/10/0c/8e0823c5a329d735cff9f3746e0f7d74e7eea4ed9b0e75f90f942d1c455a/jq-1.10.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d02c0be958ddb4d9254ff251b045df2f8ee5995137702eeab4ffa81158bcdbe0", size = 766475, upload-time = "2025-07-14T18:52:53.047Z" }, + { url = "https://files.pythonhosted.org/packages/06/0c/9b5aae9081fe6620915aa0e0ca76fd016e5b9d399b80c8615852413f4404/jq-1.10.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:37cf6fd2ebd2453e75ceef207d5a95a39fcbda371a9b8916db0bd42e8737a621", size = 770416, upload-time = "2025-07-14T18:52:55.858Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e7/8f4e1cc3102de31d71e6298bcbdb15d1439e2bc466f4dcf18bc3694ba61d/jq-1.10.0-cp312-cp312-win32.whl", hash = "sha256:655d75d54a343944a9b011f568156cdc29ae0b35d2fdeefb001f459a4e4fc313", size = 410113, upload-time = "2025-07-14T18:52:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/6efe0a2b69910643b80d7da39fbded8225749dee4b79ebe23d522109a310/jq-1.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:1d67c2653ae41eab48f8888c213c9e1807b43167f26ac623c9f3e00989d3edee", size = 422316, upload-time = "2025-07-14T18:52:59.605Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fe/eeede83103e90e8f5fd9b610514a4c714957d6575e03987ebeb77aafeafa/jq-1.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b11d6e115ebad15d738d49932c3a8b9bb302b928e0fb79acc80987598d147a43", size = 419325, upload-time = "2025-07-14T18:53:01.854Z" }, + { url = "https://files.pythonhosted.org/packages/09/12/8b39293715d7721b2999facd4a05ca3328fe4a68cf1c094667789867aac1/jq-1.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df278904c5727dfe5bc678131a0636d731cd944879d890adf2fc6de35214b19b", size = 425344, upload-time = "2025-07-14T18:53:03.528Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f4/ace0c853d4462f1d28798d5696619d2fb68c8e1db228ef5517365a0f3c1c/jq-1.10.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab4c1ec69fd7719fb1356e2ade7bd2b5a63d6f0eaf5a90fdc5c9f6145f0474ce", size = 735874, upload-time = "2025-07-14T18:53:05.406Z" }, + { url = "https://files.pythonhosted.org/packages/2a/b0/7882035062771686bd7e62db019fa0900fd9a3720b7ad8f7af65ee628484/jq-1.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd24dc21c8afcbe5aa812878251cfafa6f1dc6e1126c35d460cc7e67eb331018", size = 754355, upload-time = "2025-07-14T18:53:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/df/7d/b759a764c5d05c6829e95733a8b26f7e9b14df245ec2a325c0de049393ca/jq-1.10.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8c0d3e89cd239c340c3a54e145ddf52fe63de31866cb73368d22a66bfe7e823f", size = 742546, upload-time = "2025-07-14T18:53:11.756Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6b/483ddb82939d4f2f9b0486887666c67a966434cc8bc72acd851fc8063f50/jq-1.10.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76710b280e4c464395c3d8e656b849e2704bd06e950a4ebd767860572bbf67df", size = 738777, upload-time = "2025-07-14T18:53:14.856Z" }, + { url = "https://files.pythonhosted.org/packages/0c/72/4d0fc965a8e57f55291763bb236a5aee91430f97c844ee328667b34af19e/jq-1.10.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b11a56f1fb6e2985fd3627dbd8a0637f62b1a704f7b19705733d461dafa26429", size = 765307, upload-time = "2025-07-14T18:53:17.611Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a6/aca82622d8d20ea02bbcac8aaa92daaadd55a18c2a3ca54b2e63d98336d2/jq-1.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ac05ae44d9aa1e462329e1510e0b5139ac4446de650c7bdfdab226aafdc978ec", size = 769830, upload-time = "2025-07-14T18:53:19.937Z" }, + { url = "https://files.pythonhosted.org/packages/0e/e3/a19aeada32dde0839e3a4d77f2f0d63f2764c579b57f405ff4b91a58a8db/jq-1.10.0-cp313-cp313-win32.whl", hash = "sha256:0bad90f5734e2fc9d09c4116ae9102c357a4d75efa60a85758b0ba633774eddb", size = 410285, upload-time = "2025-07-14T18:53:21.631Z" }, + { url = "https://files.pythonhosted.org/packages/d6/32/df4eb81cf371654d91b6779d3f0005e86519977e19068638c266a9c88af7/jq-1.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:4ec3fbca80a9dfb5349cdc2531faf14dd832e1847499513cf1fc477bcf46a479", size = 423094, upload-time = "2025-07-14T18:53:23.687Z" }, +] + +[[package]] +name = "parse" +version = "1.20.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/78/d9b09ba24bb36ef8b83b71be547e118d46214735b6dfb39e4bfde0e9b9dd/parse-1.20.2.tar.gz", hash = "sha256:b41d604d16503c79d81af5165155c0b20f6c8d6c559efa66b4b695c3e5a0a0ce", size = 29391, upload-time = "2024-06-11T04:41:57.34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/31/ba45bf0b2aa7898d81cbbfac0e88c267befb59ad91a19e36e1bc5578ddb1/parse-1.20.2-py2.py3-none-any.whl", hash = "sha256:967095588cb802add9177d0c0b6133b5ba33b1ea9007ca800e526f42a85af558", size = 20126, upload-time = "2024-06-11T04:41:55.057Z" }, +] + +[[package]] +name = "parse-type" +version = "0.6.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parse" }, + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/ea/42ba6ce0abba04ab6e0b997dcb9b528a4661b62af1fe1b0d498120d5ea78/parse_type-0.6.6.tar.gz", hash = "sha256:513a3784104839770d690e04339a8b4d33439fcd5dd99f2e4580f9fc1097bfb2", size = 98012, upload-time = "2025-08-11T22:53:48.066Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/8d/eef3d8cdccc32abdd91b1286884c99b8c3a6d3b135affcc2a7a0f383bb32/parse_type-0.6.6-py2.py3-none-any.whl", hash = "sha256:3ca79bbe71e170dfccc8ec6c341edfd1c2a0fc1e5cfd18330f93af938de2348c", size = 27085, upload-time = "2025-08-11T22:53:46.396Z" }, +] + +[[package]] +name = "pycparser" +version = "2.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, +] + +[[package]] +name = "pyopenssl" +version = "25.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/be/97b83a464498a79103036bc74d1038df4a7ef0e402cfaf4d5e113fb14759/pyopenssl-25.3.0.tar.gz", hash = "sha256:c981cb0a3fd84e8602d7afc209522773b94c1c2446a3c710a75b06fe1beae329", size = 184073, upload-time = "2025-09-17T00:32:21.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/81/ef2b1dfd1862567d573a4fdbc9f969067621764fbb74338496840a1d2977/pyopenssl-25.3.0-py3-none-any.whl", hash = "sha256:1fda6fc034d5e3d179d39e59c1895c9faeaf40a79de5fc4cbbfbe0d36f4a77b6", size = 57268, upload-time = "2025-09-17T00:32:19.474Z" }, +] + +[[package]] +name = "pyrfc3339" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b4/7f/3c194647ecb80ada6937c38a162ab3edba85a8b6a58fa2919405f4de2509/pyrfc3339-2.1.0.tar.gz", hash = "sha256:c569a9714faf115cdb20b51e830e798c1f4de8dabb07f6ff25d221b5d09d8d7f", size = 12589, upload-time = "2025-08-23T16:40:31.889Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/90/0200184d2124484f918054751ef997ed6409cb05b7e8dcbf5a22da4c4748/pyrfc3339-2.1.0-py3-none-any.whl", hash = "sha256:560f3f972e339f579513fe1396974352fd575ef27caff160a38b312252fcddf3", size = 6758, upload-time = "2025-08-23T16:40:30.49Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] diff --git a/backend/package-lock.json b/backend/package-lock.json index a6cef3888..9cdaa764b 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -83,6 +83,7 @@ "ioredis": "^5.3.2", "isomorphic-dompurify": "^2.22.0", "jmespath": "^0.16.0", + "jose": "^6.1.0", "js-yaml": "^4.1.0", "jsonwebtoken": "^9.0.2", "jsrp": "^0.2.4", @@ -23236,9 +23237,10 @@ } }, "node_modules/jose": { - "version": "4.15.5", - "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.5.tgz", - "integrity": "sha512-jc7BFxgKPKi94uOvEmzlSWFFe2+vASyXaKUpdQKatWAESU2MWjDfFf0fdfc83CDKcA5QecabZeNLyfhe3yKNkg==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.0.tgz", + "integrity": "sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==", + "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" } @@ -23638,6 +23640,15 @@ } } }, + "node_modules/jwks-rsa/node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/jwks-rsa/node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -27590,6 +27601,15 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/openid-client/node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/openssl-wrapper": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/openssl-wrapper/-/openssl-wrapper-0.3.4.tgz", diff --git a/backend/package.json b/backend/package.json index 9bcd63cf1..fa4ee2f5f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -210,6 +210,7 @@ "ioredis": "^5.3.2", "isomorphic-dompurify": "^2.22.0", "jmespath": "^0.16.0", + "jose": "^6.1.0", "js-yaml": "^4.1.0", "jsonwebtoken": "^9.0.2", "jsrp": "^0.2.4", diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index a2bc332f8..d511187d0 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -32,6 +32,7 @@ import { TPamResourceServiceFactory } from "@app/ee/services/pam-resource/pam-re import { TPamSessionServiceFactory } from "@app/ee/services/pam-session/pam-session-service"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { TPitServiceFactory } from "@app/ee/services/pit/pit-service"; +import { TPkiAcmeServiceFactory } from "@app/ee/services/pki-acme/pki-acme-types"; import { TProjectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-types"; import { RateLimitConfiguration, TRateLimitServiceFactory } from "@app/ee/services/rate-limit/rate-limit-types"; import { TRelayServiceFactory } from "@app/ee/services/relay/relay-service"; @@ -101,6 +102,7 @@ import { TOfflineUsageReportServiceFactory } from "@app/services/offline-usage-r import { TOrgServiceFactory } from "@app/services/org/org-service"; import { TOrgAdminServiceFactory } from "@app/services/org-admin/org-admin-service"; import { TPkiAlertServiceFactory } from "@app/services/pki-alert/pki-alert-service"; +import { TPkiAlertV2ServiceFactory } from "@app/services/pki-alert-v2/pki-alert-v2-service"; import { TPkiCollectionServiceFactory } from "@app/services/pki-collection/pki-collection-service"; import { TPkiSubscriberServiceFactory } from "@app/services/pki-subscriber/pki-subscriber-service"; import { TPkiSyncServiceFactory } from "@app/services/pki-sync/pki-sync-service"; @@ -294,6 +296,7 @@ declare module "fastify" { certificateAuthority: TCertificateAuthorityServiceFactory; certificateAuthorityCrl: TCertificateAuthorityCrlServiceFactory; certificateEst: TCertificateEstServiceFactory; + pkiAcme: TPkiAcmeServiceFactory; certificateEstV3: TCertificateEstV3ServiceFactory; pkiCollection: TPkiCollectionServiceFactory; pkiSubscriber: TPkiSubscriberServiceFactory; @@ -353,6 +356,7 @@ declare module "fastify" { role: TRoleServiceFactory; convertor: TConvertorServiceFactory; subOrganization: TSubOrgServiceFactory; + pkiAlertV2: TPkiAlertV2ServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data // everywhere else access using service layer diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 7ff31ed99..603df5f6c 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -266,9 +266,39 @@ import { TOrgRoles, TOrgRolesInsert, TOrgRolesUpdate, + TPkiAcmeAccounts, + TPkiAcmeAccountsInsert, + TPkiAcmeAccountsUpdate, + TPkiAcmeAuths, + TPkiAcmeAuthsInsert, + TPkiAcmeAuthsUpdate, + TPkiAcmeChallenges, + TPkiAcmeChallengesInsert, + TPkiAcmeChallengesUpdate, + TPkiAcmeEnrollmentConfigs, + TPkiAcmeEnrollmentConfigsInsert, + TPkiAcmeEnrollmentConfigsUpdate, + TPkiAcmeOrderAuths, + TPkiAcmeOrderAuthsInsert, + TPkiAcmeOrderAuthsUpdate, + TPkiAcmeOrders, + TPkiAcmeOrdersInsert, + TPkiAcmeOrdersUpdate, + TPkiAlertChannels, + TPkiAlertChannelsInsert, + TPkiAlertChannelsUpdate, + TPkiAlertHistory, + TPkiAlertHistoryCertificate, + TPkiAlertHistoryCertificateInsert, + TPkiAlertHistoryCertificateUpdate, + TPkiAlertHistoryInsert, + TPkiAlertHistoryUpdate, TPkiAlerts, TPkiAlertsInsert, TPkiAlertsUpdate, + TPkiAlertsV2, + TPkiAlertsV2Insert, + TPkiAlertsV2Update, TPkiApiEnrollmentConfigs, TPkiApiEnrollmentConfigsInsert, TPkiApiEnrollmentConfigsUpdate, @@ -709,6 +739,32 @@ declare module "knex/types/tables" { TPkiApiEnrollmentConfigsInsert, TPkiApiEnrollmentConfigsUpdate >; + [TableName.PkiAcmeEnrollmentConfig]: KnexOriginal.CompositeTableType< + TPkiAcmeEnrollmentConfigs, + TPkiAcmeEnrollmentConfigsInsert, + TPkiAcmeEnrollmentConfigsUpdate + >; + [TableName.PkiAcmeAccount]: KnexOriginal.CompositeTableType< + TPkiAcmeAccounts, + TPkiAcmeAccountsInsert, + TPkiAcmeAccountsUpdate + >; + [TableName.PkiAcmeOrder]: KnexOriginal.CompositeTableType< + TPkiAcmeOrders, + TPkiAcmeOrdersInsert, + TPkiAcmeOrdersUpdate + >; + [TableName.PkiAcmeAuth]: KnexOriginal.CompositeTableType; + [TableName.PkiAcmeOrderAuth]: KnexOriginal.CompositeTableType< + TPkiAcmeOrderAuths, + TPkiAcmeOrderAuthsInsert, + TPkiAcmeOrderAuthsUpdate + >; + [TableName.PkiAcmeChallenge]: KnexOriginal.CompositeTableType< + TPkiAcmeChallenges, + TPkiAcmeChallengesInsert, + TPkiAcmeChallengesUpdate + >; [TableName.CertificateTemplateEstConfig]: KnexOriginal.CompositeTableType< TCertificateTemplateEstConfigs, TCertificateTemplateEstConfigsInsert, @@ -725,6 +781,22 @@ declare module "knex/types/tables" { TCertificateSecretsUpdate >; [TableName.PkiAlert]: KnexOriginal.CompositeTableType; + [TableName.PkiAlertsV2]: KnexOriginal.CompositeTableType; + [TableName.PkiAlertChannels]: KnexOriginal.CompositeTableType< + TPkiAlertChannels, + TPkiAlertChannelsInsert, + TPkiAlertChannelsUpdate + >; + [TableName.PkiAlertHistory]: KnexOriginal.CompositeTableType< + TPkiAlertHistory, + TPkiAlertHistoryInsert, + TPkiAlertHistoryUpdate + >; + [TableName.PkiAlertHistoryCertificate]: KnexOriginal.CompositeTableType< + TPkiAlertHistoryCertificate, + TPkiAlertHistoryCertificateInsert, + TPkiAlertHistoryCertificateUpdate + >; [TableName.PkiCollection]: KnexOriginal.CompositeTableType< TPkiCollections, TPkiCollectionsInsert, diff --git a/backend/src/db/migrations/20251024184713_feature-slack-secret-sync-error-notification.ts b/backend/src/db/migrations/20251024184713_feature-slack-secret-sync-error-notification.ts new file mode 100644 index 000000000..203333fed --- /dev/null +++ b/backend/src/db/migrations/20251024184713_feature-slack-secret-sync-error-notification.ts @@ -0,0 +1,31 @@ +import { Knex } from "knex"; + +import { TableName } from "@app/db/schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasColumn(TableName.ProjectSlackConfigs, "isSecretSyncErrorNotificationEnabled"))) { + await knex.schema.alterTable(TableName.ProjectSlackConfigs, (table) => { + table.boolean("isSecretSyncErrorNotificationEnabled").notNullable().defaultTo(false); + }); + } + + if (!(await knex.schema.hasColumn(TableName.ProjectSlackConfigs, "secretSyncErrorChannels"))) { + await knex.schema.alterTable(TableName.ProjectSlackConfigs, (table) => { + table.text("secretSyncErrorChannels").notNullable().defaultTo(""); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasColumn(TableName.ProjectSlackConfigs, "isSecretSyncErrorNotificationEnabled")) { + await knex.schema.alterTable(TableName.ProjectSlackConfigs, (table) => { + table.dropColumn("isSecretSyncErrorNotificationEnabled"); + }); + } + + if (await knex.schema.hasColumn(TableName.ProjectSlackConfigs, "secretSyncErrorChannels")) { + await knex.schema.alterTable(TableName.ProjectSlackConfigs, (table) => { + table.dropColumn("secretSyncErrorChannels"); + }); + } +} diff --git a/backend/src/db/migrations/20251103120000_add-pki-alerts-v2.ts b/backend/src/db/migrations/20251103120000_add-pki-alerts-v2.ts new file mode 100644 index 000000000..d542c2278 --- /dev/null +++ b/backend/src/db/migrations/20251103120000_add-pki-alerts-v2.ts @@ -0,0 +1,87 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.PkiAlertsV2))) { + await knex.schema.createTable(TableName.PkiAlertsV2, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.string("name").notNullable(); + t.text("description").nullable(); + t.string("eventType").notNullable(); + t.string("alertBefore").nullable(); + t.jsonb("filters").nullable(); + t.boolean("enabled").defaultTo(true); + t.string("projectId").notNullable(); + t.timestamps(true, true, true); + + t.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE"); + t.index("projectId"); + t.unique(["name", "projectId"]); + }); + } + + if (!(await knex.schema.hasTable(TableName.PkiAlertChannels))) { + await knex.schema.createTable(TableName.PkiAlertChannels, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.uuid("alertId").notNullable(); + t.string("channelType").notNullable(); + t.jsonb("config").notNullable(); + t.boolean("enabled").defaultTo(true); + t.timestamps(true, true, true); + + t.foreign("alertId").references("id").inTable(TableName.PkiAlertsV2).onDelete("CASCADE"); + t.index("alertId"); + t.index("channelType"); + t.index("enabled"); + }); + } + + if (!(await knex.schema.hasTable(TableName.PkiAlertHistory))) { + await knex.schema.createTable(TableName.PkiAlertHistory, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.uuid("alertId").notNullable(); + t.timestamp("triggeredAt").defaultTo(knex.fn.now()); + t.boolean("hasNotificationSent").defaultTo(false); + t.text("notificationError").nullable(); + t.timestamps(true, true, true); + + t.foreign("alertId").references("id").inTable(TableName.PkiAlertsV2).onDelete("CASCADE"); + t.index("alertId"); + t.index("triggeredAt"); + }); + } + + if (!(await knex.schema.hasTable(TableName.PkiAlertHistoryCertificate))) { + await knex.schema.createTable(TableName.PkiAlertHistoryCertificate, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.uuid("alertHistoryId").notNullable(); + t.uuid("certificateId").notNullable(); + t.timestamps(true, true, true); + + t.foreign("alertHistoryId").references("id").inTable(TableName.PkiAlertHistory).onDelete("CASCADE"); + t.foreign("certificateId").references("id").inTable(TableName.Certificate).onDelete("CASCADE"); + t.index("alertHistoryId"); + t.index("certificateId"); + t.unique(["alertHistoryId", "certificateId"]); + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(TableName.PkiAlertHistoryCertificate)) { + await knex.schema.dropTable(TableName.PkiAlertHistoryCertificate); + } + + if (await knex.schema.hasTable(TableName.PkiAlertHistory)) { + await knex.schema.dropTable(TableName.PkiAlertHistory); + } + + if (await knex.schema.hasTable(TableName.PkiAlertChannels)) { + await knex.schema.dropTable(TableName.PkiAlertChannels); + } + + if (await knex.schema.hasTable(TableName.PkiAlertsV2)) { + await knex.schema.dropTable(TableName.PkiAlertsV2); + } +} diff --git a/backend/src/db/migrations/20251104234547_add-pki-acme.ts b/backend/src/db/migrations/20251104234547_add-pki-acme.ts new file mode 100644 index 000000000..9dff45ccb --- /dev/null +++ b/backend/src/db/migrations/20251104234547_add-pki-acme.ts @@ -0,0 +1,247 @@ +import { Knex } from "knex"; + +import { dropConstraintIfExists } from "@app/db/migrations/utils/dropConstraintIfExists"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +// Notice: the old constraint name is "enrollmentType_check" instead of "enrollment_type_check" +// with psql, if there's no quote around an identifier, it will be lowercased. +// this may cause issues in migrations as Knex sometimes generates identifiers without quotes. +// to avoid this, we use a new constraint name that contains only lowercase letters and underscores. +const OLD_ENROLLMENT_TYPE_CHECK_CONSTRAINT = "pki_certificate_profiles_enrollmentType_check"; +const NEW_ENROLLMENT_TYPE_CHECK_CONSTRAINT = "pki_certificate_profiles_enrollment_type_check"; + +const PUBLIC_KEY_THUMBPRINT_ALG_INDEX = "pki_acme_accounts_publicKey_thumbprint_alg_index"; + +export async function up(knex: Knex): Promise { + // Create PkiAcmeEnrollmentConfig table + if (!(await knex.schema.hasTable(TableName.PkiAcmeEnrollmentConfig))) { + await knex.schema.createTable(TableName.PkiAcmeEnrollmentConfig, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.binary("encryptedEabSecret").notNullable(); + + t.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.PkiAcmeEnrollmentConfig); + } + + if (!(await knex.schema.hasColumn(TableName.PkiCertificateProfile, "acmeConfigId"))) { + await knex.schema.alterTable(TableName.PkiCertificateProfile, (t) => { + t.uuid("acmeConfigId"); + t.foreign("acmeConfigId").references("id").inTable(TableName.PkiAcmeEnrollmentConfig).onDelete("SET NULL"); + t.index("acmeConfigId"); + }); + } + + await dropConstraintIfExists(TableName.PkiCertificateProfile, OLD_ENROLLMENT_TYPE_CHECK_CONSTRAINT, knex); + if (await knex.schema.hasColumn(TableName.PkiCertificateProfile, "enrollmentType")) { + // Notice: it's okay to use `.checkIn(...).alter();` here because the constraint name is all lowercase. + await knex.schema.alterTable(TableName.PkiCertificateProfile, (t) => { + t.string("enrollmentType") + .notNullable() + .checkIn(["api", "est", "acme"], NEW_ENROLLMENT_TYPE_CHECK_CONSTRAINT) + .alter(); + }); + } + + // Create PkiAcmeAccount table + if (!(await knex.schema.hasTable(TableName.PkiAcmeAccount))) { + await knex.schema.createTable(TableName.PkiAcmeAccount, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + // Foreign key to PkiCertificateProfile + t.uuid("profileId").notNullable(); + t.foreign("profileId").references("id").inTable(TableName.PkiCertificateProfile).onDelete("CASCADE"); + + // Multi-value emails array + t.specificType("emails", "text[]").notNullable(); + + // Public key (JWK format) + t.jsonb("publicKey").notNullable(); + // Public key thumbprint + t.string("publicKeyThumbprint").notNullable(); + // The JWS algorithm used to sign the public key when creating the account, e.g. "RS256", "ES256", "PS256", etc. + t.string("alg").notNullable(); + // We may need to look up existing accounts by public key thumbprint and algorithm, so we index on both of them. + t.index(["publicKeyThumbprint", "alg"], PUBLIC_KEY_THUMBPRINT_ALG_INDEX); + + t.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.PkiAcmeAccount); + } + + // Create PkiAcmeOrder table + if (!(await knex.schema.hasTable(TableName.PkiAcmeOrder))) { + await knex.schema.createTable(TableName.PkiAcmeOrder, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + // Foreign key to PkiAcmeAccount + t.uuid("accountId").notNullable(); + t.foreign("accountId").references("id").inTable(TableName.PkiAcmeAccount).onDelete("CASCADE"); + + // Foreign key to certificate + t.uuid("certificateId").nullable(); + t.foreign("certificateId").references("id").inTable(TableName.Certificate).onDelete("CASCADE"); + + t.timestamp("notBefore").nullable(); + t.timestamp("notAfter").nullable(); + + t.timestamp("expiresAt").notNullable(); + + t.text("csr").nullable(); + t.text("error").nullable(); + // Order status + t.string("status").notNullable(); // pending, ready, processing, valid, invalid + + t.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.PkiAcmeOrder); + } + + // Create PkiAcmeAuth table + if (!(await knex.schema.hasTable(TableName.PkiAcmeAuth))) { + await knex.schema.createTable(TableName.PkiAcmeAuth, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + // Foreign key to PkiAcmeAccount + t.uuid("accountId").notNullable(); + t.foreign("accountId").references("id").inTable(TableName.PkiAcmeAccount).onDelete("CASCADE"); + + // Authorization status + t.string("status").notNullable(); // pending, valid, invalid, deactivated, expired, revoked + + // Token used to validate the authorization through ACME challenge + t.string("token").nullable(); + + // Identifier type and value + t.string("identifierType").notNullable(); // dns + t.string("identifierValue").notNullable(); // domain name + + // Expiration timestamp + t.timestamp("expiresAt").notNullable(); + + // Optional link to issued certificate + t.uuid("certificateId").nullable(); + t.foreign("certificateId").references("id").inTable(TableName.Certificate).onDelete("SET NULL"); + + t.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.PkiAcmeAuth); + } + + // Create PkiAcmeOrderAuth table + if (!(await knex.schema.hasTable(TableName.PkiAcmeOrderAuth))) { + await knex.schema.createTable(TableName.PkiAcmeOrderAuth, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + // Foreign key to PkiAcmeOrder + t.uuid("orderId").notNullable(); + t.foreign("orderId").references("id").inTable(TableName.PkiAcmeOrder).onDelete("CASCADE"); + + // Foreign key to PkiAcmeAuth + t.uuid("authId").notNullable(); + t.foreign("authId").references("id").inTable(TableName.PkiAcmeAuth).onDelete("CASCADE"); + + t.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.PkiAcmeOrderAuth); + } + + // Create PkiAcmeChallenge table + if (!(await knex.schema.hasTable(TableName.PkiAcmeChallenge))) { + await knex.schema.createTable(TableName.PkiAcmeChallenge, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + + // Foreign key to PkiAcmeAuth + t.uuid("authId").notNullable(); + t.foreign("authId").references("id").inTable(TableName.PkiAcmeAuth).onDelete("CASCADE"); + + // Challenge type + t.string("type").notNullable(); // http-01, dns-01, tls-alpn-01 + + // Challenge status + t.string("status").notNullable(); // pending, processing, valid, invalid + + // Error message when the challenge fails + t.string("error").nullable(); + + // Validation timestamp + t.timestamp("validatedAt").nullable(); + + t.timestamps(true, true, true); + }); + + await createOnUpdateTrigger(knex, TableName.PkiAcmeChallenge); + } +} + +export async function down(knex: Knex): Promise { + // Drop tables in reverse dependency order + + // Drop PkiAcmeChallenge first (depends on PkiAcmeAuth) + if (await knex.schema.hasTable(TableName.PkiAcmeChallenge)) { + await knex.schema.dropTable(TableName.PkiAcmeChallenge); + await dropOnUpdateTrigger(knex, TableName.PkiAcmeChallenge); + } + + // Drop PkiAcmeOrderAuth (depends on PkiAcmeOrder and PkiAcmeAuth) + if (await knex.schema.hasTable(TableName.PkiAcmeOrderAuth)) { + await knex.schema.dropTable(TableName.PkiAcmeOrderAuth); + await dropOnUpdateTrigger(knex, TableName.PkiAcmeOrderAuth); + } + + // Drop PkiAcmeAuth (depends on PkiAcmeAccount and Certificate) + if (await knex.schema.hasTable(TableName.PkiAcmeAuth)) { + await knex.schema.dropTable(TableName.PkiAcmeAuth); + await dropOnUpdateTrigger(knex, TableName.PkiAcmeAuth); + } + + // Drop PkiAcmeOrder (depends on PkiAcmeAccount) + if (await knex.schema.hasTable(TableName.PkiAcmeOrder)) { + await knex.schema.dropTable(TableName.PkiAcmeOrder); + await dropOnUpdateTrigger(knex, TableName.PkiAcmeOrder); + } + + // Drop PkiAcmeAccount (depends on PkiCertificateProfile) + if (await knex.schema.hasTable(TableName.PkiAcmeAccount)) { + await knex.schema.dropTable(TableName.PkiAcmeAccount); + await dropOnUpdateTrigger(knex, TableName.PkiAcmeAccount); + } + + // Change enrollmentType check constraint to allow acme + await dropConstraintIfExists(TableName.PkiCertificateProfile, NEW_ENROLLMENT_TYPE_CHECK_CONSTRAINT, knex); + if (await knex.schema.hasColumn(TableName.PkiCertificateProfile, "enrollmentType")) { + // Notice: DO NOT USE + // + // `t.string("enrollmentType").checkIn(["api", "est"], OLD_ENROLLMENT_TYPE_CHECK_CONSTRAINT).alter();` + // + // here because knex will generate a constraint name without quotes, and it will be treated as lowercased and causing problems. + await knex.raw( + `ALTER TABLE ?? + ADD CONSTRAINT ?? CHECK (?? IN ('api', 'est')); + `, + [TableName.PkiCertificateProfile, OLD_ENROLLMENT_TYPE_CHECK_CONSTRAINT, "enrollmentType"] + ); + } + + // Drop acmeConfigId column + if (await knex.schema.hasColumn(TableName.PkiCertificateProfile, "acmeConfigId")) { + await knex.schema.alterTable(TableName.PkiCertificateProfile, (t) => { + t.dropForeign(["acmeConfigId"]); + t.dropIndex("acmeConfigId"); + t.dropColumn("acmeConfigId"); + }); + } + + // Drop PkiAcmeEnrollmentConfig + if (await knex.schema.hasTable(TableName.PkiAcmeEnrollmentConfig)) { + await knex.schema.dropTable(TableName.PkiAcmeEnrollmentConfig); + await dropOnUpdateTrigger(knex, TableName.PkiAcmeEnrollmentConfig); + } +} diff --git a/backend/src/db/migrations/utils/dropConstraintIfExists.ts b/backend/src/db/migrations/utils/dropConstraintIfExists.ts index bfe487d49..93985ca76 100644 --- a/backend/src/db/migrations/utils/dropConstraintIfExists.ts +++ b/backend/src/db/migrations/utils/dropConstraintIfExists.ts @@ -3,4 +3,4 @@ import { Knex } from "knex"; import { TableName } from "@app/db/schemas"; export const dropConstraintIfExists = (tableName: TableName, constraintName: string, knex: Knex) => - knex.raw(`ALTER TABLE ${tableName} DROP CONSTRAINT IF EXISTS ${constraintName};`); + knex.raw("ALTER TABLE ?? DROP CONSTRAINT IF EXISTS ??;", [tableName, constraintName]); diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index fba195746..78dcb1980 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -92,7 +92,17 @@ export * from "./pam-accounts"; export * from "./pam-folders"; export * from "./pam-resources"; export * from "./pam-sessions"; +export * from "./pki-acme-accounts"; +export * from "./pki-acme-auths"; +export * from "./pki-acme-challenges"; +export * from "./pki-acme-enrollment-configs"; +export * from "./pki-acme-order-auths"; +export * from "./pki-acme-orders"; +export * from "./pki-alert-channels"; +export * from "./pki-alert-history"; +export * from "./pki-alert-history-certificate"; export * from "./pki-alerts"; +export * from "./pki-alerts-v2"; export * from "./pki-api-enrollment-configs"; export * from "./pki-certificate-profiles"; export * from "./pki-certificate-templates-v2"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index e10c6dcbe..444a6bd97 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -27,8 +27,13 @@ export enum TableName { PkiCertificateProfile = "pki_certificate_profiles", PkiEstEnrollmentConfig = "pki_est_enrollment_configs", PkiApiEnrollmentConfig = "pki_api_enrollment_configs", + PkiAcmeEnrollmentConfig = "pki_acme_enrollment_configs", PkiSubscriber = "pki_subscribers", PkiAlert = "pki_alerts", + PkiAlertsV2 = "pki_alerts_v2", + PkiAlertChannels = "pki_alert_channels", + PkiAlertHistory = "pki_alert_history", + PkiAlertHistoryCertificate = "pki_alert_history_certificate", PkiCollection = "pki_collections", PkiCollectionItem = "pki_collection_items", Groups = "groups", @@ -210,7 +215,14 @@ export enum TableName { PamAccount = "pam_accounts", PamSession = "pam_sessions", - VaultExternalMigrationConfig = "vault_external_migration_configs" + VaultExternalMigrationConfig = "vault_external_migration_configs", + + // PKI ACME + PkiAcmeAccount = "pki_acme_accounts", + PkiAcmeOrder = "pki_acme_orders", + PkiAcmeOrderAuth = "pki_acme_order_auths", + PkiAcmeAuth = "pki_acme_auths", + PkiAcmeChallenge = "pki_acme_challenges" } export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt" | "commitId"; diff --git a/backend/src/db/schemas/pki-acme-accounts.ts b/backend/src/db/schemas/pki-acme-accounts.ts new file mode 100644 index 000000000..69b1ffe49 --- /dev/null +++ b/backend/src/db/schemas/pki-acme-accounts.ts @@ -0,0 +1,23 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const PkiAcmeAccountsSchema = z.object({ + id: z.string().uuid(), + profileId: z.string().uuid(), + emails: z.string().array(), + publicKey: z.unknown(), + publicKeyThumbprint: z.string(), + alg: z.string(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TPkiAcmeAccounts = z.infer; +export type TPkiAcmeAccountsInsert = Omit, TImmutableDBKeys>; +export type TPkiAcmeAccountsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/pki-acme-auths.ts b/backend/src/db/schemas/pki-acme-auths.ts new file mode 100644 index 000000000..7f20e0f24 --- /dev/null +++ b/backend/src/db/schemas/pki-acme-auths.ts @@ -0,0 +1,25 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const PkiAcmeAuthsSchema = z.object({ + id: z.string().uuid(), + accountId: z.string().uuid(), + status: z.string(), + token: z.string().nullable().optional(), + identifierType: z.string(), + identifierValue: z.string(), + expiresAt: z.date(), + certificateId: z.string().uuid().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TPkiAcmeAuths = z.infer; +export type TPkiAcmeAuthsInsert = Omit, TImmutableDBKeys>; +export type TPkiAcmeAuthsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/pki-acme-challenges.ts b/backend/src/db/schemas/pki-acme-challenges.ts new file mode 100644 index 000000000..18245bb76 --- /dev/null +++ b/backend/src/db/schemas/pki-acme-challenges.ts @@ -0,0 +1,23 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const PkiAcmeChallengesSchema = z.object({ + id: z.string().uuid(), + authId: z.string().uuid(), + type: z.string(), + status: z.string(), + error: z.string().nullable().optional(), + validatedAt: z.date().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TPkiAcmeChallenges = z.infer; +export type TPkiAcmeChallengesInsert = Omit, TImmutableDBKeys>; +export type TPkiAcmeChallengesUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/pki-acme-enrollment-configs.ts b/backend/src/db/schemas/pki-acme-enrollment-configs.ts new file mode 100644 index 000000000..f0592319b --- /dev/null +++ b/backend/src/db/schemas/pki-acme-enrollment-configs.ts @@ -0,0 +1,23 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { zodBuffer } from "@app/lib/zod"; + +import { TImmutableDBKeys } from "./models"; + +export const PkiAcmeEnrollmentConfigsSchema = z.object({ + id: z.string().uuid(), + encryptedEabSecret: zodBuffer, + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TPkiAcmeEnrollmentConfigs = z.infer; +export type TPkiAcmeEnrollmentConfigsInsert = Omit, TImmutableDBKeys>; +export type TPkiAcmeEnrollmentConfigsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/pki-acme-order-auths.ts b/backend/src/db/schemas/pki-acme-order-auths.ts new file mode 100644 index 000000000..66f8704f2 --- /dev/null +++ b/backend/src/db/schemas/pki-acme-order-auths.ts @@ -0,0 +1,20 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const PkiAcmeOrderAuthsSchema = z.object({ + id: z.string().uuid(), + orderId: z.string().uuid(), + authId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TPkiAcmeOrderAuths = z.infer; +export type TPkiAcmeOrderAuthsInsert = Omit, TImmutableDBKeys>; +export type TPkiAcmeOrderAuthsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/pki-acme-orders.ts b/backend/src/db/schemas/pki-acme-orders.ts new file mode 100644 index 000000000..928753d8c --- /dev/null +++ b/backend/src/db/schemas/pki-acme-orders.ts @@ -0,0 +1,26 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const PkiAcmeOrdersSchema = z.object({ + id: z.string().uuid(), + accountId: z.string().uuid(), + certificateId: z.string().uuid().nullable().optional(), + notBefore: z.date().nullable().optional(), + notAfter: z.date().nullable().optional(), + expiresAt: z.date(), + csr: z.string().nullable().optional(), + error: z.string().nullable().optional(), + status: z.string(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TPkiAcmeOrders = z.infer; +export type TPkiAcmeOrdersInsert = Omit, TImmutableDBKeys>; +export type TPkiAcmeOrdersUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/pki-alert-channels.ts b/backend/src/db/schemas/pki-alert-channels.ts new file mode 100644 index 000000000..1101a9607 --- /dev/null +++ b/backend/src/db/schemas/pki-alert-channels.ts @@ -0,0 +1,22 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const PkiAlertChannelsSchema = z.object({ + id: z.string().uuid(), + alertId: z.string().uuid(), + channelType: z.string(), + config: z.unknown(), + enabled: z.boolean().default(true).nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TPkiAlertChannels = z.infer; +export type TPkiAlertChannelsInsert = Omit, TImmutableDBKeys>; +export type TPkiAlertChannelsUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/pki-alert-history-certificate.ts b/backend/src/db/schemas/pki-alert-history-certificate.ts new file mode 100644 index 000000000..f953b9a48 --- /dev/null +++ b/backend/src/db/schemas/pki-alert-history-certificate.ts @@ -0,0 +1,25 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const PkiAlertHistoryCertificateSchema = z.object({ + id: z.string().uuid(), + alertHistoryId: z.string().uuid(), + certificateId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TPkiAlertHistoryCertificate = z.infer; +export type TPkiAlertHistoryCertificateInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TPkiAlertHistoryCertificateUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/pki-alert-history.ts b/backend/src/db/schemas/pki-alert-history.ts new file mode 100644 index 000000000..20af7b757 --- /dev/null +++ b/backend/src/db/schemas/pki-alert-history.ts @@ -0,0 +1,22 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const PkiAlertHistorySchema = z.object({ + id: z.string().uuid(), + alertId: z.string().uuid(), + triggeredAt: z.date().nullable().optional(), + hasNotificationSent: z.boolean().default(false).nullable().optional(), + notificationError: z.string().nullable().optional(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TPkiAlertHistory = z.infer; +export type TPkiAlertHistoryInsert = Omit, TImmutableDBKeys>; +export type TPkiAlertHistoryUpdate = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/pki-alerts-v2.ts b/backend/src/db/schemas/pki-alerts-v2.ts new file mode 100644 index 000000000..cbb28220c --- /dev/null +++ b/backend/src/db/schemas/pki-alerts-v2.ts @@ -0,0 +1,25 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const PkiAlertsV2Schema = z.object({ + id: z.string().uuid(), + name: z.string(), + description: z.string().nullable().optional(), + eventType: z.string(), + alertBefore: z.string().nullable().optional(), + filters: z.unknown().nullable().optional(), + enabled: z.boolean().default(true).nullable().optional(), + projectId: z.string(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TPkiAlertsV2 = z.infer; +export type TPkiAlertsV2Insert = Omit, TImmutableDBKeys>; +export type TPkiAlertsV2Update = Partial, TImmutableDBKeys>>; diff --git a/backend/src/db/schemas/pki-certificate-profiles.ts b/backend/src/db/schemas/pki-certificate-profiles.ts index 368770c3e..04560bec6 100644 --- a/backend/src/db/schemas/pki-certificate-profiles.ts +++ b/backend/src/db/schemas/pki-certificate-profiles.ts @@ -18,7 +18,8 @@ export const PkiCertificateProfilesSchema = z.object({ estConfigId: z.string().uuid().nullable().optional(), apiConfigId: z.string().uuid().nullable().optional(), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + acmeConfigId: z.string().uuid().nullable().optional() }); export type TPkiCertificateProfiles = z.infer; diff --git a/backend/src/db/schemas/project-slack-configs.ts b/backend/src/db/schemas/project-slack-configs.ts index 0a46e5aae..48674ec8f 100644 --- a/backend/src/db/schemas/project-slack-configs.ts +++ b/backend/src/db/schemas/project-slack-configs.ts @@ -16,7 +16,9 @@ export const ProjectSlackConfigsSchema = z.object({ isSecretRequestNotificationEnabled: z.boolean().default(false), secretRequestChannels: z.string().default(""), createdAt: z.date(), - updatedAt: z.date() + updatedAt: z.date(), + isSecretSyncErrorNotificationEnabled: z.boolean().default(false), + secretSyncErrorChannels: z.string().default("") }); export type TProjectSlackConfigs = z.infer; diff --git a/backend/src/ee/routes/v1/index.ts b/backend/src/ee/routes/v1/index.ts index 31847b503..ce05ea3b6 100644 --- a/backend/src/ee/routes/v1/index.ts +++ b/backend/src/ee/routes/v1/index.ts @@ -1,4 +1,5 @@ import { registerProjectTemplateRouter } from "@app/ee/routes/v1/project-template-router"; +import { getConfig } from "@app/lib/config/env"; import { registerAccessApprovalPolicyRouter } from "./access-approval-policy-router"; import { registerAccessApprovalRequestRouter } from "./access-approval-request-router"; @@ -30,6 +31,7 @@ import { PAM_RESOURCE_REGISTER_ROUTER_MAP } from "./pam-resource-routers"; import { registerPamResourceRouter } from "./pam-resource-routers/pam-resource-router"; import { registerPamSessionRouter } from "./pam-session-router"; import { registerPITRouter } from "./pit-router"; +import { registerPkiAcmeRouter } from "./pki-acme-router"; import { registerProjectRoleRouter } from "./project-role-router"; import { registerProjectRouter } from "./project-router"; import { registerRateLimitRouter } from "./rate-limit-router"; @@ -107,6 +109,10 @@ export const registerV1EERoutes = async (server: FastifyZodProvider) => { await server.register( async (pkiRouter) => { await pkiRouter.register(registerCaCrlRouter, { prefix: "/crl" }); + // Notice: current this feature is still in development and is not yet ready for production. + if (getConfig().isAcmeFeatureEnabled === true) { + await pkiRouter.register(registerPkiAcmeRouter, { prefix: "/acme" }); + } }, { prefix: "/pki" } ); diff --git a/backend/src/ee/routes/v1/pki-acme-router.ts b/backend/src/ee/routes/v1/pki-acme-router.ts new file mode 100644 index 000000000..627537c44 --- /dev/null +++ b/backend/src/ee/routes/v1/pki-acme-router.ts @@ -0,0 +1,461 @@ +/* eslint-disable @typescript-eslint/no-floating-promises */ +import { FastifyReply, FastifyRequest } from "fastify"; +import { z } from "zod"; + +import { AcmeMalformedError } from "@app/ee/services/pki-acme/pki-acme-errors"; +import { + AcmeOrderResourceSchema, + CreateAcmeAccountResponseSchema, + CreateAcmeOrderBodySchema, + DeactivateAcmeAccountBodySchema, + DeactivateAcmeAccountResponseSchema, + FinalizeAcmeOrderBodySchema, + GetAcmeAuthorizationResponseSchema, + GetAcmeDirectoryResponseSchema, + ListAcmeOrdersPayloadSchema, + ListAcmeOrdersResponseSchema, + RawJwsPayloadSchema, + RespondToAcmeChallengeBodySchema, + RespondToAcmeChallengeResponseSchema +} from "@app/ee/services/pki-acme/pki-acme-schemas"; +import type { TAcmeResponse, TAuthenciatedJwsPayload, TRawJwsPayload } from "@app/ee/services/pki-acme/pki-acme-types"; +import { ApiDocsTags } from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; + +const SharedParamsSchema = z.object({ + profileId: z.string().uuid() +}); + +export interface MyRequestInterface { + Params: { profileId: string; accountId?: string }; + Body: TRawJwsPayload; +} + +export const registerPkiAcmeRouter = async (server: FastifyZodProvider) => { + const validateExistingAccount = async < + // eslint-disable-next-line @typescript-eslint/no-explicit-any + R extends FastifyRequest, + TSchema extends z.ZodSchema | undefined = undefined, + T = TSchema extends z.ZodSchema ? U : string + >({ + req, + schema + }: { + req: R; + schema?: TSchema; + }): Promise> => { + return server.services.pkiAcme.validateExistingAccountJwsPayload({ + url: new URL(req.url, `${req.protocol}://${req.hostname}`), + profileId: (req.params as { profileId: string }).profileId, + rawJwsPayload: req.body as TRawJwsPayload, + schema, + expectedAccountId: (req.params as { accountId?: string }).accountId + }); + }; + + const sendAcmeResponse = async (res: FastifyReply, profileId: string, response: TAcmeResponse): Promise => { + res.code(response.status); + for (const [key, value] of Object.entries(response.headers)) { + res.header(key, value); + } + + const nonce = await server.services.pkiAcme.getAcmeNewNonce(profileId); + res.header("Replay-Nonce", nonce); + res.header("Cache-Control", "no-store"); + return response.body; + }; + + server.addContentTypeParser("application/jose+json", { parseAs: "string" }, (_, body, done) => { + try { + const strBody = body instanceof Buffer ? body.toString() : body; + if (!strBody) { + done(null, undefined); + } + const json: unknown = JSON.parse(strBody); + done(null, json); + } catch (err) { + const error = err as Error; + done(error, undefined); + } + }); + // GET /api/v1/pki/acme/profiles//directory + // Directory (RFC 8555 Section 7.1.1) + server.route({ + method: "GET", + url: "/profiles/:profileId/directory", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiAcme], + description: "ACME Directory - provides URLs for the client to make API calls to", + params: z.object({ + profileId: z.string().uuid() + }), + response: { + 200: GetAcmeDirectoryResponseSchema + } + }, + handler: async (req) => server.services.pkiAcme.getAcmeDirectory(req.params.profileId) + }); + + // HEAD /api/v1/pki/acme/profiles//new-nonce + // New Nonce (RFC 8555 Section 7.2) + server.route({ + method: "HEAD", + url: "/profiles/:profileId/new-nonce", + config: { + // TODO: probably a different rate limit for nonce creation? + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiAcme], + description: "ACME New Nonce - generate a new nonce and return in Replay-Nonce header", + params: z.object({ + profileId: z.string().uuid() + }), + response: { + 200: z.string().length(0) + } + }, + handler: async (req, res) => { + const nonce = await server.services.pkiAcme.getAcmeNewNonce(req.params.profileId); + res.header("Replay-Nonce", nonce); + return ""; + } + }); + + // POST /api/v1/pki/acme/profiles//new-account + // New Account (RFC 8555 Section 7.3) + server.route({ + method: "POST", + url: "/profiles/:profileId/new-account", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiAcme], + description: "ACME New Account - register a new account or find existing one", + params: SharedParamsSchema, + body: RawJwsPayloadSchema, + response: { + 201: CreateAcmeAccountResponseSchema + } + }, + handler: async (req, res) => { + const { payload, protectedHeader } = await server.services.pkiAcme.validateNewAccountJwsPayload({ + url: new URL(req.url, `${req.protocol}://${req.hostname}`), + rawJwsPayload: req.body + }); + const { alg, jwk } = protectedHeader; + return sendAcmeResponse( + res, + req.params.profileId, + await server.services.pkiAcme.createAcmeAccount({ + profileId: req.params.profileId, + alg, + jwk: jwk!, + payload + }) + ); + } + }); + + // POST /api/v1/pki/acme/profiles//accounts/ + // Account Deactivation (RFC 8555 Section 7.3.6) + server.route({ + method: "POST", + url: "/profiles/:profileId/accounts/:accountId", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiAcme], + description: "ACME Account Deactivation", + params: SharedParamsSchema.extend({ + accountId: z.string() + }), + body: RawJwsPayloadSchema, + response: { + 200: DeactivateAcmeAccountResponseSchema + } + }, + handler: async (req, res) => { + const { payload, profileId, accountId } = await validateExistingAccount({ + req, + schema: DeactivateAcmeAccountBodySchema + }); + return sendAcmeResponse( + res, + profileId, + await server.services.pkiAcme.deactivateAcmeAccount({ + profileId, + accountId, + payload + }) + ); + } + }); + + // POST /api/v1/pki/acme/profiles//new-order + // New Certificate Order (RFC 8555 Section 7.4) + server.route({ + method: "POST", + url: "/profiles/:profileId/new-order", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiAcme], + description: "ACME New Order - apply for a new certificate", + params: SharedParamsSchema, + body: RawJwsPayloadSchema, + response: { + 201: AcmeOrderResourceSchema + } + }, + handler: async (req, res) => { + const { profileId, accountId, payload } = await validateExistingAccount({ + req, + schema: CreateAcmeOrderBodySchema + }); + return sendAcmeResponse( + res, + profileId, + await server.services.pkiAcme.createAcmeOrder({ + profileId, + accountId, + payload + }) + ); + } + }); + + // POST /api/v1/pki/acme/profiles//orders/ + // Get Order (RFC 8555 Section 7.1.3) + server.route({ + method: "POST", + url: "/profiles/:profileId/orders/:orderId", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiAcme], + description: "ACME Get Order - return status and details of the order", + params: SharedParamsSchema.extend({ + orderId: z.string().uuid() + }), + body: RawJwsPayloadSchema, + response: { + 200: AcmeOrderResourceSchema + } + }, + handler: async (req, res) => { + const { profileId, accountId, payload } = await validateExistingAccount({ + req + }); + if (payload !== "") { + throw new AcmeMalformedError({ detail: "Payload should be empty" }); + } + return sendAcmeResponse( + res, + profileId, + await server.services.pkiAcme.getAcmeOrder({ + profileId, + accountId, + orderId: req.params.orderId + }) + ); + } + }); + + // POST /api/v1/pki/acme/profiles//orders//finalize + // Applying for Certificate Issuance (RFC 8555 Section 7.4) + server.route({ + method: "POST", + url: "/profiles/:profileId/orders/:orderId/finalize", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiAcme], + description: "ACME Finalize Order - finalize cert order by providing CSR", + params: SharedParamsSchema.extend({ + orderId: z.string().uuid() + }), + body: RawJwsPayloadSchema, + response: { + 200: AcmeOrderResourceSchema + } + }, + handler: async (req, res) => { + const { profileId, accountId, payload } = await validateExistingAccount({ + req, + schema: FinalizeAcmeOrderBodySchema + }); + return sendAcmeResponse( + res, + profileId, + await server.services.pkiAcme.finalizeAcmeOrder({ + profileId, + accountId, + orderId: req.params.orderId, + payload + }) + ); + } + }); + // POST /api/v1/pki/acme/profiles//accounts//orders + // List Orders (RFC 8555 Section 7.1.2.1) + server.route({ + method: "POST", + url: "/profiles/:profileId/accounts/:accountId/orders", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiAcme], + description: "ACME List Orders - get existing orders from current account", + params: SharedParamsSchema.extend({ + accountId: z.string() + }), + body: RawJwsPayloadSchema, + response: { + 200: ListAcmeOrdersResponseSchema + } + }, + handler: async (req, res) => { + const { profileId, accountId } = await validateExistingAccount({ + req, + schema: ListAcmeOrdersPayloadSchema + }); + return sendAcmeResponse( + res, + profileId, + await server.services.pkiAcme.listAcmeOrders({ + profileId, + accountId + }) + ); + } + }); + + // POST /api/v1/pki/acme/profiles//orders//certificate + // Download Certificate (RFC 8555 Section 7.4.2) + server.route({ + method: "POST", + url: "/profiles/:profileId/orders/:orderId/certificate", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiAcme], + description: "ACME Download Certificate - download certificate when ready", + params: SharedParamsSchema.extend({ + orderId: z.string().uuid() + }), + body: RawJwsPayloadSchema, + response: { + 200: z.string() + } + }, + handler: async (req, res) => { + const { profileId, accountId, payload } = await validateExistingAccount({ + req + }); + if (payload !== "") { + throw new AcmeMalformedError({ detail: "Payload should be empty" }); + } + res.type("application/pem-certificate-chain"); + return sendAcmeResponse( + res, + profileId, + await server.services.pkiAcme.downloadAcmeCertificate({ profileId, accountId, orderId: req.params.orderId }) + ); + } + }); + + // POST /api/v1/pki/acme/profiles//authorizations/ + // Identifier Authorization (RFC 8555 Section 7.5) + server.route({ + method: "POST", + url: "/profiles/:profileId/authorizations/:authzId", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiAcme], + description: "ACME Identifier Authorization - get authorization info (challenges)", + params: SharedParamsSchema.extend({ + authzId: z.string().uuid() + }), + body: RawJwsPayloadSchema, + response: { + 200: GetAcmeAuthorizationResponseSchema + } + }, + handler: async (req, res) => { + const { profileId, accountId, payload } = await validateExistingAccount({ req }); + if (payload !== "") { + throw new AcmeMalformedError({ detail: "Payload should be empty" }); + } + return sendAcmeResponse( + res, + profileId, + await server.services.pkiAcme.getAcmeAuthorization({ + profileId, + accountId, + authzId: req.params.authzId + }) + ); + } + }); + + // POST /api/v1/pki/acme/profiles//authorizations//challenges/ + // Respond to Challenge (RFC 8555 Section 7.5.1) + server.route({ + method: "POST", + url: "/profiles/:profileId/authorizations/:authzId/challenges/:challengeId", + config: { + rateLimit: writeLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiAcme], + description: "ACME Respond to Challenge - let ACME server know challenge is ready", + params: SharedParamsSchema.extend({ + authzId: z.string().uuid(), + challengeId: z.string().uuid() + }), + response: { + 200: RespondToAcmeChallengeResponseSchema + } + }, + handler: async (req, res) => { + const { profileId, accountId } = await validateExistingAccount({ + req, + schema: RespondToAcmeChallengeBodySchema + }); + return sendAcmeResponse( + res, + profileId, + await server.services.pkiAcme.respondToAcmeChallenge({ + profileId, + accountId, + authzId: req.params.authzId, + challengeId: req.params.challengeId + }) + ); + } + }); +}; diff --git a/backend/src/ee/routes/v1/secret-approval-request-router.ts b/backend/src/ee/routes/v1/secret-approval-request-router.ts index 78d9aeddc..bd5bacc5f 100644 --- a/backend/src/ee/routes/v1/secret-approval-request-router.ts +++ b/backend/src/ee/routes/v1/secret-approval-request-router.ts @@ -315,6 +315,7 @@ export const registerSecretApprovalRequestRouter = async (server: FastifyZodProv .extend({ status: z.string(), comment: z.string().optional(), + createdAt: z.date(), isOrgMembershipActive: z.boolean().nullable().optional() }) .array(), diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts index 1027995a7..4b2608c24 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts @@ -243,7 +243,8 @@ export const accessApprovalRequestServiceFactory = ({ ); const requesterFullName = `${requestedByUser.firstName} ${requestedByUser.lastName}`; - const approvalPath = `/projects/secret-management/${project.id}/approval`; + const projectPath = `/projects/secret-management/${project.id}`; + const approvalPath = `${projectPath}/approval`; const approvalUrl = `${cfg.SITE_URL}${approvalPath}`; await triggerWorkflowIntegrationNotification({ @@ -252,6 +253,7 @@ export const accessApprovalRequestServiceFactory = ({ type: TriggerFeature.ACCESS_REQUEST, payload: { projectName: project.name, + projectPath, requesterFullName, isTemporary, requesterEmail: requestedByUser.email as string, @@ -397,7 +399,8 @@ export const accessApprovalRequestServiceFactory = ({ const requesterFullName = `${requestedByUser.firstName} ${requestedByUser.lastName}`; const editorFullName = `${editedByUser.firstName} ${editedByUser.lastName}`; - const approvalPath = `/projects/secret-management/${project.id}/approval`; + const projectPath = `/projects/secret-management/${project.id}`; + const approvalPath = `${projectPath}/approval`; const approvalUrl = `${cfg.SITE_URL}${approvalPath}`; await triggerWorkflowIntegrationNotification({ @@ -415,7 +418,8 @@ export const accessApprovalRequestServiceFactory = ({ approvalUrl, editNote, editorEmail: editedByUser.email as string, - editorFullName + editorFullName, + projectPath } }, projectId: project.id 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 c6ac6ff3b..bcc2a0770 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -36,6 +36,7 @@ import { CertExtendedKeyUsage, CertKeyAlgorithm, CertKeyUsage } from "@app/servi import { CaStatus } from "@app/services/certificate-authority/certificate-authority-enums"; import { TIdentityTrustedIp } from "@app/services/identity/identity-types"; import { TAllowedFields } from "@app/services/identity-ldap-auth/identity-ldap-auth-types"; +import { PkiAlertEventType } from "@app/services/pki-alert-v2/pki-alert-v2-types"; import { PkiItemType } from "@app/services/pki-collection/pki-collection-types"; import { SecretSync, SecretSyncImportBehavior } from "@app/services/secret-sync/secret-sync-enums"; import { @@ -370,6 +371,7 @@ export enum EventType { SIGN_CERTIFICATE_FROM_PROFILE = "sign-certificate-from-profile", ORDER_CERTIFICATE_FROM_PROFILE = "order-certificate-from-profile", RENEW_CERTIFICATE = "renew-certificate", + GET_CERTIFICATE_PROFILE_LATEST_ACTIVE_BUNDLE = "get-certificate-profile-latest-active-bundle", UPDATE_CERTIFICATE_RENEWAL_CONFIG = "update-certificate-renewal-config", DISABLE_CERTIFICATE_RENEWAL_CONFIG = "disable-certificate-renewal-config", ATTEMPT_CREATE_SLACK_INTEGRATION = "attempt-create-slack-integration", @@ -2318,10 +2320,11 @@ interface CreatePkiAlert { type: EventType.CREATE_PKI_ALERT; metadata: { pkiAlertId: string; - pkiCollectionId: string; + pkiCollectionId?: string; name: string; - alertBeforeDays: number; - recipientEmails: string; + alertBefore: string; + eventType: PkiAlertEventType; + recipientEmails?: string; }; } interface GetPkiAlert { @@ -2337,7 +2340,8 @@ interface UpdatePkiAlert { pkiAlertId: string; pkiCollectionId?: string; name?: string; - alertBeforeDays?: number; + alertBefore?: string; + eventType?: PkiAlertEventType; recipientEmails?: string; }; } @@ -2749,6 +2753,17 @@ interface OrderCertificateFromProfile { }; } +interface GetCertificateProfileLatestActiveBundle { + type: EventType.GET_CERTIFICATE_PROFILE_LATEST_ACTIVE_BUNDLE; + metadata: { + certificateProfileId: string; + certificateId: string; + commonName: string; + profileName: string; + serialNumber: string; + }; +} + interface RenewCertificate { type: EventType.RENEW_CERTIFICATE; metadata: { @@ -4279,6 +4294,7 @@ export type Event = | DeleteCertificateProfile | GetCertificateProfile | ListCertificateProfiles + | GetCertificateProfileLatestActiveBundle | IssueCertificateFromProfile | SignCertificateFromProfile | OrderCertificateFromProfile diff --git a/backend/src/ee/services/permission/default-roles.ts b/backend/src/ee/services/permission/default-roles.ts index 34876f739..5e7025f05 100644 --- a/backend/src/ee/services/permission/default-roles.ts +++ b/backend/src/ee/services/permission/default-roles.ts @@ -106,7 +106,9 @@ const buildAdminPermissionRules = () => { ProjectPermissionCertificateProfileActions.Edit, ProjectPermissionCertificateProfileActions.Create, ProjectPermissionCertificateProfileActions.Delete, - ProjectPermissionCertificateProfileActions.IssueCert + ProjectPermissionCertificateProfileActions.IssueCert, + ProjectPermissionCertificateProfileActions.RevealAcmeEabSecret, + ProjectPermissionCertificateProfileActions.RotateAcmeEabSecret ], ProjectPermissionSub.CertificateProfiles ); diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index bb62440c1..74e4554ed 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -116,7 +116,9 @@ export enum ProjectPermissionCertificateProfileActions { Create = "create", Edit = "edit", Delete = "delete", - IssueCert = "issue-cert" + IssueCert = "issue-cert", + RevealAcmeEabSecret = "reveal-acme-eab-secret", + RotateAcmeEabSecret = "rotate-acme-eab-secret" } export enum ProjectPermissionSecretSyncActions { diff --git a/backend/src/ee/services/pki-acme/pki-acme-account-dal.ts b/backend/src/ee/services/pki-acme/pki-acme-account-dal.ts new file mode 100644 index 000000000..685d07f2b --- /dev/null +++ b/backend/src/ee/services/pki-acme/pki-acme-account-dal.ts @@ -0,0 +1,42 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +export type TPkiAcmeAccountDALFactory = ReturnType; + +export const pkiAcmeAccountDALFactory = (db: TDbClient) => { + const pkiAcmeAccountOrm = ormify(db, TableName.PkiAcmeAccount); + + const findByProjectIdAndAccountId = async (profileId: string, id: string, tx?: Knex) => { + try { + const account = await (tx || db)(TableName.PkiAcmeAccount).where({ profileId, id }).first(); + + return account || null; + } catch (error) { + throw new DatabaseError({ error, name: "Find PKI ACME account by id" }); + } + }; + + const findByProfileIdAndPublicKeyThumbprintAndAlg = async ( + profileId: string, + alg: string, + publicKeyThumbprint: string, + tx?: Knex + ) => { + try { + const account = await (tx || db)(TableName.PkiAcmeAccount).where({ profileId, alg, publicKeyThumbprint }).first(); + return account || null; + } catch (error) { + throw new DatabaseError({ error, name: "Find PKI ACME account by profile id, public key thumbprint and alg" }); + } + }; + + return { + ...pkiAcmeAccountOrm, + findByProjectIdAndAccountId, + findByProfileIdAndPublicKeyThumbprintAndAlg + }; +}; diff --git a/backend/src/ee/services/pki-acme/pki-acme-auth-dal.ts b/backend/src/ee/services/pki-acme/pki-acme-auth-dal.ts new file mode 100644 index 000000000..cef4d619f --- /dev/null +++ b/backend/src/ee/services/pki-acme/pki-acme-auth-dal.ts @@ -0,0 +1,54 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; + +export type TPkiAcmeAuthDALFactory = ReturnType; + +export const pkiAcmeAuthDALFactory = (db: TDbClient) => { + const pkiAcmeAuthOrm = ormify(db, TableName.PkiAcmeAuth); + + const findByAccountIdAndAuthIdWithChallenges = async (accountId: string, authId: string, tx?: Knex) => { + try { + const rows = await (tx || db)(TableName.PkiAcmeAuth) + .leftJoin(TableName.PkiAcmeChallenge, `${TableName.PkiAcmeChallenge}.authId`, `${TableName.PkiAcmeAuth}.id`) + .select( + selectAllTableCols(TableName.PkiAcmeAuth), + db.ref("id").withSchema(TableName.PkiAcmeChallenge).as("challengeId"), + db.ref("type").withSchema(TableName.PkiAcmeChallenge).as("challengeType"), + db.ref("status").withSchema(TableName.PkiAcmeChallenge).as("challengeStatus") + ) + .where(`${TableName.PkiAcmeAuth}.accountId`, accountId) + .where(`${TableName.PkiAcmeAuth}.id`, authId); + + if (rows.length === 0) { + return null; + } + return sqlNestRelationships({ + data: rows, + key: "id", + parentMapper: (row) => row, + childrenMapper: [ + { + key: "challengeId", + label: "challenges" as const, + mapper: ({ challengeId, challengeType, challengeStatus }) => ({ + id: challengeId, + type: challengeType, + status: challengeStatus + }) + } + ] + })?.[0]; + } catch (error) { + throw new DatabaseError({ error, name: "Find PKI ACME auth by account id and auth id with challenges" }); + } + }; + + return { + ...pkiAcmeAuthOrm, + findByAccountIdAndAuthIdWithChallenges + }; +}; diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts new file mode 100644 index 000000000..74cbd1466 --- /dev/null +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-dal.ts @@ -0,0 +1,177 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName, TPkiAcmeChallenges } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +import { AcmeAuthStatus, AcmeChallengeStatus, AcmeOrderStatus } from "./pki-acme-schemas"; + +export type TPkiAcmeChallengeDALFactory = ReturnType; + +export const pkiAcmeChallengeDALFactory = (db: TDbClient) => { + const pkiAcmeChallengeOrm = ormify(db, TableName.PkiAcmeChallenge); + + const markAsValidCascadeById = async (id: string, tx?: Knex): Promise => { + try { + const [challenge] = (await (tx || db)(TableName.PkiAcmeChallenge) + .where({ id }) + .update({ status: AcmeChallengeStatus.Valid, validatedAt: new Date() }) + .returning("*")) as [TPkiAcmeChallenges]; + + // Update pending auth to valid as well + const updatedAuths = await (tx || db)(TableName.PkiAcmeAuth) + .where({ id: challenge.authId, status: AcmeAuthStatus.Pending }) + .update({ status: AcmeAuthStatus.Valid }) + .returning("id"); + + if (updatedAuths.length > 0) { + // Find all the orders that are involved in the challenge validation + const involvedOrderIds = (tx || db)({ o: TableName.PkiAcmeOrder }) + .distinct("o.id") + .join({ oa: TableName.PkiAcmeOrderAuth }, "o.id", "oa.orderId") + .join({ a: TableName.PkiAcmeAuth }, "oa.authId", `a.id`) + .whereIn( + "a.id", + updatedAuths.map((auth) => auth.id) + ); + // Update status for pending orders that have all auths valid + await (tx || db)(TableName.PkiAcmeOrder) + .whereIn("id", (qb) => { + void qb + .select("o2.id") + .from({ o2: TableName.PkiAcmeOrder }) + .join({ oa2: TableName.PkiAcmeOrderAuth }, "o2.id", "oa2.orderId") + .join({ a2: TableName.PkiAcmeAuth }, "oa2.authId", "a2.id") + .groupBy("o2.id") + // All auths should be valid for the order to be ready + .havingRaw("SUM(CASE WHEN a2.status = ? THEN 1 ELSE 0 END) = COUNT(DISTINCT a2.id)", [ + AcmeAuthStatus.Valid + ]) + // Only update orders that are pending + .where("o2.status", AcmeOrderStatus.Pending) + .whereIn("o2.id", involvedOrderIds); + }) + .update({ status: AcmeOrderStatus.Ready }); + } + + return challenge; + } catch (error) { + throw new DatabaseError({ error, name: "Update certificate profile" }); + } + }; + + const markAsInvalidCascadeById = async (id: string, tx?: Knex): Promise => { + try { + const [challenge] = (await (tx || db)(TableName.PkiAcmeChallenge) + .where({ id }) + .update({ status: AcmeChallengeStatus.Invalid }) + .returning("*")) as [TPkiAcmeChallenges]; + + // Update pending auth to valid as well + const updatedAuths = await (tx || db)(TableName.PkiAcmeAuth) + .where({ id: challenge.authId, status: AcmeAuthStatus.Pending }) + .update({ status: AcmeAuthStatus.Invalid }) + .returning("id"); + + if (updatedAuths.length > 0) { + // Update status for pending orders that have all auths valid + await (tx || db)(TableName.PkiAcmeOrder) + .whereIn("id", (qb) => { + void qb + .select("o.id") + .from({ o: TableName.PkiAcmeOrder }) + .join(TableName.PkiAcmeOrderAuth, "o.id", `${TableName.PkiAcmeOrderAuth}.orderId`) + .join(TableName.PkiAcmeAuth, `${TableName.PkiAcmeOrderAuth}.authId`, `${TableName.PkiAcmeAuth}.id`) + // We only update orders that are pending + .where("o.status", AcmeOrderStatus.Pending) + .whereIn( + `${TableName.PkiAcmeAuth}.id`, + updatedAuths.map((auth) => auth.id) + ); + }) + .update({ status: AcmeOrderStatus.Invalid }); + } + + // TODO: update order status to invalid as well + return challenge; + } catch (error) { + throw new DatabaseError({ error, name: "Update certificate profile" }); + } + }; + + const findByAccountAuthAndChallengeId = async (accountId: string, authId: string, challengeId: string, tx?: Knex) => { + try { + const challenge = await (tx || db)(TableName.PkiAcmeChallenge) + .join(TableName.PkiAcmeAuth, `${TableName.PkiAcmeChallenge}.authId`, `${TableName.PkiAcmeAuth}.id`) + .select(selectAllTableCols(TableName.PkiAcmeChallenge)) + .where(`${TableName.PkiAcmeChallenge}.id`, challengeId) + .where(`${TableName.PkiAcmeChallenge}.authId`, authId) + .where(`${TableName.PkiAcmeAuth}.accountId`, accountId) + .first(); + if (!challenge) { + return null; + } + return challenge; + } catch (error) { + throw new DatabaseError({ error, name: "Find PKI ACME challenge by account id, auth id and challenge id" }); + } + }; + + const findByIdForChallengeValidation = async (id: string, tx?: Knex) => { + const result = await (tx || db)(TableName.PkiAcmeChallenge) + .join(TableName.PkiAcmeAuth, `${TableName.PkiAcmeChallenge}.authId`, `${TableName.PkiAcmeAuth}.id`) + .join(TableName.PkiAcmeAccount, `${TableName.PkiAcmeAuth}.accountId`, `${TableName.PkiAcmeAccount}.id`) + .select( + selectAllTableCols(TableName.PkiAcmeChallenge), + db.ref("id").withSchema(TableName.PkiAcmeAuth).as("authId"), + db.ref("token").withSchema(TableName.PkiAcmeAuth).as("authToken"), + db.ref("status").withSchema(TableName.PkiAcmeAuth).as("authStatus"), + db.ref("identifierType").withSchema(TableName.PkiAcmeAuth).as("authIdentifierType"), + db.ref("identifierValue").withSchema(TableName.PkiAcmeAuth).as("authIdentifierValue"), + db.ref("expiresAt").withSchema(TableName.PkiAcmeAuth).as("authExpiresAt"), + db.ref("id").withSchema(TableName.PkiAcmeAccount).as("accountId"), + db.ref("publicKeyThumbprint").withSchema(TableName.PkiAcmeAccount).as("accountPublicKeyThumbprint") + ) + // For all challenges, acquire update lock on the auth to avoid race conditions + .forUpdate(TableName.PkiAcmeAuth) + .where(`${TableName.PkiAcmeChallenge}.id`, id) + .first(); + if (!result) { + return null; + } + const { + authId, + authToken, + authStatus, + authIdentifierType, + authIdentifierValue, + authExpiresAt, + accountId, + accountPublicKeyThumbprint, + ...challenge + } = result; + return { + ...challenge, + auth: { + token: authToken, + status: authStatus, + identifierType: authIdentifierType, + identifierValue: authIdentifierValue, + expiresAt: authExpiresAt, + account: { + id: accountId, + publicKeyThumbprint: accountPublicKeyThumbprint + } + } + }; + }; + + return { + ...pkiAcmeChallengeOrm, + markAsValidCascadeById, + markAsInvalidCascadeById, + findByAccountAuthAndChallengeId, + findByIdForChallengeValidation + }; +}; diff --git a/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts new file mode 100644 index 000000000..61bd0c110 --- /dev/null +++ b/backend/src/ee/services/pki-acme/pki-acme-challenge-service.ts @@ -0,0 +1,134 @@ +import { getConfig } from "@app/lib/config/env"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { isPrivateIp } from "@app/lib/ip/ipRange"; +import { logger } from "@app/lib/logger"; + +import { TPkiAcmeChallengeDALFactory } from "./pki-acme-challenge-dal"; +import { + AcmeConnectionError, + AcmeDnsFailureError, + AcmeIncorrectResponseError, + AcmeServerInternalError +} from "./pki-acme-errors"; +import { AcmeAuthStatus, AcmeChallengeStatus, AcmeChallengeType } from "./pki-acme-schemas"; +import { TPkiAcmeChallengeServiceFactory } from "./pki-acme-types"; + +type FetchError = Error & { + code?: string; +}; + +type TPkiAcmeChallengeServiceFactoryDep = { + acmeChallengeDAL: Pick< + TPkiAcmeChallengeDALFactory, + "transaction" | "findByIdForChallengeValidation" | "markAsValidCascadeById" | "markAsInvalidCascadeById" + >; +}; + +export const pkiAcmeChallengeServiceFactory = ({ + acmeChallengeDAL +}: TPkiAcmeChallengeServiceFactoryDep): TPkiAcmeChallengeServiceFactory => { + const appCfg = getConfig(); + + const validateChallengeResponse = async (challengeId: string): Promise => { + const error: Error | undefined = await acmeChallengeDAL.transaction(async (tx) => { + logger.info({ challengeId }, "Validating ACME challenge response"); + const challenge = await acmeChallengeDAL.findByIdForChallengeValidation(challengeId, tx); + if (!challenge) { + throw new NotFoundError({ message: "ACME challenge not found" }); + } + if (challenge.status !== AcmeChallengeStatus.Pending) { + throw new BadRequestError({ + message: `ACME challenge is ${challenge.status} instead of ${AcmeChallengeStatus.Pending}` + }); + } + if (challenge.auth.expiresAt < new Date()) { + throw new BadRequestError({ message: "ACME auth has expired" }); + } + if (challenge.auth.status !== AcmeAuthStatus.Pending) { + throw new BadRequestError({ + message: `ACME auth status is ${challenge.auth.status} instead of ${AcmeAuthStatus.Pending}` + }); + } + + // TODO: support other challenge types here. Currently only HTTP-01 is supported + if (challenge.type !== AcmeChallengeType.HTTP_01) { + throw new BadRequestError({ message: "Only HTTP-01 challenges are supported for now" }); + } + let host = challenge.auth.identifierValue; + // check if host is a private ip address + if (isPrivateIp(host)) { + throw new BadRequestError({ message: "Private IP addresses are not allowed" }); + } + if (appCfg.isAcmeDevelopmentMode && appCfg.ACME_DEVELOPMENT_HTTP01_CHALLENGE_HOST_OVERRIDES[host]) { + host = appCfg.ACME_DEVELOPMENT_HTTP01_CHALLENGE_HOST_OVERRIDES[host]; + logger.warn( + { srcHost: challenge.auth.identifierValue, dstHost: host }, + "Using ACME development HTTP-01 challenge host override" + ); + } + const challengeUrl = new URL(`/.well-known/acme-challenge/${challenge.auth.token}`, `http://${host}`); + logger.info({ challengeUrl }, "Performing ACME HTTP-01 challenge validation"); + try { + // TODO: read config from the profile to get the timeout instead + const timeoutMs = 10 * 1000; // 10 seconds + // Notice: well, we are in a transaction, ideally we should not hold transaction and perform + // a long running operation for long time. But assuming we are not performing a tons of + // challenge validation at the same time, it should be fine. + const challengeResponse = await fetch(challengeUrl, { signal: AbortSignal.timeout(timeoutMs) }); + if (challengeResponse.status !== 200) { + throw new BadRequestError({ message: "ACME challenge response is not 200" }); + } + const challengeResponseBody = await challengeResponse.text(); + const thumbprint = challenge.auth.account.publicKeyThumbprint; + const expectedChallengeResponseBody = `${challenge.auth.token}.${thumbprint}`; + if (challengeResponseBody.trimEnd() !== expectedChallengeResponseBody) { + throw new AcmeIncorrectResponseError({ message: "ACME challenge response is not correct" }); + } + await acmeChallengeDAL.markAsValidCascadeById(challengeId, tx); + } catch (exp) { + // TODO: we should retry the challenge validation a few times, but let's keep it simple for now + await acmeChallengeDAL.markAsInvalidCascadeById(challengeId, tx); + // Properly type and inspect the error + if (exp instanceof TypeError && exp.message.includes("fetch failed")) { + const { cause } = exp; + let errors: Error[] = []; + if (cause instanceof AggregateError) { + errors = cause.errors as Error[]; + } else if (cause instanceof Error) { + errors = [cause]; + } + // eslint-disable-next-line no-unreachable-loop + for (const err of errors) { + // TODO: handle multiple errors, return a compound error instead of just the first error + const fetchError = err as FetchError; + if (fetchError.code === "ECONNREFUSED" || fetchError.message.includes("ECONNREFUSED")) { + return new AcmeConnectionError({ message: "Connection refused" }); + } + if (fetchError.code === "ENOTFOUND" || fetchError.message.includes("ENOTFOUND")) { + return new AcmeDnsFailureError({ message: "Hostname could not be resolved (DNS failure)" }); + } + return new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" }); + } + } else if (exp instanceof DOMException) { + if (exp.name === "TimeoutError") { + logger.error(exp, "Connection timed out while validating ACME challenge response"); + return new AcmeConnectionError({ message: "Connection timed out" }); + } + logger.error(exp, "Unknown error validating ACME challenge response"); + return new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" }); + } else if (exp instanceof Error) { + logger.error(exp, "Error validating ACME challenge response"); + } else { + logger.error(exp, "Unknown error validating ACME challenge response"); + return new AcmeServerInternalError({ message: "Unknown error validating ACME challenge response" }); + } + return exp; + } + }); + if (error) { + throw error; + } + }; + + return { validateChallengeResponse }; +}; diff --git a/backend/src/ee/services/pki-acme/pki-acme-errors.ts b/backend/src/ee/services/pki-acme/pki-acme-errors.ts new file mode 100644 index 000000000..febce5e81 --- /dev/null +++ b/backend/src/ee/services/pki-acme/pki-acme-errors.ts @@ -0,0 +1,574 @@ +/** + * ACME Error Classes based on RFC 8555 Section 6.7 + * https://datatracker.ietf.org/doc/html/rfc8555#section-6.7 + */ + +/* eslint-disable max-classes-per-file */ + +// RFC 8555 Section 6.7 - Error Types +export enum AcmeErrorType { + AccountDoesNotExist = "accountDoesNotExist", + AlreadyRevoked = "alreadyRevoked", + BadCsr = "badCSR", + BadNonce = "badNonce", + BadPublicKey = "badPublicKey", + BadRevocationReason = "badRevocationReason", + BadSignatureAlgorithm = "badSignatureAlgorithm", + CAA = "caa", + Compound = "compound", + Connection = "connection", + DNS = "dns", + ExternalAccountRequired = "externalAccountRequired", + IncorrectResponse = "incorrectResponse", + InvalidContact = "invalidContact", + Malformed = "malformed", + OrderNotReady = "orderNotReady", + RateLimited = "rateLimited", + RejectedIdentifier = "rejectedIdentifier", + ServerInternal = "serverInternal", + TLS = "tls", + Unauthorized = "unauthorized", + UnsupportedContact = "unsupportedContact", + UnsupportedIdentifier = "unsupportedIdentifier", + UserActionRequired = "userActionRequired" +} + +export interface IAcmeError { + type: AcmeErrorType; + detail: string; + status: number; + subproblems?: Array<{ type: string; detail: string; identifier?: { type: string; value: string } }>; +} + +export class AcmeError extends Error implements IAcmeError { + type: AcmeErrorType; + + detail: string; + + status: number; + + subproblems?: Array<{ type: string; detail: string; identifier?: { type: string; value: string } }>; + + error?: unknown; + + constructor({ + type, + detail, + status, + subproblems, + error, + message + }: { + type: AcmeErrorType; + detail: string; + status: number; + subproblems?: Array<{ type: string; detail: string; identifier?: { type: string; value: string } }>; + error?: unknown; + message?: string; + }) { + super(message || detail); + this.type = type; + this.detail = detail; + this.status = status; + this.subproblems = subproblems; + this.error = error; + this.name = "AcmeError"; + } + + toAcmeResponse(): IAcmeError { + return { + type: this.type, + detail: this.detail, + status: this.status, + subproblems: this.subproblems + }; + } +} + +/** + * malformed - The request message was malformed (RFC 8555 Section 6.7.1) + */ +export class AcmeMalformedError extends AcmeError { + constructor({ + detail = "The request message was malformed", + error, + message + }: { + detail?: string; + error?: unknown; + message?: string; + } = {}) { + super({ + type: AcmeErrorType.Malformed, + detail, + status: 400, + error, + message + }); + this.name = "AcmeMalformedError"; + } +} + +/** + * unauthorized - The client lacks sufficient authorization (RFC 8555 Section 6.7.2) + */ +export class AcmeUnauthorizedError extends AcmeError { + constructor({ + detail = "The client lacks sufficient authorization", + error, + message + }: { + detail?: string; + error?: unknown; + message?: string; + } = {}) { + super({ + type: AcmeErrorType.Unauthorized, + detail, + status: 403, + error, + message + }); + this.name = "AcmeUnauthorizedError"; + } +} + +/** + * accountDoesNotExist - The request specified an account that does not exist + * (RFC 8555 Section 6.7.3) + */ +export class AcmeAccountDoesNotExistError extends AcmeError { + constructor({ + detail = "The request specified an account that does not exist", + error, + message + }: { + detail?: string; + error?: unknown; + message?: string; + } = {}) { + super({ + type: AcmeErrorType.AccountDoesNotExist, + detail, + status: 400, + error, + message + }); + this.name = "AcmeAccountDoesNotExistError"; + } +} + +/** + * badNonce - The client sent an unacceptable anti-replay nonce (RFC 8555 Section 6.7.4) + */ +export class AcmeBadNonceError extends AcmeError { + constructor({ + detail = "The client sent an unacceptable anti-replay nonce", + error, + message + }: { + detail?: string; + error?: unknown; + message?: string; + } = {}) { + super({ + type: AcmeErrorType.BadNonce, + detail, + status: 400, + error, + message + }); + this.name = "AcmeBadNonceError"; + } +} + +/** + * badSignatureAlgorithm - The signature algorithm is invalid (RFC 8555 Section 6.7.5) + */ +export class AcmeBadSignatureAlgorithmError extends AcmeError { + constructor({ + detail = "The signature algorithm is invalid", + error, + message + }: { + detail?: string; + error?: unknown; + message?: string; + } = {}) { + super({ + type: AcmeErrorType.BadSignatureAlgorithm, + detail, + status: 401, + error, + message + }); + this.name = "AcmeBadSignatureAlgorithmError"; + } +} + +/** + * badPublicKey - The public key is not acceptable (RFC 8555 Section 6.7.6) + */ +export class AcmeBadPublicKeyError extends AcmeError { + constructor({ + detail = "The public key is not acceptable", + error, + message + }: { + detail?: string; + error?: unknown; + message?: string; + } = {}) { + super({ + type: AcmeErrorType.BadPublicKey, + detail, + status: 400, + error, + message + }); + this.name = "AcmeBadPublicKeyError"; + } +} + +/** + * badCSR - The CSR is unacceptable (RFC 8555 Section 6.7.7) + */ +export class AcmeBadCsrError extends AcmeError { + constructor({ + detail = "The CSR is unacceptable", + error, + message + }: { + detail?: string; + error?: unknown; + message?: string; + } = {}) { + super({ + type: AcmeErrorType.BadCsr, + detail, + status: 400, + error, + message + }); + this.name = "AcmeBadCsrError"; + } +} + +/** + * badRevocationReason - The revocation reason provided is not allowed + * (RFC 8555 Section 6.7.8) + */ +export class AcmeBadRevocationReasonError extends AcmeError { + constructor({ + detail = "The revocation reason provided is not allowed", + error, + message + }: { + detail?: string; + error?: unknown; + message?: string; + } = {}) { + super({ + type: AcmeErrorType.BadRevocationReason, + detail, + status: 400, + error, + message + }); + this.name = "AcmeBadRevocationReasonError"; + } +} + +/** + * rateLimited - The client has exceeded a rate limit (RFC 8555 Section 6.7.9) + */ +export class AcmeRateLimitedError extends AcmeError { + constructor({ + detail = "The client has exceeded a rate limit", + error, + message + }: { + detail?: string; + error?: unknown; + message?: string; + } = {}) { + super({ + type: AcmeErrorType.RateLimited, + detail, + status: 429, + error, + message + }); + this.name = "AcmeRateLimitedError"; + } +} + +/** + * rejectedIdentifier - The server will not issue certificates for the identifier + * (RFC 8555 Section 6.7.10) + */ +export class AcmeRejectedIdentifierError extends AcmeError { + constructor({ + detail = "The server will not issue certificates for the identifier", + subproblems, + error, + message + }: { + detail?: string; + subproblems?: Array<{ type: string; detail: string; identifier?: { type: string; value: string } }>; + error?: unknown; + message?: string; + } = {}) { + super({ + type: AcmeErrorType.RejectedIdentifier, + detail, + status: 400, + subproblems, + error, + message + }); + this.name = "AcmeRejectedIdentifierError"; + } +} + +/** + * serverInternal - An internal error occurred (RFC 8555 Section 6.7.11) + */ +export class AcmeServerInternalError extends AcmeError { + constructor({ + detail = "An internal error occurred", + error, + message + }: { + detail?: string; + error?: unknown; + message?: string; + } = {}) { + super({ + type: AcmeErrorType.ServerInternal, + detail, + status: 500, + error, + message + }); + this.name = "AcmeServerInternalError"; + } +} + +/** + * unsupportedContact - A contact URL is of an unsupported type (RFC 8555 Section 6.7.13) + */ +export class AcmeUnsupportedContactError extends AcmeError { + constructor({ + detail = "A contact URL is of an unsupported type", + error, + message + }: { + detail?: string; + error?: unknown; + message?: string; + } = {}) { + super({ + type: AcmeErrorType.UnsupportedContact, + detail, + status: 400, + error, + message + }); + this.name = "AcmeUnsupportedContactError"; + } +} + +/** + * unsupportedIdentifier - An identifier is of an unsupported type + * (RFC 8555 Section 6.7.14) + */ +export class AcmeUnsupportedIdentifierError extends AcmeError { + constructor({ + detail = "An identifier is of an unsupported type", + error, + message + }: { + detail?: string; + error?: unknown; + message?: string; + } = {}) { + super({ + type: AcmeErrorType.UnsupportedIdentifier, + detail, + status: 400, + error, + message + }); + this.name = "AcmeUnsupportedIdentifierError"; + } +} + +/** + * userActionRequired - Visit the "instance" URL and take actions specified there + * (RFC 8555 Section 6.7.15) + */ +export class AcmeUserActionRequiredError extends AcmeError { + instance?: string; + + constructor({ + detail = "Visit the instance URL and take actions specified there", + instance, + error, + message + }: { + detail?: string; + instance?: string; + error?: unknown; + message?: string; + } = {}) { + super({ + type: AcmeErrorType.UserActionRequired, + detail, + status: 403, + error, + message + }); + this.instance = instance; + this.name = "AcmeUserActionRequiredError"; + } + + toAcmeResponse(): IAcmeError & { instance?: string } { + return { + ...super.toAcmeResponse(), + instance: this.instance + }; + } +} + +/** + * incorrectResponse - The response is incorrect (RFC 8555 Section 6.7.16) + */ +export class AcmeIncorrectResponseError extends AcmeError { + constructor({ + detail = "The response is incorrect", + error, + message + }: { + detail?: string; + error?: unknown; + message?: string; + } = {}) { + super({ + type: AcmeErrorType.IncorrectResponse, + detail, + status: 400, + error, + message + }); + this.name = "AcmeIncorrectResponseError"; + } +} + +/** + * connectionError - A connection error occurred (RFC 8555 Section 6.7.17) + */ +export class AcmeConnectionError extends AcmeError { + constructor({ + detail = "A connection error occurred", + error, + message + }: { + detail?: string; + error?: unknown; + message?: string; + } = {}) { + super({ + type: AcmeErrorType.Connection, + detail, + status: 400, + error, + message + }); + this.name = "AcmeConnectionError"; + } +} + +export class AcmeDnsFailureError extends AcmeError { + constructor({ + detail = "Hostname could not be resolved (DNS failure)", + error, + message + }: { + detail?: string; + error?: unknown; + message?: string; + } = {}) { + super({ + type: AcmeErrorType.DNS, + detail, + status: 400, + error, + message + }); + this.name = "AcmeDnsFailureError"; + } +} + +export class AcmeOrderNotReadyError extends AcmeError { + constructor({ + detail = "The order is not ready", + error, + message + }: { + detail?: string; + error?: unknown; + message?: string; + } = {}) { + super({ + type: AcmeErrorType.OrderNotReady, + detail, + status: 403, + error, + message + }); + this.name = "AcmeOrderNotReadyError"; + } +} + +export class AcmeBadCSRError extends AcmeError { + constructor({ + detail = "The CSR is unacceptable", + error, + message + }: { + detail?: string; + error?: unknown; + message?: string; + } = {}) { + super({ + type: AcmeErrorType.BadCsr, + detail, + status: 400, + error, + message + }); + this.name = "AcmeBadCSRError"; + } +} + +export class AcmeExternalAccountRequiredError extends AcmeError { + constructor({ + detail = "External account binding is required", + error, + message + }: { + detail?: string; + error?: unknown; + message?: string; + } = {}) { + super({ + type: AcmeErrorType.ExternalAccountRequired, + detail, + status: 400, + error, + message + }); + this.name = "AcmeExternalAccountRequiredError"; + } +} diff --git a/backend/src/ee/services/pki-acme/pki-acme-fns.ts b/backend/src/ee/services/pki-acme/pki-acme-fns.ts new file mode 100644 index 000000000..828e0801b --- /dev/null +++ b/backend/src/ee/services/pki-acme/pki-acme-fns.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +import { getConfig } from "@app/lib/config/env"; + +import { AcmeMalformedError } from "./pki-acme-errors"; + +export const buildUrl = (profileId: string, path: string): string => { + const appCfg = getConfig(); + const baseUrl = appCfg.SITE_URL ?? ""; + return `${baseUrl}/api/v1/pki/acme/profiles/${profileId}${path}`; +}; + +export const extractAccountIdFromKid = (kid: string, profileId: string): string => { + const kidPrefix = buildUrl(profileId, "/accounts/"); + if (!kid.startsWith(kidPrefix)) { + throw new AcmeMalformedError({ detail: "KID must start with the profile account URL" }); + } + return z.string().uuid().parse(kid.slice(kidPrefix.length)); +}; diff --git a/backend/src/ee/services/pki-acme/pki-acme-order-auth-dal.ts b/backend/src/ee/services/pki-acme/pki-acme-order-auth-dal.ts new file mode 100644 index 000000000..5a91e6fea --- /dev/null +++ b/backend/src/ee/services/pki-acme/pki-acme-order-auth-dal.ts @@ -0,0 +1,13 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TPkiAcmeOrderAuthDALFactory = ReturnType; + +export const pkiAcmeOrderAuthDALFactory = (db: TDbClient) => { + const pkiAcmeOrderAuthOrm = ormify(db, TableName.PkiAcmeOrderAuth); + + return { + ...pkiAcmeOrderAuthOrm + }; +}; diff --git a/backend/src/ee/services/pki-acme/pki-acme-order-dal.ts b/backend/src/ee/services/pki-acme/pki-acme-order-dal.ts new file mode 100644 index 000000000..5aab0be63 --- /dev/null +++ b/backend/src/ee/services/pki-acme/pki-acme-order-dal.ts @@ -0,0 +1,78 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex"; + +export type TPkiAcmeOrderDALFactory = ReturnType; + +export const pkiAcmeOrderDALFactory = (db: TDbClient) => { + const pkiAcmeOrderOrm = ormify(db, TableName.PkiAcmeOrder); + + const findByIdForFinalization = async (id: string, tx?: Knex) => { + try { + const order = await (tx || db)(TableName.PkiAcmeOrder).forUpdate().where({ id }).first(); + return order || null; + } catch (error) { + throw new DatabaseError({ error, name: "Find PKI ACME order by id for finalization" }); + } + }; + + const findByAccountAndOrderIdWithAuthorizations = async (accountId: string, orderId: string, tx?: Knex) => { + try { + const rows = await (tx || db)(TableName.PkiAcmeOrder) + .join(TableName.PkiAcmeOrderAuth, `${TableName.PkiAcmeOrderAuth}.orderId`, `${TableName.PkiAcmeOrder}.id`) + .join(TableName.PkiAcmeAuth, `${TableName.PkiAcmeOrderAuth}.authId`, `${TableName.PkiAcmeAuth}.id`) + .select( + selectAllTableCols(TableName.PkiAcmeOrder), + db.ref("id").withSchema(TableName.PkiAcmeAuth).as("authId"), + db.ref("identifierType").withSchema(TableName.PkiAcmeAuth).as("identifierType"), + db.ref("identifierValue").withSchema(TableName.PkiAcmeAuth).as("identifierValue"), + db.ref("expiresAt").withSchema(TableName.PkiAcmeAuth).as("authExpiresAt") + ) + .where(`${TableName.PkiAcmeOrder}.id`, orderId) + .where(`${TableName.PkiAcmeOrder}.accountId`, accountId) + .orderBy(`${TableName.PkiAcmeAuth}.identifierValue`, "asc"); + + if (rows.length === 0) { + return null; + } + return sqlNestRelationships({ + data: rows, + key: "id", + parentMapper: (row) => row, + childrenMapper: [ + { + key: "authId", + label: "authorizations" as const, + mapper: ({ authId, identifierType, identifierValue, authExpiresAt }) => ({ + id: authId, + identifierType, + identifierValue, + expiresAt: authExpiresAt + }) + } + ] + })?.[0]; + } catch (error) { + throw new DatabaseError({ error, name: "Find PKI ACME order by id" }); + } + }; + + const listByAccountId = async (accountId: string, tx?: Knex) => { + try { + const orders = await (tx || db)(TableName.PkiAcmeOrder).where({ accountId }).orderBy("createdAt", "desc"); + return orders; + } catch (error) { + throw new DatabaseError({ error, name: "List PKI ACME orders by account id" }); + } + }; + + return { + ...pkiAcmeOrderOrm, + findByIdForFinalization, + findByAccountAndOrderIdWithAuthorizations, + listByAccountId + }; +}; diff --git a/backend/src/ee/services/pki-acme/pki-acme-schemas.ts b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts new file mode 100644 index 000000000..4c7d6c3c1 --- /dev/null +++ b/backend/src/ee/services/pki-acme/pki-acme-schemas.ts @@ -0,0 +1,174 @@ +import RE2 from "re2"; +import { z } from "zod"; + +export enum AcmeIdentifierType { + DNS = "dns" +} + +export enum AcmeOrderStatus { + Pending = "pending", + Processing = "processing", + Ready = "ready", + Valid = "valid", + Invalid = "invalid" +} + +export enum AcmeAuthStatus { + Pending = "pending", + Valid = "valid", + Invalid = "invalid", + Deactivated = "deactivated", + Expired = "expired", + Revoked = "revoked" +} + +export enum AcmeChallengeStatus { + Pending = "pending", + Processing = "processing", + Valid = "valid", + Invalid = "invalid" +} + +export enum AcmeChallengeType { + HTTP_01 = "http-01", + DNS_01 = "dns-01", + TLS_ALPN_01 = "tls-alpn-01" +} + +export const ProtectedHeaderSchema = z + .object({ + alg: z.string(), + nonce: z.string(), + url: z.string(), + kid: z.string().optional(), + jwk: z.record(z.string(), z.string()).optional() + }) + .refine((data) => data.kid || data.jwk, { + message: "Either kid or jwk must be provided", + path: ["kid", "jwk"] + }); + +// Raw JWS payload schema before parsing and verification +export const RawJwsPayloadSchema = z.object({ + protected: z.string(), + payload: z.string(), + signature: z.string() +}); + +export const GetAcmeDirectoryResponseSchema = z.object({ + newNonce: z.string(), + newAccount: z.string(), + newOrder: z.string(), + revokeCert: z.string().optional() +}); + +// New Account payload schema +export const CreateAcmeAccountBodySchema = z.object({ + contact: z.array(z.string()).optional(), + termsOfServiceAgreed: z.boolean().optional(), + onlyReturnExisting: z.boolean().optional(), + externalAccountBinding: RawJwsPayloadSchema.optional() +}); + +// New Account endpoint +export const CreateAcmeAccountSchema = z.object({ + params: z.object({ + profileId: z.string().uuid() + }), + body: CreateAcmeAccountBodySchema +}); + +export const CreateAcmeAccountResponseSchema = z.object({ + status: z.string(), + contact: z.array(z.string()).optional(), + orders: z.string().optional() +}); + +// New Order payload schema +export const CreateAcmeOrderBodySchema = z.object({ + identifiers: z.array( + z.object({ + type: z.enum(Object.values(AcmeIdentifierType) as [string, ...string[]]), + value: z.string().refine((val) => { + // DNS label pattern: 1-63 chars, alphanumeric or hyphen, but not starting or ending with hyphen + const labelPattern = new RE2(/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/); + const labels = val.split("."); + return labels.every((label) => label.length >= 1 && label.length <= 63 && labelPattern.test(label)); + }, "Invalid DNS identifier") + }) + ), + notBefore: z.string().optional(), + notAfter: z.string().optional() +}); + +export const AcmeOrderResourceSchema = z.object({ + status: z.enum(Object.values(AcmeOrderStatus) as [string, ...string[]]), + expires: z.string().optional(), + notBefore: z.string().optional(), + notAfter: z.string().optional(), + identifiers: z.array( + z.object({ + type: z.string(), + value: z.string() + }) + ), + authorizations: z.array(z.string()), + finalize: z.string(), + certificate: z.string().optional() +}); + +// Account Deactivation payload schema +export const DeactivateAcmeAccountBodySchema = z.object({ + status: z.literal("deactivated") +}); + +export const DeactivateAcmeAccountResponseSchema = z.object({ + status: z.string() +}); + +// List Orders endpoint +export const ListAcmeOrdersPayloadSchema = z.object({}).strict(); + +export const ListAcmeOrdersResponseSchema = z.object({ + orders: z.array(z.string()) +}); + +// Finalize Order payload schema +export const FinalizeAcmeOrderBodySchema = z.object({ + csr: z.string() +}); + +export const GetAcmeAuthorizationResponseSchema = z.object({ + status: z.enum(Object.values(AcmeAuthStatus) as [string, ...string[]]), + expires: z.string().optional(), + identifier: z.object({ + type: z.string(), + value: z.string() + }), + challenges: z.array( + z.object({ + type: z.enum(Object.values(AcmeChallengeType) as [string, ...string[]]), + url: z.string(), + status: z.string(), + token: z.string(), + validated: z.string().optional() + }) + ) +}); + +export const RespondToAcmeChallengeBodySchema = z.object({}).strict(); + +export const RespondToAcmeChallengeResponseSchema = z.object({ + type: z.enum(Object.values(AcmeChallengeType) as [string, ...string[]]), + url: z.string(), + status: z.string(), + token: z.string(), + validated: z.string().optional(), + error: z + .object({ + type: z.string(), + detail: z.string(), + status: z.number() + }) + .optional() +}); diff --git a/backend/src/ee/services/pki-acme/pki-acme-service.ts b/backend/src/ee/services/pki-acme/pki-acme-service.ts new file mode 100644 index 000000000..1e7123353 --- /dev/null +++ b/backend/src/ee/services/pki-acme/pki-acme-service.ts @@ -0,0 +1,841 @@ +import * as x509 from "@peculiar/x509"; +import { + calculateJwkThumbprint, + errors, + flattenedVerify, + FlattenedVerifyResult, + importJWK, + JWSHeaderParameters +} from "jose"; +import { z, ZodError } from "zod"; + +import { TPkiAcmeAccounts } from "@app/db/schemas/pki-acme-accounts"; +import { TPkiAcmeAuths } from "@app/db/schemas/pki-acme-auths"; +import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; +import { crypto } from "@app/lib/crypto/cryptography"; +import { BadRequestError, NotFoundError, UnauthorizedError } from "@app/lib/errors"; +import { isPrivateIp } from "@app/lib/ip/ipRange"; +import { logger } from "@app/lib/logger"; +import { ActorType } from "@app/services/auth/auth-type"; +import { TCertificateBodyDALFactory } from "@app/services/certificate/certificate-body-dal"; +import { TCertificateProfileDALFactory } from "@app/services/certificate-profile/certificate-profile-dal"; +import { + EnrollmentType, + TCertificateProfileWithConfigs +} from "@app/services/certificate-profile/certificate-profile-types"; +import { TCertificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-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 { TPkiAcmeAccountDALFactory } from "./pki-acme-account-dal"; +import { TPkiAcmeAuthDALFactory } from "./pki-acme-auth-dal"; +import { TPkiAcmeChallengeDALFactory } from "./pki-acme-challenge-dal"; +import { + AcmeAccountDoesNotExistError, + AcmeBadCSRError, + AcmeBadNonceError, + AcmeBadPublicKeyError, + AcmeError, + AcmeExternalAccountRequiredError, + AcmeMalformedError, + AcmeOrderNotReadyError, + AcmeServerInternalError, + AcmeUnauthorizedError, + AcmeUnsupportedIdentifierError +} from "./pki-acme-errors"; +import { buildUrl, extractAccountIdFromKid } from "./pki-acme-fns"; +import { TPkiAcmeOrderAuthDALFactory } from "./pki-acme-order-auth-dal"; +import { TPkiAcmeOrderDALFactory } from "./pki-acme-order-dal"; +import { + AcmeAuthStatus, + AcmeChallengeStatus, + AcmeChallengeType, + AcmeIdentifierType, + AcmeOrderStatus, + CreateAcmeAccountBodySchema, + ProtectedHeaderSchema +} from "./pki-acme-schemas"; +import { + TAcmeOrderResource, + TAcmeResponse, + TAuthenciatedJwsPayload, + TCreateAcmeAccountPayload, + TCreateAcmeAccountResponse, + TCreateAcmeOrderPayload, + TDeactivateAcmeAccountPayload, + TDeactivateAcmeAccountResponse, + TFinalizeAcmeOrderPayload, + TGetAcmeAuthorizationResponse, + TGetAcmeDirectoryResponse, + TJwsPayload, + TListAcmeOrdersResponse, + TPkiAcmeChallengeServiceFactory, + TPkiAcmeServiceFactory, + TRawJwsPayload, + TRespondToAcmeChallengeResponse +} from "./pki-acme-types"; + +type TPkiAcmeServiceFactoryDep = { + projectDAL: Pick; + certificateProfileDAL: Pick; + certificateBodyDAL: Pick; + acmeAccountDAL: Pick< + TPkiAcmeAccountDALFactory, + "findByProjectIdAndAccountId" | "findByProfileIdAndPublicKeyThumbprintAndAlg" | "create" + >; + acmeOrderDAL: Pick< + TPkiAcmeOrderDALFactory, + | "create" + | "transaction" + | "updateById" + | "findByAccountAndOrderIdWithAuthorizations" + | "findByIdForFinalization" + | "listByAccountId" + >; + acmeAuthDAL: Pick; + acmeOrderAuthDAL: Pick; + acmeChallengeDAL: Pick< + TPkiAcmeChallengeDALFactory, + "create" | "transaction" | "updateById" | "findByAccountAuthAndChallengeId" | "findByIdForChallengeValidation" + >; + keyStore: Pick; + kmsService: Pick; + certificateV3Service: Pick; + acmeChallengeService: TPkiAcmeChallengeServiceFactory; +}; + +export const pkiAcmeServiceFactory = ({ + projectDAL, + certificateProfileDAL, + certificateBodyDAL, + acmeAccountDAL, + acmeOrderDAL, + acmeAuthDAL, + acmeOrderAuthDAL, + acmeChallengeDAL, + keyStore, + kmsService, + certificateV3Service, + acmeChallengeService +}: TPkiAcmeServiceFactoryDep): TPkiAcmeServiceFactory => { + const validateAcmeProfile = async (profileId: string): Promise => { + const profile = await certificateProfileDAL.findByIdWithConfigs(profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + if (profile.enrollmentType !== EnrollmentType.ACME) { + throw new NotFoundError({ message: "Certificate profile is not configured for ACME enrollment" }); + } + return profile; + }; + + const validateJwsPayload = async < + TSchema extends z.ZodSchema | undefined = undefined, + T = TSchema extends z.ZodSchema ? R : string + >({ + url, + rawJwsPayload, + getJWK, + schema + }: { + url: URL; + rawJwsPayload: TRawJwsPayload; + getJWK: (protectedHeader: JWSHeaderParameters) => Promise; + schema?: TSchema; + }): Promise> => { + let result: FlattenedVerifyResult; + try { + result = await flattenedVerify(rawJwsPayload, async (protectedHeader: JWSHeaderParameters | undefined) => { + if (protectedHeader === undefined) { + throw new AcmeMalformedError({ detail: "Protected header is required" }); + } + const jwk = await getJWK(protectedHeader); + const key = await importJWK(jwk, protectedHeader.alg); + return key; + }); + } catch (error) { + if (error instanceof AcmeError) { + throw error; + } + if (error instanceof ZodError) { + throw new AcmeMalformedError({ detail: `Invalid JWS payload: ${error.message}` }); + } + if (error instanceof errors.JWSSignatureVerificationFailed) { + throw new AcmeBadPublicKeyError({ detail: "Invalid JWS payload" }); + } + logger.error(error, "Unexpected error while verifying JWS payload"); + throw new AcmeServerInternalError({ detail: "Failed to verify JWS payload" }); + } + const { protectedHeader: rawProtectedHeader, payload: rawPayload } = result; + try { + const protectedHeader = ProtectedHeaderSchema.parse(rawProtectedHeader); + // Validate the URL + if (new URL(protectedHeader.url).href !== url.href) { + throw new AcmeUnauthorizedError({ detail: "URL mismatch in the protected header" }); + } + // Consume the nonce + if (!protectedHeader.nonce) { + throw new AcmeMalformedError({ detail: "Nonce is required in the protected header" }); + } + const deleted = await keyStore.deleteItem(KeyStorePrefixes.PkiAcmeNonce(protectedHeader.nonce)); + if (deleted !== 1) { + throw new AcmeBadNonceError({ detail: "Invalid nonce" }); + } + + // Parse the payload + const decoder = new TextDecoder(); + const textPayload = decoder.decode(rawPayload); + const payload = schema ? schema.parse(JSON.parse(textPayload)) : textPayload; + return { + protectedHeader, + payload: payload as T + }; + } catch (error) { + if (error instanceof AcmeError) { + throw error; + } + if (error instanceof ZodError) { + throw new AcmeMalformedError({ detail: `Invalid JWS payload: ${error.message}` }); + } + logger.error(error, "Unexpected error while parsing JWS payload"); + throw new AcmeServerInternalError({ detail: "Failed to verify JWS payload" }); + } + }; + + const validateNewAccountJwsPayload = ({ + url, + rawJwsPayload + }: { + url: URL; + rawJwsPayload: TRawJwsPayload; + }): Promise> => { + return validateJwsPayload({ + url, + rawJwsPayload, + getJWK: async (protectedHeader) => { + if (!protectedHeader.jwk) { + throw new AcmeMalformedError({ detail: "JWK is required in the protected header" }); + } + return protectedHeader.jwk as unknown as JsonWebKey; + }, + schema: CreateAcmeAccountBodySchema + }); + }; + + const validateExistingAccountJwsPayload = async < + // eslint-disable-next-line @typescript-eslint/no-explicit-any + TSchema extends z.ZodSchema | undefined = undefined, + T = TSchema extends z.ZodSchema ? R : string + >({ + url, + profileId, + rawJwsPayload, + schema, + expectedAccountId + }: { + url: URL; + profileId: string; + rawJwsPayload: TRawJwsPayload; + schema?: TSchema; + expectedAccountId?: string; + }): Promise> => { + const profile = await validateAcmeProfile(profileId); + const result = await validateJwsPayload({ + url, + rawJwsPayload, + getJWK: async (protectedHeader) => { + if (!protectedHeader.kid) { + throw new AcmeMalformedError({ detail: "KID is required in the protected header" }); + } + const accountId = extractAccountIdFromKid(protectedHeader.kid, profileId); + if (expectedAccountId && accountId !== expectedAccountId) { + throw new NotFoundError({ message: "ACME resource not found" }); + } + const account = await acmeAccountDAL.findByProjectIdAndAccountId(profile.id, accountId); + if (!account) { + throw new AcmeAccountDoesNotExistError({ message: "ACME account not found" }); + } + if (account.alg !== protectedHeader.alg) { + throw new AcmeMalformedError({ detail: "ACME account algorithm mismatch" }); + } + return account.publicKey as JsonWebKey; + }, + schema + }); + return { + ...result, + accountId: extractAccountIdFromKid(result.protectedHeader.kid!, profileId), + profileId + }; + }; + + const buildAcmeOrderResource = ({ + profileId, + order + }: { + order: { + id: string; + status: string; + expiresAt: Date; + notBefore?: Date | null; + notAfter?: Date | null; + authorizations: { + id: string; + identifierType: string; + identifierValue: string; + expiresAt: Date; + }[]; + }; + profileId: string; + }): TAcmeOrderResource => { + return { + status: order.status, + expires: order.expiresAt.toISOString(), + notBefore: order.notBefore?.toISOString(), + notAfter: order.notAfter?.toISOString(), + identifiers: order.authorizations.map((auth) => ({ + type: auth.identifierType, + value: auth.identifierValue + })), + authorizations: order.authorizations.map((auth) => buildUrl(profileId, `/authorizations/${auth.id}`)), + finalize: buildUrl(profileId, `/orders/${order.id}/finalize`), + certificate: + order.status === AcmeOrderStatus.Valid ? buildUrl(profileId, `/orders/${order.id}/certificate`) : undefined + }; + }; + + const getAcmeDirectory = async (profileId: string): Promise => { + const profile = await validateAcmeProfile(profileId); + return { + newNonce: buildUrl(profile.id, "/new-nonce"), + newAccount: buildUrl(profile.id, "/new-account"), + newOrder: buildUrl(profile.id, "/new-order") + }; + }; + + const getAcmeNewNonce = async (profileId: string): Promise => { + await validateAcmeProfile(profileId); + const nonce = crypto.randomBytes(32).toString("base64url"); + const nonceKey = KeyStorePrefixes.PkiAcmeNonce(nonce); + await keyStore.setItemWithExpiry( + nonceKey, + // Expire in 5 minutes. + // TODO: read config from the profile to get the expiration time instead + 60 * 5, + nonce + ); + return nonce; + }; + + /** -------------------------------------------------------------- + * ACME Account + * -------------------------------------------------------------- */ + const createAcmeAccount = async ({ + profileId, + alg, + jwk, + payload: { onlyReturnExisting, contact, externalAccountBinding } + }: { + profileId: string; + alg: string; + jwk: JsonWebKey; + payload: TCreateAcmeAccountPayload; + }): Promise> => { + const profile = await validateAcmeProfile(profileId); + if (!externalAccountBinding) { + throw new AcmeExternalAccountRequiredError({ detail: "External account binding is required" }); + } + + const publicKeyThumbprint = await calculateJwkThumbprint(jwk, "sha256"); + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: profile.projectId, + projectDAL, + kmsService + }); + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + const eabSecret = await kmsDecryptor({ cipherTextBlob: profile.acmeConfig!.encryptedEabSecret! }); + const { eabPayload, eabProtectedHeader } = await (async () => { + try { + const result = await flattenedVerify(externalAccountBinding, eabSecret); + return { eabPayload: result.payload, eabProtectedHeader: result.protectedHeader }; + } catch (error) { + if (error instanceof errors.JWSSignatureVerificationFailed) { + throw new AcmeMalformedError({ detail: "Invalid external account binding JWS signature" }); + } + logger.error(error, "Unexpected error while verifying EAB JWS signature"); + throw new AcmeServerInternalError({ detail: "Failed to verify EAB JWS signature" }); + } + })(); + + const { alg: eabAlg, kid: eabKid } = eabProtectedHeader!; + if (!["HS256", "HS384", "HS512"].includes(eabAlg!)) { + throw new AcmeMalformedError({ detail: "Invalid algorithm for external account binding JWS payload" }); + } + // Make sure the KID in the EAB payload matches the profile ID + if (eabKid !== profile.id) { + throw new UnauthorizedError({ message: "External account binding KID mismatch" }); + } + + // Make sure the URL matches the expected URL + const url = eabProtectedHeader!.url!; + if (url !== buildUrl(profile.id, "/new-account")) { + throw new UnauthorizedError({ message: "External account binding URL mismatch" }); + } + + // Make sure the JWK in the EAB payload matches the one provided in the outer JWS payload + const decoder = new TextDecoder(); + const decodedEabPayload = decoder.decode(eabPayload); + const eabJWK = JSON.parse(decodedEabPayload) as JsonWebKey; + const eabPayloadJwkThumbprint = await calculateJwkThumbprint(eabJWK, "sha256"); + if (eabPayloadJwkThumbprint !== publicKeyThumbprint) { + throw new AcmeBadPublicKeyError({ + message: "External account binding public key thumbprint or algorithm mismatch" + }); + } + + const existingAccount: TPkiAcmeAccounts | null = await acmeAccountDAL.findByProfileIdAndPublicKeyThumbprintAndAlg( + profileId, + alg, + publicKeyThumbprint + ); + if (onlyReturnExisting && !existingAccount) { + throw new AcmeAccountDoesNotExistError({ message: "ACME account not found" }); + } + if (existingAccount) { + // With the same public key, we found an existing account, just return it + return { + status: 200, + body: { + status: "valid", + contact: existingAccount.emails, + orders: buildUrl(profile.id, `/accounts/${existingAccount.id}/orders`) + }, + headers: { + Location: buildUrl(profile.id, `/accounts/${existingAccount.id}`), + Link: `<${buildUrl(profile.id, "/directory")}>;rel="index"` + } + }; + } + + const newAccount = await acmeAccountDAL.create({ + profileId: profile.id, + alg, + publicKey: jwk, + publicKeyThumbprint, + emails: contact ?? [] + }); + // TODO: create audit log here + return { + status: 201, + body: { + status: "valid", + contact: newAccount.emails, + orders: buildUrl(profile.id, `/accounts/${newAccount.id}/orders`) + }, + headers: { + Location: buildUrl(profile.id, `/accounts/${newAccount.id}`), + Link: `<${buildUrl(profile.id, "/directory")}>;rel="index"` + } + }; + }; + + const deactivateAcmeAccount = async ({ + profileId, + accountId + }: { + profileId: string; + accountId: string; + payload?: TDeactivateAcmeAccountPayload; + }): Promise> => { + await validateAcmeProfile(profileId); + // FIXME: Implement ACME account deactivation + return { + status: 200, + body: { + status: "deactivated" + }, + headers: { + Location: buildUrl(profileId, `/accounts/${accountId}`), + Link: `<${buildUrl(profileId, "/directory")}>;rel="index"` + } + }; + }; + + /** -------------------------------------------------------------- + * ACME Order + * -------------------------------------------------------------- */ + const createAcmeOrder = async ({ + profileId, + accountId, + payload + }: { + profileId: string; + accountId: string; + payload: TCreateAcmeOrderPayload; + }): Promise> => { + // TODO: check and see if we have existing orders for this account that meet the criteria + // if we do, return the existing order + // TODO: check the identifiers and see if are they even allowed for this profile. + // if not, we may be able to reject it early with an unsupportedIdentifier error. + + const order = await acmeOrderDAL.transaction(async (tx) => { + const account = (await acmeAccountDAL.findByProjectIdAndAccountId(profileId, accountId))!; + const createdOrder = await acmeOrderDAL.create( + { + accountId: account.id, + status: AcmeOrderStatus.Pending, + notBefore: payload.notBefore ? new Date(payload.notBefore) : undefined, + notAfter: payload.notAfter ? new Date(payload.notAfter) : undefined, + // TODO: read config from the profile to get the expiration time instead + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000) + }, + tx + ); + const authorizations: TPkiAcmeAuths[] = await Promise.all( + payload.identifiers.map(async (identifier) => { + if (identifier.type !== AcmeIdentifierType.DNS) { + throw new AcmeUnsupportedIdentifierError({ detail: "Only DNS identifiers are supported" }); + } + if (isPrivateIp(identifier.value)) { + throw new AcmeUnsupportedIdentifierError({ detail: "Private IP addresses are not allowed" }); + } + const auth = await acmeAuthDAL.create( + { + accountId: account.id, + status: AcmeAuthStatus.Pending, + identifierType: identifier.type, + identifierValue: identifier.value, + // RFC 8555 suggests a token with at least 128 bits of entropy + // We are using 256 bits of entropy here, should be enough for now + // ref: https://datatracker.ietf.org/doc/html/rfc8555#section-11.3 + token: crypto.randomBytes(32).toString("base64url"), + // TODO: read config from the profile to get the expiration time instead + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000) + }, + tx + ); + // TODO: support other challenge types here. Currently only HTTP-01 is supported. + await acmeChallengeDAL.create( + { + authId: auth.id, + status: AcmeChallengeStatus.Pending, + type: AcmeChallengeType.HTTP_01 + }, + tx + ); + return auth; + }) + ); + + await acmeOrderAuthDAL.insertMany( + authorizations.map((auth) => ({ + orderId: createdOrder.id, + authId: auth.id + })), + tx + ); + // TODO: create audit log here + return { ...createdOrder, authorizations, account }; + }); + + return { + status: 201, + body: buildAcmeOrderResource({ + profileId, + order + }), + headers: { + Location: buildUrl(profileId, `/orders/${order.id}`), + Link: `<${buildUrl(profileId, "/directory")}>;rel="index"` + } + }; + }; + + const getAcmeOrder = async ({ + profileId, + accountId, + orderId + }: { + profileId: string; + accountId: string; + orderId: string; + }): Promise> => { + const order = await acmeOrderDAL.findByAccountAndOrderIdWithAuthorizations(accountId, orderId); + if (!order) { + throw new NotFoundError({ message: "ACME order not found" }); + } + return { + status: 200, + body: buildAcmeOrderResource({ profileId, order }), + headers: { + Location: buildUrl(profileId, `/orders/${orderId}`), + Link: `<${buildUrl(profileId, "/directory")}>;rel="index"` + } + }; + }; + + const finalizeAcmeOrder = async ({ + profileId, + accountId, + orderId, + payload + }: { + profileId: string; + accountId: string; + orderId: string; + payload: TFinalizeAcmeOrderPayload; + }): Promise> => { + let order = await acmeOrderDAL.findByAccountAndOrderIdWithAuthorizations(accountId, orderId); + if (!order) { + throw new NotFoundError({ message: "ACME order not found" }); + } + if (order.status === AcmeOrderStatus.Ready) { + const { order: updatedOrder, error } = await acmeOrderDAL.transaction(async (tx) => { + const finalizingOrder = (await acmeOrderDAL.findByIdForFinalization(orderId, tx))!; + // TODO: ideally, this should be doen with onRequest: verifyAuth([AuthMode.ACME_JWS_SIGNATURE]), instead? + const { ownerOrgId: actorOrgId } = (await certificateProfileDAL.findByIdWithOwnerOrgId(profileId, tx))!; + if (finalizingOrder.status !== AcmeOrderStatus.Ready) { + throw new AcmeOrderNotReadyError({ message: "ACME order is not ready" }); + } + if (finalizingOrder.expiresAt < new Date()) { + throw new AcmeOrderNotReadyError({ message: "ACME order has expired" }); + } + const { csr } = payload; + let errorToReturn: Error | undefined; + try { + const { certificateId } = await certificateV3Service.signCertificateFromProfile({ + actor: ActorType.ACME_ACCOUNT, + actorId: accountId, + actorAuthMethod: null, + actorOrgId, + profileId, + csr, + notBefore: finalizingOrder.notBefore ? new Date(finalizingOrder.notBefore) : undefined, + notAfter: finalizingOrder.notAfter ? new Date(finalizingOrder.notAfter) : undefined, + validity: !finalizingOrder.notAfter + ? { + // TODO: read config from the profile to get the expiration time instead + ttl: (24 * 60 * 60 * 1000).toString() + } + : // ttl is not used if notAfter is provided + ({ ttl: "0" } as const), + enrollmentType: EnrollmentType.ACME + }); + // TODO: associate the certificate with the order + await acmeOrderDAL.updateById( + orderId, + { + status: AcmeOrderStatus.Valid, + csr, + certificateId + }, + tx + ); + } catch (exp) { + await acmeOrderDAL.updateById( + orderId, + { + csr, + status: AcmeOrderStatus.Invalid, + error: exp instanceof Error ? exp.message : "Unknown error" + }, + tx + ); + logger.error(exp, "Failed to sign certificate"); + // TODO: audit log the error + if (exp instanceof BadRequestError) { + errorToReturn = new AcmeBadCSRError({ detail: `Invalid CSR: ${exp.message}` }); + } else { + errorToReturn = new AcmeServerInternalError({ detail: "Failed to sign certificate with internal error" }); + } + } + return { + order: (await acmeOrderDAL.findByAccountAndOrderIdWithAuthorizations(accountId, orderId, tx))!, + error: errorToReturn + }; + }); + if (error) { + throw error; + } + order = updatedOrder; + } else if (order.status !== AcmeOrderStatus.Valid) { + throw new AcmeOrderNotReadyError({ message: "ACME order is not ready" }); + } + return { + status: 200, + body: buildAcmeOrderResource({ profileId, order }), + headers: { + Location: buildUrl(profileId, `/orders/${orderId}`), + Link: `<${buildUrl(profileId, "/directory")}>;rel="index"` + } + }; + }; + + const downloadAcmeCertificate = async ({ + profileId, + accountId, + orderId + }: { + profileId: string; + accountId: string; + orderId: string; + }): Promise> => { + const profile = await validateAcmeProfile(profileId); + const order = await acmeOrderDAL.findByAccountAndOrderIdWithAuthorizations(accountId, orderId); + if (!order) { + throw new NotFoundError({ message: "ACME order not found" }); + } + if (order.status !== AcmeOrderStatus.Valid) { + throw new AcmeOrderNotReadyError({ message: "ACME order is not valid" }); + } + if (!order.certificateId) { + throw new NotFoundError({ message: "The certificate for this ACME order no longer exists" }); + } + + const certBody = await certificateBodyDAL.findOne({ certId: order.certificateId }); + const certificateManagerKeyId = await getProjectKmsCertificateKeyId({ + projectId: profile.projectId, + projectDAL, + kmsService + }); + + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKeyId + }); + const decryptedCert = await kmsDecryptor({ + cipherTextBlob: certBody.encryptedCertificate + }); + const certObj = new x509.X509Certificate(decryptedCert); + const decryptedCertChain = await kmsDecryptor({ + cipherTextBlob: certBody.encryptedCertificateChain! + }); + const certificateChain = decryptedCertChain.toString(); + + const certLeaf = certObj.toString("pem").trim().replace("\n", "\r\n"); + const certChain = certificateChain.trim().replace("\n", "\r\n"); + return { + status: 200, + body: + // The final line is needed, otherwise some clients will not parse the certificate chain correctly + // ref: https://github.com/certbot/certbot/blob/4d5d5f7ae8164884c841969e46caed8db1ad34af/certbot/src/certbot/crypto_util.py#L506-L514 + `${certLeaf}\r\n${certChain}\r\n`, + headers: { + Location: buildUrl(profileId, `/orders/${orderId}/certificate`), + Link: `<${buildUrl(profileId, "/directory")}>;rel="index"` + } + }; + }; + + const listAcmeOrders = async ({ + profileId, + accountId + }: { + profileId: string; + accountId: string; + }): Promise> => { + const orders = await acmeOrderDAL.listByAccountId(accountId); + return { + status: 200, + body: { orders: orders.map((order) => buildUrl(profileId, `/orders/${order.id}`)) }, + headers: { + Link: `<${buildUrl(profileId, "/directory")}>;rel="index"` + } + }; + }; + + /** -------------------------------------------------------------- + * ACME Authorization + * -------------------------------------------------------------- */ + const getAcmeAuthorization = async ({ + profileId, + accountId, + authzId + }: { + profileId: string; + accountId: string; + authzId: string; + }): Promise> => { + const auth = await acmeAuthDAL.findByAccountIdAndAuthIdWithChallenges(accountId, authzId); + if (!auth) { + throw new NotFoundError({ message: "ACME authorization not found" }); + } + return { + status: 200, + body: { + status: auth.status, + expires: auth.expiresAt.toISOString(), + identifier: { + type: auth.identifierType, + value: auth.identifierValue + }, + challenges: auth.challenges.map((challenge) => { + return { + type: challenge.type, + url: buildUrl(profileId, `/authorizations/${authzId}/challenges/${challenge.id}`), + status: challenge.status, + token: auth.token! + }; + }) + }, + headers: { + Location: buildUrl(profileId, `/authorizations/${authzId}`), + Link: `<${buildUrl(profileId, "/directory")}>;rel="index"` + } + }; + }; + + const respondToAcmeChallenge = async ({ + profileId, + accountId, + authzId, + challengeId + }: { + profileId: string; + accountId: string; + authzId: string; + challengeId: string; + }): Promise> => { + const result = await acmeChallengeDAL.findByAccountAuthAndChallengeId(accountId, authzId, challengeId); + if (!result) { + throw new NotFoundError({ message: "ACME challenge not found" }); + } + await acmeChallengeService.validateChallengeResponse(challengeId); + const challenge = (await acmeChallengeDAL.findByIdForChallengeValidation(challengeId))!; + return { + status: 200, + body: { + type: challenge.type, + url: buildUrl(profileId, `/authorizations/${authzId}/challenges/${challengeId}`), + status: challenge.status, + token: challenge.auth.token! + }, + headers: { + Location: buildUrl(profileId, `/authorizations/${authzId}/challenges/${challengeId}`), + Link: [ + `<${buildUrl(profileId, `/authorizations/${authzId}`)}>;rel="up"`, + `<${buildUrl(profileId, "/directory")}>;rel="index"` + ] + } + }; + }; + + return { + validateJwsPayload, + validateNewAccountJwsPayload, + validateExistingAccountJwsPayload, + getAcmeDirectory, + getAcmeNewNonce, + createAcmeAccount, + createAcmeOrder, + deactivateAcmeAccount, + listAcmeOrders, + getAcmeOrder, + finalizeAcmeOrder, + downloadAcmeCertificate, + getAcmeAuthorization, + respondToAcmeChallenge + }; +}; diff --git a/backend/src/ee/services/pki-acme/pki-acme-types.ts b/backend/src/ee/services/pki-acme/pki-acme-types.ts new file mode 100644 index 000000000..3ddb424f1 --- /dev/null +++ b/backend/src/ee/services/pki-acme/pki-acme-types.ts @@ -0,0 +1,180 @@ +import { JWSHeaderParameters } from "jose"; +import { z } from "zod"; + +import { + AcmeOrderResourceSchema, + CreateAcmeAccountBodySchema, + CreateAcmeAccountResponseSchema, + CreateAcmeOrderBodySchema, + DeactivateAcmeAccountBodySchema, + DeactivateAcmeAccountResponseSchema, + FinalizeAcmeOrderBodySchema, + GetAcmeAuthorizationResponseSchema, + GetAcmeDirectoryResponseSchema, + ListAcmeOrdersResponseSchema, + ProtectedHeaderSchema, + RawJwsPayloadSchema, + RespondToAcmeChallengeResponseSchema +} from "./pki-acme-schemas"; + +export type TGetAcmeDirectoryResponse = z.infer; +export type TCreateAcmeAccountResponse = z.infer; +export type TAcmeOrderResource = z.infer; +export type TDeactivateAcmeAccountResponse = z.infer; +export type TListAcmeOrdersResponse = z.infer; +export type TDownloadAcmeCertificateDTO = string; +export type TGetAcmeAuthorizationResponse = z.infer; +export type TRespondToAcmeChallengeResponse = z.infer; + +// Payload types +export type TRawJwsPayload = z.infer; +export type TProtectedHeader = z.infer; +export type TCreateAcmeAccountPayload = z.infer; +export type TCreateAcmeOrderPayload = z.infer; +export type TDeactivateAcmeAccountPayload = z.infer; +export type TFinalizeAcmeOrderPayload = z.infer; + +export type TJwsPayload = { + protectedHeader: TProtectedHeader; + payload: T; +}; +export type TAuthenciatedJwsPayload = TJwsPayload & { + profileId: string; + accountId: string; +}; +export type TAcmeResponse = { + status: number; + headers: Record; + body: TPayload; +}; + +export type TPkiAcmeServiceFactory = { + validateJwsPayload: < + TSchema extends z.ZodSchema | undefined = undefined, + T = TSchema extends z.ZodSchema ? R : string + >({ + url, + rawJwsPayload, + getJWK, + schema + }: { + url: URL; + rawJwsPayload: TRawJwsPayload; + getJWK: (protectedHeader: JWSHeaderParameters) => Promise; + schema?: TSchema; + }) => Promise>; + validateNewAccountJwsPayload: ({ + url, + rawJwsPayload + }: { + url: URL; + rawJwsPayload: TRawJwsPayload; + }) => Promise>; + validateExistingAccountJwsPayload: < + TSchema extends z.ZodSchema | undefined = undefined, + T = TSchema extends z.ZodSchema ? R : string + >({ + url, + profileId, + rawJwsPayload, + schema, + expectedAccountId + }: { + url: URL; + profileId: string; + rawJwsPayload: TRawJwsPayload; + schema?: TSchema; + expectedAccountId?: string; + }) => Promise>; + getAcmeDirectory: (profileId: string) => Promise; + getAcmeNewNonce: (profileId: string) => Promise; + createAcmeAccount: ({ + profileId, + alg, + jwk, + payload + }: { + profileId: string; + alg: string; + jwk: JsonWebKey; + payload: TCreateAcmeAccountPayload; + }) => Promise>; + deactivateAcmeAccount: ({ + profileId, + accountId, + payload + }: { + profileId: string; + accountId: string; + payload?: TDeactivateAcmeAccountPayload; + }) => Promise>; + createAcmeOrder: ({ + profileId, + accountId, + payload + }: { + profileId: string; + accountId: string; + payload: TCreateAcmeOrderPayload; + }) => Promise>; + getAcmeOrder: ({ + profileId, + accountId, + orderId + }: { + profileId: string; + accountId: string; + orderId: string; + }) => Promise>; + finalizeAcmeOrder: ({ + profileId, + accountId, + orderId, + payload + }: { + profileId: string; + accountId: string; + orderId: string; + payload: TFinalizeAcmeOrderPayload; + }) => Promise>; + downloadAcmeCertificate: ({ + profileId, + accountId, + orderId + }: { + profileId: string; + accountId: string; + orderId: string; + }) => Promise>; + listAcmeOrders: ({ + profileId, + accountId + }: { + profileId: string; + accountId: string; + }) => Promise>; + getAcmeAuthorization: ({ + profileId, + accountId, + authzId + }: { + profileId: string; + accountId: string; + authzId: string; + }) => Promise>; + respondToAcmeChallenge: ({ + profileId, + accountId, + authzId, + challengeId + }: { + profileId: string; + accountId: string; + authzId: string; + challengeId: string; + }) => Promise>; +}; + +export type TPkiAcmeChallengeServiceFactory = { + validateChallengeResponse: (challengeId: string) => Promise; +}; diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts index db300720f..2610d9324 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-dal.ts @@ -166,6 +166,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { tx.ref("reviewerUserId").withSchema(TableName.SecretApprovalRequestReviewer), tx.ref("status").withSchema(TableName.SecretApprovalRequestReviewer).as("reviewerStatus"), tx.ref("comment").withSchema(TableName.SecretApprovalRequestReviewer).as("reviewerComment"), + tx.ref("createdAt").withSchema(TableName.SecretApprovalRequestReviewer).as("reviewerCreatedAt"), tx.ref("email").withSchema("secretApprovalReviewerUser").as("reviewerEmail"), tx.ref("username").withSchema("secretApprovalReviewerUser").as("reviewerUsername"), tx.ref("firstName").withSchema("secretApprovalReviewerUser").as("reviewerFirstName"), @@ -240,6 +241,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { reviewerUsername: username, reviewerFirstName: firstName, reviewerComment: comment, + reviewerCreatedAt: createdAt, reviewerIsOrgMembershipActive: isOrgMembershipActive }) => userId @@ -251,6 +253,7 @@ export const secretApprovalRequestDALFactory = (db: TDbClient) => { lastName, username, comment: comment ?? "", + createdAt, isOrgMembershipActive } : undefined diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-fns.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-fns.ts index d96fb2e53..8f1c3d060 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-fns.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-fns.ts @@ -37,7 +37,7 @@ export const sendApprovalEmailsFn = async ({ type: NotificationType.SECRET_CHANGE_REQUEST, title: "Secret Change Request", body: `You have a new secret change request pending your review for the project **${project.name}** in the organization **${project.organization.name}**.`, - link: `/projects/secret-management/${project.id}/approval?requestId=${secretApprovalRequest.id}` + link: `/projects/secret-management/${project.id}/approval` })) ); @@ -51,7 +51,7 @@ export const sendApprovalEmailsFn = async ({ firstName: reviewerUser.firstName, projectName: project.name, organizationName: project.organization.name, - approvalUrl: `${cfg.SITE_URL}/projects/secret-management/${project.id}/approval?requestId=${secretApprovalRequest.id}` + approvalUrl: `${cfg.SITE_URL}/projects/secret-management/${project.id}/approval}` }, template: SmtpTemplates.SecretApprovalRequestNeedsReview }); diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts index a10a3f568..e6455c113 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-service.ts @@ -1416,6 +1416,11 @@ export const secretApprovalRequestServiceFactory = ({ const env = await projectEnvDAL.findOne({ id: policy.envId }); const user = await userDAL.findById(actorId); + const projectPath = `/projects/secret-management/${projectId}`; + const approvalPath = `${projectPath}/approval`; + const cfg = getConfig(); + const approvalUrl = `${cfg.SITE_URL}${approvalPath}`; + await triggerWorkflowIntegrationNotification({ input: { projectId, @@ -1427,7 +1432,8 @@ export const secretApprovalRequestServiceFactory = ({ secretPath, projectId, requestId: secretApprovalRequest.id, - secretKeys: [...new Set(Object.values(data).flatMap((arr) => arr?.map((item) => item.secretName) ?? []))] + secretKeys: [...new Set(Object.values(data).flatMap((arr) => arr?.map((item) => item.secretName) ?? []))], + approvalUrl } } }, @@ -1786,6 +1792,11 @@ export const secretApprovalRequestServiceFactory = ({ const user = await userDAL.findById(actorId); const env = await projectEnvDAL.findOne({ id: policy.envId }); + const projectPath = `/projects/secret-management/${project.id}`; + const approvalPath = `${projectPath}/approval`; + const cfg = getConfig(); + const approvalUrl = `${cfg.SITE_URL}${approvalPath}`; + await triggerWorkflowIntegrationNotification({ input: { projectId, @@ -1797,7 +1808,8 @@ export const secretApprovalRequestServiceFactory = ({ secretPath, projectId, requestId: secretApprovalRequest.id, - secretKeys: [...new Set(Object.values(data).flatMap((arr) => arr?.map((item) => item.secretKey) ?? []))] + secretKeys: [...new Set(Object.values(data).flatMap((arr) => arr?.map((item) => item.secretKey) ?? []))], + approvalUrl } } }, diff --git a/backend/src/keystore/keystore.ts b/backend/src/keystore/keystore.ts index 3155fe05c..204952a92 100644 --- a/backend/src/keystore/keystore.ts +++ b/backend/src/keystore/keystore.ts @@ -77,7 +77,9 @@ export const KeyStorePrefixes = { UserProjectPermissionPattern: (userId: string) => `project-permission:*:*:USER:${userId}:*` as const, IdentityProjectPermissionPattern: (identityId: string) => `project-permission:*:*:IDENTITY:${identityId}:*` as const, GroupMemberProjectPermissionPattern: (projectId: string, groupId: string) => - `group-member-project-permission:${projectId}:${groupId}:*` as const + `group-member-project-permission:${projectId}:${groupId}:*` as const, + + PkiAcmeNonce: (nonce: string) => `pki-acme-nonce:${nonce}` as const }; export const KeyStoreTtls = { diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 0cb606cbf..927694ef4 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -62,6 +62,7 @@ export enum ApiDocsTags { PkiCertificateCollections = "PKI Certificate Collections", PkiAlerting = "PKI Alerting", PkiSubscribers = "PKI Subscribers", + PkiAcme = "PKI ACME", SshCertificates = "SSH Certificates", SshCertificateAuthorities = "SSH Certificate Authorities", SshCertificateTemplates = "SSH Certificate Templates", @@ -2308,7 +2309,10 @@ export const AppConnections = { code: "The OAuth code to use to connect with Azure Client Secrets.", tenantId: "The Tenant ID to use to connect with Azure Client Secrets.", clientId: "The Client ID to use to connect with Azure Client Secrets.", - clientSecret: "The Client Secret to use to connect with Azure Client Secrets." + clientSecret: "The Client Secret to use to connect with Azure Client Secrets.", + certificateBody: "The certificate body in PEM format to use to connect with Azure Client Secrets.", + privateKey: + "The private key to use to connect with Azure Client Secrets. This is never transmitted to Azure and is only used to sign the Azure client assertion with." }, AZURE_DEVOPS: { code: "The OAuth code to use to connect with Azure DevOps.", diff --git a/backend/src/lib/config/env.ts b/backend/src/lib/config/env.ts index 9fc4cff92..b60971b1f 100644 --- a/backend/src/lib/config/env.ts +++ b/backend/src/lib/config/env.ts @@ -106,6 +106,21 @@ const envSchema = z HTTPS_ENABLED: zodStrBool, ROTATION_DEVELOPMENT_MODE: zodStrBool.default("false").optional(), DAILY_RESOURCE_CLEAN_UP_DEVELOPMENT_MODE: zodStrBool.default("false").optional(), + // Note: The ACME feature is still in development and is not yet ready for production. + // This is the feature flag to enable/disable the ACME feature. + // It's not intended to be used by users outside of the development team yet. + ACME_FEATURE_ENABLED: zodStrBool.default("false").optional(), + ACME_DEVELOPMENT_MODE: zodStrBool.default("false").optional(), + ACME_DEVELOPMENT_HTTP01_CHALLENGE_HOST_OVERRIDES: zpStr( + z + .string() + .optional() + .transform((val) => { + if (!val) return {}; + return JSON.parse(val) as Record; + }) + .default("{}") + ), // smtp options SMTP_HOST: zpStr(z.string().optional()), SMTP_IGNORE_TLS: zodStrBool.default("false"), @@ -384,6 +399,8 @@ const envSchema = z (data.NODE_ENV === "development" && data.ROTATION_DEVELOPMENT_MODE) || data.NODE_ENV === "test", isDailyResourceCleanUpDevelopmentMode: data.NODE_ENV === "development" && data.DAILY_RESOURCE_CLEAN_UP_DEVELOPMENT_MODE, + isAcmeFeatureEnabled: data.NODE_ENV === "development" && data.ACME_FEATURE_ENABLED === true, + isAcmeDevelopmentMode: data.NODE_ENV === "development" && data.ACME_DEVELOPMENT_MODE, isProductionMode: data.NODE_ENV === "production" || IS_PACKAGED, isRedisSentinelMode: Boolean(data.REDIS_SENTINEL_HOSTS), REDIS_SENTINEL_HOSTS: data.REDIS_SENTINEL_HOSTS?.trim() diff --git a/backend/src/lib/workflow-integrations/notification-handlers/microsoft-teams.ts b/backend/src/lib/workflow-integrations/notification-handlers/microsoft-teams.ts new file mode 100644 index 000000000..0cfe28491 --- /dev/null +++ b/backend/src/lib/workflow-integrations/notification-handlers/microsoft-teams.ts @@ -0,0 +1,92 @@ +import { validateMicrosoftTeamsChannelsSchema } from "@app/services/microsoft-teams/microsoft-teams-fns"; +import { TMicrosoftTeamsServiceFactory } from "@app/services/microsoft-teams/microsoft-teams-service"; +import { + TProjectMicrosoftTeamsConfigDALFactory, + TProjectMicrosoftTeamsConfigWithIntegrations +} from "@app/services/microsoft-teams/project-microsoft-teams-config-dal"; + +import { logger } from "../../logger"; +import { TNotification, TriggerFeature } from "../types"; + +const handleMicrosoftTeamsNotification = async ({ + microsoftTeamsConfig, + notification, + orgId, + microsoftTeamsService +}: { + microsoftTeamsConfig: TProjectMicrosoftTeamsConfigWithIntegrations; + notification: TNotification; + orgId: string; + microsoftTeamsService: Pick; +}): Promise => { + let targetChannels: unknown; + let isEnabled = false; + + switch (notification.type) { + case TriggerFeature.ACCESS_REQUEST: + case TriggerFeature.ACCESS_REQUEST_UPDATED: + targetChannels = microsoftTeamsConfig.accessRequestChannels; + isEnabled = microsoftTeamsConfig.isAccessRequestNotificationEnabled; + break; + case TriggerFeature.SECRET_APPROVAL: + targetChannels = microsoftTeamsConfig.secretRequestChannels; + isEnabled = microsoftTeamsConfig.isSecretRequestNotificationEnabled; + break; + default: + return; + } + + if (isEnabled && targetChannels) { + const { success, data, error: validationError } = validateMicrosoftTeamsChannelsSchema.safeParse(targetChannels); + + if (!success) { + logger.error(validationError, "Invalid Microsoft Teams channel configuration"); + return; + } + + if (data) { + await microsoftTeamsService + .sendNotification({ + notification, + target: data, + tenantId: microsoftTeamsConfig.tenantId, + microsoftTeamsIntegrationId: microsoftTeamsConfig.id, + orgId + }) + .catch((error) => { + logger.error( + error, + `Error sending Microsoft Teams notification. Notification type: ${notification.type}, Tenant ID: ${microsoftTeamsConfig.tenantId}, Project ID: ${microsoftTeamsConfig.projectId}` + ); + }); + } + } +}; + +export const triggerMicrosoftTeamsNotification = async ({ + projectId, + notification, + orgId, + projectMicrosoftTeamsConfigDAL, + microsoftTeamsService +}: { + projectId: string; + notification: TNotification; + orgId: string; + projectMicrosoftTeamsConfigDAL: Pick; + microsoftTeamsService: Pick; +}): Promise => { + try { + const config = await projectMicrosoftTeamsConfigDAL.getIntegrationDetailsByProject(projectId); + if (config) { + await handleMicrosoftTeamsNotification({ + microsoftTeamsConfig: config, + notification, + orgId, + microsoftTeamsService + }); + } + } catch (error) { + logger.error(error, `Error handling Microsoft Teams notification. Project ID: ${projectId}`); + } +}; diff --git a/backend/src/lib/workflow-integrations/notification-handlers/slack.ts b/backend/src/lib/workflow-integrations/notification-handlers/slack.ts new file mode 100644 index 000000000..b55f172cb --- /dev/null +++ b/backend/src/lib/workflow-integrations/notification-handlers/slack.ts @@ -0,0 +1,80 @@ +import { TKmsServiceFactory } from "@app/services/kms/kms-service"; +import { + TProjectSlackConfigDALFactory, + TProjectSlackConfigWithIntegrations +} from "@app/services/slack/project-slack-config-dal"; +import { sendSlackNotification } from "@app/services/slack/slack-fns"; + +import { logger } from "../../logger"; +import { TNotification, TriggerFeature } from "../types"; + +const handleSlackNotification = async ({ + slackConfig, + notification, + orgId, + kmsService +}: { + slackConfig: TProjectSlackConfigWithIntegrations; + notification: TNotification; + orgId: string; + kmsService: Pick; +}): Promise => { + let targetChannelIds: string[] = []; + let isEnabled = false; + + switch (notification.type) { + case TriggerFeature.ACCESS_REQUEST: + case TriggerFeature.ACCESS_REQUEST_UPDATED: + targetChannelIds = slackConfig.accessRequestChannels?.split(", ").filter(Boolean) || []; + isEnabled = slackConfig.isAccessRequestNotificationEnabled; + break; + case TriggerFeature.SECRET_APPROVAL: + targetChannelIds = slackConfig.secretRequestChannels?.split(", ").filter(Boolean) || []; + isEnabled = slackConfig.isSecretRequestNotificationEnabled; + break; + case TriggerFeature.SECRET_SYNC_ERROR: + targetChannelIds = slackConfig.secretSyncErrorChannels?.split(", ").filter(Boolean) || []; + isEnabled = slackConfig.isSecretSyncErrorNotificationEnabled; + break; + default: + return; + } + + if (targetChannelIds.length && isEnabled) { + await sendSlackNotification({ + orgId, + notification, + kmsService, + targetChannelIds, + slackIntegration: slackConfig + }).catch((error) => { + logger.error( + error, + `Error sending Slack notification. Notification type: ${notification.type}, Target channel IDs: ${targetChannelIds.join(", ")}, Project ID: ${slackConfig.projectId}` + ); + }); + } +}; + +export const triggerSlackNotification = async ({ + projectId, + notification, + orgId, + projectSlackConfigDAL, + kmsService +}: { + projectId: string; + notification: TNotification; + orgId: string; + projectSlackConfigDAL: Pick; + kmsService: Pick; +}): Promise => { + try { + const config = await projectSlackConfigDAL.getIntegrationDetailsByProject(projectId); + if (config) { + await handleSlackNotification({ slackConfig: config, notification, orgId, kmsService }); + } + } catch (error) { + logger.error(error, `Error handling Slack notification. Project ID: ${projectId}`); + } +}; diff --git a/backend/src/lib/workflow-integrations/trigger-notification.ts b/backend/src/lib/workflow-integrations/trigger-notification.ts index 355761f73..2dc6f61fe 100644 --- a/backend/src/lib/workflow-integrations/trigger-notification.ts +++ b/backend/src/lib/workflow-integrations/trigger-notification.ts @@ -1,8 +1,7 @@ -import { validateMicrosoftTeamsChannelsSchema } from "@app/services/microsoft-teams/microsoft-teams-fns"; -import { sendSlackNotification } from "@app/services/slack/slack-fns"; - import { logger } from "../logger"; -import { TriggerFeature, TTriggerWorkflowNotificationDTO } from "./types"; +import { triggerMicrosoftTeamsNotification } from "./notification-handlers/microsoft-teams"; +import { triggerSlackNotification } from "./notification-handlers/slack"; +import { TTriggerWorkflowNotificationDTO } from "./types"; export const triggerWorkflowIntegrationNotification = async (dto: TTriggerWorkflowNotificationDTO) => { try { @@ -16,88 +15,25 @@ export const triggerWorkflowIntegrationNotification = async (dto: TTriggerWorkfl return; } - const microsoftTeamsConfig = await projectMicrosoftTeamsConfigDAL.getIntegrationDetailsByProject(projectId); - const slackConfig = await projectSlackConfigDAL.getIntegrationDetailsByProject(projectId); + const handlerPromises = [ + triggerSlackNotification({ + projectId, + notification, + orgId: project.orgId, + projectSlackConfigDAL, + kmsService + }), - if (slackConfig) { - if ( - notification.type === TriggerFeature.ACCESS_REQUEST || - notification.type === TriggerFeature.ACCESS_REQUEST_UPDATED - ) { - const targetChannelIds = slackConfig.accessRequestChannels?.split(", ") || []; - if (targetChannelIds.length && slackConfig.isAccessRequestNotificationEnabled) { - await sendSlackNotification({ - orgId: project.orgId, - notification, - kmsService, - targetChannelIds, - slackIntegration: slackConfig - }).catch((error) => { - logger.error(error, "Error sending Slack notification"); - }); - } - } else if (notification.type === TriggerFeature.SECRET_APPROVAL) { - const targetChannelIds = slackConfig.secretRequestChannels?.split(", ") || []; - if (targetChannelIds.length && slackConfig.isSecretRequestNotificationEnabled) { - await sendSlackNotification({ - orgId: project.orgId, - notification, - kmsService, - targetChannelIds, - slackIntegration: slackConfig - }).catch((error) => { - logger.error(error, "Error sending Slack notification"); - }); - } - } - } + triggerMicrosoftTeamsNotification({ + projectId, + notification, + orgId: project.orgId, + projectMicrosoftTeamsConfigDAL, + microsoftTeamsService + }) + ]; - if (microsoftTeamsConfig) { - if ( - notification.type === TriggerFeature.ACCESS_REQUEST || - notification.type === TriggerFeature.ACCESS_REQUEST_UPDATED - ) { - if (microsoftTeamsConfig.isAccessRequestNotificationEnabled && microsoftTeamsConfig.accessRequestChannels) { - const { success, data } = validateMicrosoftTeamsChannelsSchema.safeParse( - microsoftTeamsConfig.accessRequestChannels - ); - - if (success && data) { - await microsoftTeamsService - .sendNotification({ - notification, - target: data, - tenantId: microsoftTeamsConfig.tenantId, - microsoftTeamsIntegrationId: microsoftTeamsConfig.id, - orgId: project.orgId - }) - .catch((error) => { - logger.error(error, "Error sending Microsoft Teams notification"); - }); - } - } - } else if (notification.type === TriggerFeature.SECRET_APPROVAL) { - if (microsoftTeamsConfig.isSecretRequestNotificationEnabled && microsoftTeamsConfig.secretRequestChannels) { - const { success, data } = validateMicrosoftTeamsChannelsSchema.safeParse( - microsoftTeamsConfig.secretRequestChannels - ); - - if (success && data) { - await microsoftTeamsService - .sendNotification({ - notification, - target: data, - tenantId: microsoftTeamsConfig.tenantId, - microsoftTeamsIntegrationId: microsoftTeamsConfig.id, - orgId: project.orgId - }) - .catch((error) => { - logger.error(error, "Error sending Microsoft Teams notification"); - }); - } - } - } - } + await Promise.allSettled(handlerPromises); } catch (error) { logger.error(error, "Error triggering workflow integration notification"); } diff --git a/backend/src/lib/workflow-integrations/types.ts b/backend/src/lib/workflow-integrations/types.ts index f8f55eadd..6d81c9174 100644 --- a/backend/src/lib/workflow-integrations/types.ts +++ b/backend/src/lib/workflow-integrations/types.ts @@ -7,7 +7,8 @@ import { TProjectSlackConfigDALFactory } from "@app/services/slack/project-slack export enum TriggerFeature { SECRET_APPROVAL = "secret-approval", ACCESS_REQUEST = "access-request", - ACCESS_REQUEST_UPDATED = "access-request-updated" + ACCESS_REQUEST_UPDATED = "access-request-updated", + SECRET_SYNC_ERROR = "secret-sync-error" } export type TNotification = @@ -20,6 +21,7 @@ export type TNotification = requestId: string; projectId: string; secretKeys: string[]; + approvalUrl: string; }; } | { @@ -31,6 +33,7 @@ export type TNotification = secretPath: string; environment: string; projectName: string; + projectPath: string; permissions: string[]; approvalUrl: string; note?: string; @@ -50,6 +53,21 @@ export type TNotification = editNote?: string; editorFullName?: string; editorEmail?: string; + projectPath: string; + }; + } + | { + type: TriggerFeature.SECRET_SYNC_ERROR; + payload: { + syncName: string; + syncActionLabel: string; + syncDestination: string; + failureMessage: string; + syncUrl: string; + environment: string; + secretPath: string; + projectName: string; + projectPath: string; }; }; diff --git a/backend/src/queue/queue-service.ts b/backend/src/queue/queue-service.ts index 3e6b1dd19..8cf8555f9 100644 --- a/backend/src/queue/queue-service.ts +++ b/backend/src/queue/queue-service.ts @@ -51,6 +51,7 @@ export enum QueueName { AuditLogPrune = "audit-log-prune", DailyResourceCleanUp = "daily-resource-cleanup", DailyExpiringPkiItemAlert = "daily-expiring-pki-item-alert", + DailyPkiAlertV2Processing = "daily-pki-alert-v2-processing", PkiSyncCleanup = "pki-sync-cleanup", PkiSubscriber = "pki-subscriber", TelemetryInstanceStats = "telemtry-self-hosted-stats", @@ -90,6 +91,7 @@ export enum QueueJobs { AuditLogPrune = "audit-log-prune-job", DailyResourceCleanUp = "daily-resource-cleanup-job", DailyExpiringPkiItemAlert = "daily-expiring-pki-item-alert", + DailyPkiAlertV2Processing = "daily-pki-alert-v2-processing", PkiSyncCleanup = "pki-sync-cleanup-job", SecWebhook = "secret-webhook-trigger", TelemetryInstanceStats = "telemetry-self-hosted-stats", @@ -159,6 +161,10 @@ export type TQueueJobTypes = { name: QueueJobs.DailyExpiringPkiItemAlert; payload: undefined; }; + [QueueName.DailyPkiAlertV2Processing]: { + name: QueueJobs.DailyPkiAlertV2Processing; + payload: undefined; + }; [QueueName.PkiSyncCleanup]: { name: QueueJobs.PkiSyncCleanup; payload: undefined; diff --git a/backend/src/server/plugins/add-errors-to-response-schemas.ts b/backend/src/server/plugins/add-errors-to-response-schemas.ts index 8eb358a1b..6337bae0f 100644 --- a/backend/src/server/plugins/add-errors-to-response-schemas.ts +++ b/backend/src/server/plugins/add-errors-to-response-schemas.ts @@ -6,9 +6,16 @@ import { DefaultResponseErrorsSchema } from "../routes/sanitizedSchemas"; const isScimRoutes = (pathname: string) => pathname.startsWith("/api/v1/scim/Users") || pathname.startsWith("/api/v1/scim/Groups"); +const isAcmeRoutes = (pathname: string) => pathname.startsWith("/api/v1/pki/acme/"); + export const addErrorsToResponseSchemas = fp(async (server) => { server.addHook("onRoute", (routeOptions) => { - if (routeOptions.schema && routeOptions.schema.response && !isScimRoutes(routeOptions.path)) { + if ( + routeOptions.schema && + routeOptions.schema.response && + !isScimRoutes(routeOptions.path) && + !isAcmeRoutes(routeOptions.path) + ) { routeOptions.schema.response = { ...DefaultResponseErrorsSchema, ...routeOptions.schema.response diff --git a/backend/src/server/plugins/error-handler.ts b/backend/src/server/plugins/error-handler.ts index 8d10a8630..4b29f6930 100644 --- a/backend/src/server/plugins/error-handler.ts +++ b/backend/src/server/plugins/error-handler.ts @@ -5,6 +5,7 @@ import fastifyPlugin from "fastify-plugin"; import jwt from "jsonwebtoken"; import { ZodError } from "zod"; +import { AcmeError } from "@app/ee/services/pki-acme/pki-acme-errors"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError, @@ -242,6 +243,19 @@ export const fastifyErrHandler = fastifyPlugin(async (server: FastifyZodProvider error: "TokenError", message: errorMessage }); + } else if (error instanceof AcmeError) { + void res + .type("application/problem+json") + .status(error.status) + .send({ + reqId: req.id, + error: error.name, + status: error.status, + type: `urn:ietf:params:acme:error:${error.type}`, + detail: error.detail, + message: error.message + // TODO: add subproblems if they exist + }); } else { void res.status(HttpStatusCodes.InternalServerError).send({ reqId: req.id, diff --git a/backend/src/server/plugins/serve-ui.ts b/backend/src/server/plugins/serve-ui.ts index b71451b6e..4330f9397 100644 --- a/backend/src/server/plugins/serve-ui.ts +++ b/backend/src/server/plugins/serve-ui.ts @@ -31,7 +31,10 @@ export const registerServeUI = async ( CAPTCHA_SITE_KEY: appCfg.CAPTCHA_SITE_KEY, POSTHOG_API_KEY: appCfg.POSTHOG_PROJECT_API_KEY, INTERCOM_ID: appCfg.INTERCOM_ID, - TELEMETRY_CAPTURING_ENABLED: appCfg.TELEMETRY_ENABLED + TELEMETRY_CAPTURING_ENABLED: appCfg.TELEMETRY_ENABLED, + // The feature flag to enable/disable the ACME feature. + // Will be removed once the feature is ready for production. + ACME_FEATURE_ENABLED: appCfg.isAcmeFeatureEnabled }; const js = `window.__INFISICAL_RUNTIME_ENV__ = Object.freeze(${JSON.stringify(config)});`; return res.send(js); diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 6bd37e991..a0309eb3e 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -74,6 +74,13 @@ import { pamSessionServiceFactory } from "@app/ee/services/pam-session/pam-sessi import { permissionDALFactory } from "@app/ee/services/permission/permission-dal"; import { permissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { pitServiceFactory } from "@app/ee/services/pit/pit-service"; +import { pkiAcmeAccountDALFactory } from "@app/ee/services/pki-acme/pki-acme-account-dal"; +import { pkiAcmeAuthDALFactory } from "@app/ee/services/pki-acme/pki-acme-auth-dal"; +import { pkiAcmeChallengeDALFactory } from "@app/ee/services/pki-acme/pki-acme-challenge-dal"; +import { pkiAcmeChallengeServiceFactory } from "@app/ee/services/pki-acme/pki-acme-challenge-service"; +import { pkiAcmeOrderAuthDALFactory } from "@app/ee/services/pki-acme/pki-acme-order-auth-dal"; +import { pkiAcmeOrderDALFactory } from "@app/ee/services/pki-acme/pki-acme-order-dal"; +import { pkiAcmeServiceFactory } from "@app/ee/services/pki-acme/pki-acme-service"; import { projectTemplateDALFactory } from "@app/ee/services/project-template/project-template-dal"; import { projectTemplateServiceFactory } from "@app/ee/services/project-template/project-template-service"; import { rateLimitDALFactory } from "@app/ee/services/rate-limit/rate-limit-dal"; @@ -182,6 +189,7 @@ import { certificateV3QueueServiceFactory } from "@app/services/certificate-v3/c import { certificateV3ServiceFactory } from "@app/services/certificate-v3/certificate-v3-service"; import { cmekServiceFactory } from "@app/services/cmek/cmek-service"; import { convertorServiceFactory } from "@app/services/convertor/convertor-service"; +import { acmeEnrollmentConfigDALFactory } from "@app/services/enrollment-config/acme-enrollment-config-dal"; import { apiEnrollmentConfigDALFactory } from "@app/services/enrollment-config/api-enrollment-config-dal"; import { estEnrollmentConfigDALFactory } from "@app/services/enrollment-config/est-enrollment-config-dal"; import { externalGroupOrgRoleMappingDALFactory } from "@app/services/external-group-org-role-mapping/external-group-org-role-mapping-dal"; @@ -267,6 +275,11 @@ import { pamAccountRotationServiceFactory } from "@app/services/pam-account-rota import { dailyExpiringPkiItemAlertQueueServiceFactory } from "@app/services/pki-alert/expiring-pki-item-alert-queue"; import { pkiAlertDALFactory } from "@app/services/pki-alert/pki-alert-dal"; import { pkiAlertServiceFactory } from "@app/services/pki-alert/pki-alert-service"; +import { pkiAlertChannelDALFactory } from "@app/services/pki-alert-v2/pki-alert-channel-dal"; +import { pkiAlertHistoryDALFactory } from "@app/services/pki-alert-v2/pki-alert-history-dal"; +import { pkiAlertV2DALFactory } from "@app/services/pki-alert-v2/pki-alert-v2-dal"; +import { pkiAlertV2QueueServiceFactory } from "@app/services/pki-alert-v2/pki-alert-v2-queue"; +import { pkiAlertV2ServiceFactory } from "@app/services/pki-alert-v2/pki-alert-v2-service"; import { pkiCollectionDALFactory } from "@app/services/pki-collection/pki-collection-dal"; import { pkiCollectionItemDALFactory } from "@app/services/pki-collection/pki-collection-item-dal"; import { pkiCollectionServiceFactory } from "@app/services/pki-collection/pki-collection-service"; @@ -547,6 +560,9 @@ export const registerRoutes = async ( const additionalPrivilegeDAL = additionalPrivilegeDALFactory(db); const membershipRoleDAL = membershipRoleDALFactory(db); const roleDAL = roleDALFactory(db); + const pkiAlertHistoryDAL = pkiAlertHistoryDALFactory(db); + const pkiAlertChannelDAL = pkiAlertChannelDALFactory(db); + const pkiAlertV2DAL = pkiAlertV2DALFactory(db); const vaultExternalMigrationConfigDAL = vaultExternalMigrationConfigDALFactory(db); @@ -1061,7 +1077,12 @@ export const registerRoutes = async ( const certificateProfileDAL = certificateProfileDALFactory(db); const apiEnrollmentConfigDAL = apiEnrollmentConfigDALFactory(db); const estEnrollmentConfigDAL = estEnrollmentConfigDALFactory(db); - + const acmeEnrollmentConfigDAL = acmeEnrollmentConfigDALFactory(db); + const acmeAccountDAL = pkiAcmeAccountDALFactory(db); + const acmeOrderDAL = pkiAcmeOrderDALFactory(db); + const acmeAuthDAL = pkiAcmeAuthDALFactory(db); + const acmeOrderAuthDAL = pkiAcmeOrderAuthDALFactory(db); + const acmeChallengeDAL = pkiAcmeChallengeDALFactory(db); const certificateDAL = certificateDALFactory(db); const certificateBodyDAL = certificateBodyDALFactory(db); const certificateSecretDAL = certificateSecretDALFactory(db); @@ -1156,6 +1177,11 @@ export const registerRoutes = async ( certificateTemplateV2DAL, apiEnrollmentConfigDAL, estEnrollmentConfigDAL, + acmeEnrollmentConfigDAL, + certificateBodyDAL, + certificateSecretDAL, + certificateAuthorityDAL, + certificateAuthorityCertDAL, permissionService, kmsService, projectDAL @@ -1251,7 +1277,10 @@ export const registerRoutes = async ( licenseService, gatewayService, gatewayV2Service, - notificationService + notificationService, + projectSlackConfigDAL, + projectMicrosoftTeamsConfigDAL, + microsoftTeamsService }); const secretQueueService = secretQueueFactory({ @@ -1801,6 +1830,21 @@ export const registerRoutes = async ( groupDAL }); + const pkiAlertV2Service = pkiAlertV2ServiceFactory({ + pkiAlertV2DAL, + pkiAlertChannelDAL, + pkiAlertHistoryDAL, + permissionService, + smtpService + }); + + const pkiAlertV2Queue = pkiAlertV2QueueServiceFactory({ + queueService, + pkiAlertV2Service, + pkiAlertV2DAL, + pkiAlertHistoryDAL + }); + const dynamicSecretProviders = buildDynamicSecretProviders({ gatewayService, gatewayV2Service @@ -2152,6 +2196,7 @@ export const registerRoutes = async ( certificateAuthorityDAL, certificateProfileDAL, certificateTemplateV2Service, + acmeAccountDAL, internalCaService: internalCertificateAuthorityService, permissionService, certificateSyncDAL, @@ -2178,6 +2223,24 @@ export const registerRoutes = async ( estEnrollmentConfigDAL }); + const acmeChallengeService = pkiAcmeChallengeServiceFactory({ + acmeChallengeDAL + }); + const pkiAcmeService = pkiAcmeServiceFactory({ + projectDAL, + certificateProfileDAL, + certificateBodyDAL, + acmeAccountDAL, + acmeOrderDAL, + acmeAuthDAL, + acmeOrderAuthDAL, + acmeChallengeDAL, + keyStore, + kmsService, + certificateV3Service, + acmeChallengeService + }); + const pkiSubscriberService = pkiSubscriberServiceFactory({ pkiSubscriberDAL, certificateAuthorityDAL, @@ -2355,6 +2418,7 @@ export const registerRoutes = async ( await dailyReminderQueueService.startSecretReminderMigrationJob(); await dailyExpiringPkiItemAlert.startSendingAlerts(); await pkiSubscriberQueue.startDailyAutoRenewalJob(); + await pkiAlertV2Queue.init(); await certificateV3Queue.init(); await kmsService.startService(hsmStatus); await microsoftTeamsService.start(); @@ -2433,6 +2497,7 @@ export const registerRoutes = async ( certificateProfile: certificateProfileService, certificateAuthorityCrl: certificateAuthorityCrlService, certificateEst: certificateEstService, + pkiAcme: pkiAcmeService, pit: pitService, pkiAlert: pkiAlertService, pkiCollection: pkiCollectionService, @@ -2486,7 +2551,8 @@ export const registerRoutes = async ( role: roleService, additionalPrivilege: additionalPrivilegeService, identityProject: identityProjectService, - convertor: convertorService + convertor: convertorService, + pkiAlertV2: pkiAlertV2Service }); const cronJobs: CronJob[] = []; diff --git a/backend/src/server/routes/v1/certificate-profiles-router.ts b/backend/src/server/routes/v1/certificate-profiles-router.ts index 08f532bc4..5792c5e83 100644 --- a/backend/src/server/routes/v1/certificate-profiles-router.ts +++ b/backend/src/server/routes/v1/certificate-profiles-router.ts @@ -44,7 +44,8 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid autoRenew: z.boolean().default(false), renewBeforeDays: z.number().min(1).max(30).optional() }) - .optional() + .optional(), + acmeConfig: z.object({}).optional() }) .refine( (data) => { @@ -55,6 +56,9 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid if (data.apiConfig) { return false; } + if (data.acmeConfig) { + return false; + } } if (data.enrollmentType === EnrollmentType.API) { if (!data.apiConfig) { @@ -63,12 +67,26 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid 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 true; }, { message: - "EST enrollment type requires EST configuration and cannot have API configuration. API enrollment type requires API configuration and cannot have EST configuration." + "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." } ), response: { @@ -150,6 +168,12 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid autoRenew: z.boolean(), renewBeforeDays: z.number().optional() }) + .optional(), + acmeConfig: z + .object({ + id: z.string(), + directoryUrl: z.string() + }) .optional() }).array(), totalCount: z.number() @@ -473,4 +497,101 @@ export const registerCertificateProfilesRouter = async (server: FastifyZodProvid return { certificates }; } }); + + server.route({ + method: "GET", + url: "/:id/certificates/latest-active-bundle", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateProfiles], + description: "Get latest active certificate bundle for a profile", + params: z.object({ + id: z.string().uuid() + }), + response: { + 200: z.object({ + certificate: z.string().nullable(), + certificateChain: z.string().nullable(), + privateKey: z.string().nullable(), + serialNumber: z.string().nullable() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const response = await server.services.certificateProfile.getLatestActiveCertificateBundle({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: req.params.id + }); + + if (!response) { + return { + certificate: null, + certificateChain: null, + privateKey: null, + serialNumber: null + }; + } + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: response.certObj.projectId, + event: { + type: EventType.GET_CERTIFICATE_PROFILE_LATEST_ACTIVE_BUNDLE, + metadata: { + certificateProfileId: response.profile.id, + certificateId: response.certObj.id, + commonName: response.certObj.commonName, + profileName: response.profile.slug, + serialNumber: response.certObj.serialNumber + } + } + }); + + return { + certificate: response.certificate, + certificateChain: response.certificateChain, + privateKey: response.privateKey, + serialNumber: response.certObj.serialNumber + }; + } + }); + + server.route({ + method: "GET", + url: "/:id/acme/eab-secret/reveal", + config: { + rateLimit: readLimit + }, + schema: { + hide: false, + tags: [ApiDocsTags.PkiCertificateProfiles], + params: z.object({ + id: z.string().uuid() + }), + response: { + 200: z.object({ + eabKid: z.string(), + eabSecret: z.string() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { eabKid, eabSecret } = await server.services.certificateProfile.revealAcmeEabSecret({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + profileId: req.params.id + }); + return { eabKid, eabSecret }; + } + }); }; diff --git a/backend/src/server/routes/v1/deprecated-project-router.ts b/backend/src/server/routes/v1/deprecated-project-router.ts index 687d95b9b..f6efdb78a 100644 --- a/backend/src/server/routes/v1/deprecated-project-router.ts +++ b/backend/src/server/routes/v1/deprecated-project-router.ts @@ -614,8 +614,10 @@ export const registerDeprecatedProjectRouter = async (server: FastifyZodProvider integrationId: z.string(), accessRequestChannels: validateSlackChannelsField, secretRequestChannels: validateSlackChannelsField, + secretSyncErrorChannels: validateSlackChannelsField, isAccessRequestNotificationEnabled: z.boolean(), - isSecretRequestNotificationEnabled: z.boolean() + isSecretRequestNotificationEnabled: z.boolean(), + isSecretSyncErrorNotificationEnabled: z.boolean() }), z.object({ integration: z.literal(WorkflowIntegration.MICROSOFT_TEAMS), @@ -633,7 +635,9 @@ export const registerDeprecatedProjectRouter = async (server: FastifyZodProvider isAccessRequestNotificationEnabled: true, accessRequestChannels: true, isSecretRequestNotificationEnabled: true, - secretRequestChannels: true + secretRequestChannels: true, + isSecretSyncErrorNotificationEnabled: true, + secretSyncErrorChannels: true }).merge( z.object({ integration: z.literal(WorkflowIntegration.SLACK), diff --git a/backend/src/server/routes/v1/pki-alert-router.ts b/backend/src/server/routes/v1/pki-alert-router.ts index 43ce91e88..fde179981 100644 --- a/backend/src/server/routes/v1/pki-alert-router.ts +++ b/backend/src/server/routes/v1/pki-alert-router.ts @@ -6,6 +6,7 @@ import { ALERTS, ApiDocsTags } from "@app/lib/api-docs"; import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { AuthMode } from "@app/services/auth/auth-type"; +import { PkiAlertEventType } from "@app/services/pki-alert-v2/pki-alert-v2-types"; export const registerPkiAlertRouter = async (server: FastifyZodProvider) => { server.route({ @@ -52,7 +53,8 @@ export const registerPkiAlertRouter = async (server: FastifyZodProvider) => { pkiAlertId: alert.id, pkiCollectionId: alert.pkiCollectionId, name: alert.name, - alertBeforeDays: alert.alertBeforeDays, + alertBefore: alert.alertBeforeDays.toString(), + eventType: PkiAlertEventType.EXPIRATION, recipientEmails: alert.recipientEmails } } @@ -152,7 +154,8 @@ export const registerPkiAlertRouter = async (server: FastifyZodProvider) => { pkiAlertId: alert.id, pkiCollectionId: alert.pkiCollectionId, name: alert.name, - alertBeforeDays: alert.alertBeforeDays, + alertBefore: alert.alertBeforeDays.toString(), + eventType: PkiAlertEventType.EXPIRATION, recipientEmails: alert.recipientEmails } } diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index 1054d359b..70548395d 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -769,7 +769,9 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { isAccessRequestNotificationEnabled: true, accessRequestChannels: true, isSecretRequestNotificationEnabled: true, - secretRequestChannels: true + secretRequestChannels: true, + isSecretSyncErrorNotificationEnabled: true, + secretSyncErrorChannels: true }).merge( z.object({ integration: z.literal(WorkflowIntegration.SLACK), @@ -873,7 +875,9 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { accessRequestChannels: validateSlackChannelsField, secretRequestChannels: validateSlackChannelsField, isAccessRequestNotificationEnabled: z.boolean(), - isSecretRequestNotificationEnabled: z.boolean() + isSecretRequestNotificationEnabled: z.boolean(), + secretSyncErrorChannels: validateSlackChannelsField, + isSecretSyncErrorNotificationEnabled: z.boolean() }), z.object({ integration: z.literal(WorkflowIntegration.MICROSOFT_TEAMS), @@ -891,7 +895,9 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { isAccessRequestNotificationEnabled: true, accessRequestChannels: true, isSecretRequestNotificationEnabled: true, - secretRequestChannels: true + secretRequestChannels: true, + isSecretSyncErrorNotificationEnabled: true, + secretSyncErrorChannels: true }).merge( z.object({ integration: z.literal(WorkflowIntegration.SLACK), diff --git a/backend/src/server/routes/v2/index.ts b/backend/src/server/routes/v2/index.ts index db4ebb176..d3d91a3ba 100644 --- a/backend/src/server/routes/v2/index.ts +++ b/backend/src/server/routes/v2/index.ts @@ -8,6 +8,7 @@ import { registerIdentityOrgRouter } from "./identity-org-router"; import { registerMfaRouter } from "./mfa-router"; import { registerOrgRouter } from "./organization-router"; import { registerPasswordRouter } from "./password-router"; +import { registerPkiAlertRouter } from "./pki-alert-router"; import { registerPkiTemplatesRouter } from "./pki-templates-router"; import { registerSecretFolderRouter } from "./secret-folder-router"; import { registerSecretImportRouter } from "./secret-import-router"; @@ -26,6 +27,7 @@ export const registerV2Routes = async (server: FastifyZodProvider) => { async (pkiRouter) => { await pkiRouter.register(registerCaRouter, { prefix: "/ca" }); await pkiRouter.register(registerPkiTemplatesRouter, { prefix: "/certificate-templates" }); + await pkiRouter.register(registerPkiAlertRouter, { prefix: "/alerts" }); }, { prefix: "/pki" } ); diff --git a/backend/src/server/routes/v2/pki-alert-router.ts b/backend/src/server/routes/v2/pki-alert-router.ts new file mode 100644 index 000000000..459b5cc6c --- /dev/null +++ b/backend/src/server/routes/v2/pki-alert-router.ts @@ -0,0 +1,443 @@ +import { z } from "zod"; + +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { ApiDocsTags } from "@app/lib/api-docs"; +import { readLimit, writeLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; +import { + CreatePkiAlertV2Schema, + createSecureAlertBeforeValidator, + PkiAlertChannelType, + PkiAlertEventType, + PkiFilterRuleSchema, + UpdatePkiAlertV2Schema +} from "@app/services/pki-alert-v2/pki-alert-v2-types"; + +export const registerPkiAlertRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "POST", + url: "/", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Create a new PKI alert", + tags: [ApiDocsTags.PkiAlerting], + body: CreatePkiAlertV2Schema.extend({ + projectId: z.string().uuid().describe("Project ID") + }), + response: { + 200: z.object({ + alert: z.object({ + id: z.string().uuid(), + name: z.string(), + description: z.string().nullable(), + eventType: z.nativeEnum(PkiAlertEventType), + alertBefore: z.string(), + filters: z.array(PkiFilterRuleSchema), + enabled: z.boolean(), + projectId: z.string().uuid(), + channels: z.array( + z.object({ + id: z.string().uuid(), + channelType: z.nativeEnum(PkiAlertChannelType), + config: z.record(z.any()), + enabled: z.boolean(), + createdAt: z.date(), + updatedAt: z.date() + }) + ), + createdAt: z.date(), + updatedAt: z.date() + }) + }) + } + }, + handler: async (req) => { + const alert = await server.services.pkiAlertV2.createAlert({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.body.projectId, + event: { + type: EventType.CREATE_PKI_ALERT, + metadata: { + pkiAlertId: alert.id, + name: alert.name, + eventType: alert.eventType, + alertBefore: alert.alertBefore + } + } + }); + + return { alert }; + } + }); + + server.route({ + method: "GET", + url: "/", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "List PKI alerts for a project", + tags: [ApiDocsTags.PkiAlerting], + querystring: z.object({ + projectId: z.string().uuid(), + search: z.string().optional(), + eventType: z.nativeEnum(PkiAlertEventType).optional(), + enabled: z.coerce.boolean().optional(), + limit: z.coerce.number().min(1).max(100).default(20), + offset: z.coerce.number().min(0).default(0) + }), + response: { + 200: z.object({ + alerts: z.array( + z.object({ + id: z.string().uuid(), + name: z.string(), + description: z.string().nullable(), + eventType: z.nativeEnum(PkiAlertEventType), + alertBefore: z.string(), + filters: z.array(PkiFilterRuleSchema), + enabled: z.boolean(), + channels: z.array( + z.object({ + id: z.string().uuid(), + channelType: z.nativeEnum(PkiAlertChannelType), + config: z.record(z.any()), + enabled: z.boolean(), + createdAt: z.date(), + updatedAt: z.date() + }) + ), + createdAt: z.date(), + updatedAt: z.date() + }) + ), + total: z.number() + }) + } + }, + handler: async (req) => { + const alerts = await server.services.pkiAlertV2.listAlerts({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.query + }); + + return alerts; + } + }); + + server.route({ + method: "GET", + url: "/:alertId", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Get a PKI alert by ID", + tags: [ApiDocsTags.PkiAlerting], + params: z.object({ + alertId: z.string().uuid().describe("Alert ID") + }), + response: { + 200: z.object({ + alert: z.object({ + id: z.string().uuid(), + name: z.string(), + description: z.string().nullable(), + eventType: z.nativeEnum(PkiAlertEventType), + alertBefore: z.string(), + filters: z.array(PkiFilterRuleSchema), + enabled: z.boolean(), + projectId: z.string().uuid(), + channels: z.array( + z.object({ + id: z.string().uuid(), + channelType: z.nativeEnum(PkiAlertChannelType), + config: z.record(z.any()), + enabled: z.boolean(), + createdAt: z.date(), + updatedAt: z.date() + }) + ), + createdAt: z.date(), + updatedAt: z.date() + }) + }) + } + }, + handler: async (req) => { + const alert = await server.services.pkiAlertV2.getAlertById({ + alertId: req.params.alertId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: alert.projectId, + event: { + type: EventType.GET_PKI_ALERT, + metadata: { + pkiAlertId: alert.id + } + } + }); + + return { alert }; + } + }); + + server.route({ + method: "PATCH", + url: "/:alertId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Update a PKI alert", + tags: [ApiDocsTags.PkiAlerting], + params: z.object({ + alertId: z.string().uuid().describe("Alert ID") + }), + body: UpdatePkiAlertV2Schema, + response: { + 200: z.object({ + alert: z.object({ + id: z.string().uuid(), + name: z.string(), + description: z.string().nullable(), + eventType: z.nativeEnum(PkiAlertEventType), + alertBefore: z.string(), + filters: z.array(PkiFilterRuleSchema), + enabled: z.boolean(), + projectId: z.string().uuid(), + channels: z.array( + z.object({ + id: z.string().uuid(), + channelType: z.nativeEnum(PkiAlertChannelType), + config: z.record(z.any()), + enabled: z.boolean(), + createdAt: z.date(), + updatedAt: z.date() + }) + ), + createdAt: z.date(), + updatedAt: z.date() + }) + }) + } + }, + handler: async (req) => { + const alert = await server.services.pkiAlertV2.updateAlert({ + alertId: req.params.alertId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: alert.projectId, + event: { + type: EventType.UPDATE_PKI_ALERT, + metadata: { + pkiAlertId: alert.id, + name: alert.name, + eventType: alert.eventType, + alertBefore: alert.alertBefore + } + } + }); + + return { alert }; + } + }); + + server.route({ + method: "DELETE", + url: "/:alertId", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Delete a PKI alert", + tags: [ApiDocsTags.PkiAlerting], + params: z.object({ + alertId: z.string().uuid().describe("Alert ID") + }), + response: { + 200: z.object({ + alert: z.object({ + id: z.string().uuid(), + name: z.string(), + description: z.string().nullable(), + eventType: z.nativeEnum(PkiAlertEventType), + alertBefore: z.string(), + filters: z.array(PkiFilterRuleSchema), + enabled: z.boolean(), + projectId: z.string().uuid(), + channels: z.array( + z.object({ + id: z.string().uuid(), + channelType: z.nativeEnum(PkiAlertChannelType), + config: z.record(z.any()), + enabled: z.boolean(), + createdAt: z.date(), + updatedAt: z.date() + }) + ), + createdAt: z.date(), + updatedAt: z.date() + }) + }) + } + }, + handler: async (req) => { + const alert = await server.services.pkiAlertV2.deleteAlert({ + alertId: req.params.alertId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId + }); + + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: alert.projectId, + event: { + type: EventType.DELETE_PKI_ALERT, + metadata: { + pkiAlertId: alert.id + } + } + }); + + return { alert }; + } + }); + + server.route({ + method: "GET", + url: "/:alertId/certificates", + config: { + rateLimit: readLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "List certificates that match an alert's filter rules", + tags: [ApiDocsTags.PkiAlerting], + params: z.object({ + alertId: z.string().uuid().describe("Alert ID") + }), + querystring: z.object({ + limit: z.coerce.number().min(1).max(100).default(20), + offset: z.coerce.number().min(0).default(0) + }), + response: { + 200: z.object({ + certificates: z.array( + z.object({ + id: z.string().uuid(), + serialNumber: z.string(), + commonName: z.string(), + san: z.array(z.string()), + profileName: z.string().nullable(), + enrollmentType: z.string().nullable(), + notBefore: z.date(), + notAfter: z.date(), + status: z.string() + }) + ), + total: z.number() + }) + } + }, + handler: async (req) => { + const result = await server.services.pkiAlertV2.listMatchingCertificates({ + alertId: req.params.alertId, + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.query + }); + + return result; + } + }); + + server.route({ + method: "POST", + url: "/preview/certificates", + config: { + rateLimit: writeLimit + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Preview certificates that would match the given filter rules", + tags: [ApiDocsTags.PkiAlerting], + body: z.object({ + projectId: z.string().uuid().describe("Project ID"), + filters: z.array(PkiFilterRuleSchema), + alertBefore: z + .string() + .refine(createSecureAlertBeforeValidator(), "Must be in format like '30d', '1w', '3m', '1y'") + .describe("Alert timing (e.g., '30d', '1w')"), + limit: z.coerce.number().min(1).max(100).default(20), + offset: z.coerce.number().min(0).default(0) + }), + response: { + 200: z.object({ + certificates: z.array( + z.object({ + id: z.string().uuid(), + serialNumber: z.string(), + commonName: z.string(), + san: z.array(z.string()), + profileName: z.string().nullable(), + enrollmentType: z.string().nullable(), + notBefore: z.date(), + notAfter: z.date(), + status: z.string() + }) + ), + total: z.number() + }) + } + }, + handler: async (req) => { + const result = await server.services.pkiAlertV2.listCurrentMatchingCertificates({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.body + }); + + return result; + } + }); +}; diff --git a/backend/src/server/routes/v3/certificates-router.ts b/backend/src/server/routes/v3/certificates-router.ts index d2d696596..f590aa111 100644 --- a/backend/src/server/routes/v3/certificates-router.ts +++ b/backend/src/server/routes/v3/certificates-router.ts @@ -20,6 +20,7 @@ import { } from "@app/services/certificate-common/certificate-constants"; import { extractCertificateRequestFromCSR } from "@app/services/certificate-common/certificate-csr-utils"; import { mapEnumsForValidation } from "@app/services/certificate-common/certificate-utils"; +import { EnrollmentType } from "@app/services/certificate-profile/certificate-profile-types"; import { validateTemplateRegexField } from "@app/services/certificate-template/certificate-template-validators"; interface CertificateRequestForService { @@ -204,7 +205,8 @@ export const registerCertificatesRouter = async (server: FastifyZodProvider) => ttl: req.body.ttl }, notBefore: req.body.notBefore ? new Date(req.body.notBefore) : undefined, - notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined + notAfter: req.body.notAfter ? new Date(req.body.notAfter) : undefined, + enrollmentType: EnrollmentType.API }); await server.services.auditLog.createAuditLog({ diff --git a/backend/src/server/routes/v3/index.ts b/backend/src/server/routes/v3/index.ts index 47c3c2cb8..4ee4566c1 100644 --- a/backend/src/server/routes/v3/index.ts +++ b/backend/src/server/routes/v3/index.ts @@ -11,5 +11,5 @@ export const registerV3Routes = async (server: FastifyZodProvider) => { await server.register(registerUserRouter, { prefix: "/users" }); await server.register(registerDeprecatedSecretRouter, { prefix: "/secrets" }); await server.register(registerExternalMigrationRouter, { prefix: "/external-migration" }); - await server.register(registerCertificatesRouter, { prefix: "/certificates" }); + await server.register(registerCertificatesRouter, { prefix: "/pki/certificates" }); }; diff --git a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-enums.ts b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-enums.ts index eb0521c64..1f7fc808d 100644 --- a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-enums.ts +++ b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-enums.ts @@ -1,4 +1,5 @@ export enum AzureClientSecretsConnectionMethod { OAuth = "oauth", - ClientSecret = "client-secret" + ClientSecret = "client-secret", + Certificate = "certificate" } diff --git a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-fns.ts b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-fns.ts index 22cec0ae7..7916ac92d 100644 --- a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-fns.ts +++ b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-fns.ts @@ -1,9 +1,14 @@ /* eslint-disable no-case-declarations */ import { AxiosError, AxiosResponse } from "axios"; +import type { KeyObject } from "crypto"; +import RE2 from "re2"; +import { v4 as uuidv4 } from "uuid"; import { getConfig } from "@app/lib/config/env"; import { request } from "@app/lib/config/request"; +import { crypto } from "@app/lib/crypto"; import { BadRequestError, InternalServerError, NotFoundError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; import { decryptAppConnectionCredentials, encryptAppConnectionCredentials, @@ -17,11 +22,82 @@ import { AppConnection } from "../app-connection-enums"; import { AzureClientSecretsConnectionMethod } from "./azure-client-secrets-connection-enums"; import { ExchangeCodeAzureResponse, + TAzureClientSecretsConnectionCertificateCredentials, TAzureClientSecretsConnectionClientSecretCredentials, TAzureClientSecretsConnectionConfig, TAzureClientSecretsConnectionCredentials } from "./azure-client-secrets-connection-types"; +const generateClientAssertion = ( + clientId: string, + tenantId: string, + privateKey: string, + certificate: string +): string => { + const tokenEndpoint = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`; + + const certBuffer = Buffer.from( + certificate + .replace(new RE2("-----BEGIN CERTIFICATE-----"), "") + .replace(new RE2("-----END CERTIFICATE-----"), "") + .replace(new RE2("\\s", "g"), ""), + "base64" + ); + + // thumbprint of the certificate is used for the jwt header + const thumbprint = crypto.nativeCrypto.createHash("sha1").update(certBuffer).digest("hex"); + const x5t = Buffer.from(thumbprint, "hex").toString("base64url"); + + // JWT Header + const header = { + alg: "RS256", + typ: "JWT", + x5t + }; + + const now = Math.floor(Date.now() / 1000); + const payload = { + aud: tokenEndpoint, + exp: now + 600, // expire the assertion in 10 minutes (not the access access token TTL, but rather the assertion TTL itself) + iss: clientId, + jti: uuidv4(), // random ID for the JWT + nbf: now, // not before the jwt is valid + sub: clientId + }; + + // encode header and payload + const encodedHeader = Buffer.from(JSON.stringify(header)).toString("base64url"); + const encodedPayload = Buffer.from(JSON.stringify(payload)).toString("base64url"); + const signatureInput = `${encodedHeader}.${encodedPayload}`; + + let keyObject: KeyObject; + + try { + if (privateKey.includes("BEGIN PRIVATE KEY")) { + keyObject = crypto.nativeCrypto.createPrivateKey(privateKey); + } else { + // if user forgot to wrap in begin/end private key, decode and use as der format + keyObject = crypto.nativeCrypto.createPrivateKey({ + key: Buffer.from(privateKey, "base64"), + format: "der", + type: "pkcs8" + }); + } + } catch (error) { + throw new BadRequestError({ + message: "Invalid private key format provided. Expected PEM format private key." + }); + } + + // sign with private key + const signer = crypto.nativeCrypto.createSign("RSA-SHA256"); + signer.update(signatureInput); + signer.end(); + const signature = signer.sign(keyObject, "base64url"); + + return `${signatureInput}.${signature}`; +}; + export const getAzureClientSecretsConnectionListItem = () => { const { INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID } = getConfig(); @@ -30,7 +106,8 @@ export const getAzureClientSecretsConnectionListItem = () => { app: AppConnection.AzureClientSecrets as const, methods: Object.values(AzureClientSecretsConnectionMethod) as [ AzureClientSecretsConnectionMethod.OAuth, - AzureClientSecretsConnectionMethod.ClientSecret + AzureClientSecretsConnectionMethod.ClientSecret, + AzureClientSecretsConnectionMethod.Certificate ], oauthClientId: INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID }; @@ -64,7 +141,7 @@ export const getAzureConnectionAccessToken = async ( const { refreshToken } = credentials; const currentTime = Date.now(); switch (appConnection.method) { - case AzureClientSecretsConnectionMethod.OAuth: + case AzureClientSecretsConnectionMethod.OAuth: { if ( !appCfg.INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_ID || !appCfg.INF_APP_CONNECTION_AZURE_CLIENT_SECRETS_CLIENT_SECRET @@ -101,7 +178,8 @@ export const getAzureConnectionAccessToken = async ( await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials }); return data.access_token; - case AzureClientSecretsConnectionMethod.ClientSecret: + } + case AzureClientSecretsConnectionMethod.ClientSecret: { const accessTokenCredentials = (await decryptAppConnectionCredentials({ orgId: appConnection.orgId, projectId: appConnection.projectId, @@ -139,6 +217,50 @@ export const getAzureConnectionAccessToken = async ( await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials: encryptedClientCredentials }); return clientData.access_token; + } + + case AzureClientSecretsConnectionMethod.Certificate: { + const accessTokenCredentials = (await decryptAppConnectionCredentials({ + orgId: appConnection.orgId, + projectId: appConnection.projectId, + kmsService, + encryptedCredentials: appConnection.encryptedCredentials + })) as TAzureClientSecretsConnectionCertificateCredentials; + const { accessToken, expiresAt, clientId, tenantId, certificateBody, privateKey } = accessTokenCredentials; + if (accessToken && expiresAt && expiresAt > currentTime + 300000) { + return accessToken; + } + + const clientAssertion = generateClientAssertion(clientId, tenantId, privateKey, certificateBody); + const { data: clientData } = await request.post( + IntegrationUrls.AZURE_TOKEN_URL.replace("common", tenantId || "common"), + new URLSearchParams({ + grant_type: "client_credentials", + scope: `https://graph.microsoft.com/.default`, + client_id: clientId, + client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + client_assertion: clientAssertion + }) + ); + + const updatedClientCredentials = { + ...accessTokenCredentials, + accessToken: clientData.access_token, + expiresAt: currentTime + clientData.expires_in * 1000 + }; + + const encryptedClientCredentials = await encryptAppConnectionCredentials({ + credentials: updatedClientCredentials, + orgId: appConnection.orgId, + projectId: appConnection.projectId, + kmsService + }); + + await appConnectionDAL.updateById(appConnection.id, { encryptedCredentials: encryptedClientCredentials }); + + return clientData.access_token; + } + default: throw new InternalServerError({ message: `Unhandled Azure connection method: ${appConnection.method as AzureClientSecretsConnectionMethod}` @@ -156,7 +278,7 @@ export const validateAzureClientSecretsConnectionCredentials = async (config: TA } = getConfig(); switch (method) { - case AzureClientSecretsConnectionMethod.OAuth: + case AzureClientSecretsConnectionMethod.OAuth: { if (!SITE_URL) { throw new InternalServerError({ message: "SITE_URL env var is required to complete Azure OAuth flow" }); } @@ -221,8 +343,9 @@ export const validateAzureClientSecretsConnectionCredentials = async (config: TA refreshToken: tokenResp.data.refresh_token, expiresAt: Date.now() + tokenResp.data.expires_in * 1000 }; + } - case AzureClientSecretsConnectionMethod.ClientSecret: + case AzureClientSecretsConnectionMethod.ClientSecret: { const { tenantId, clientId, clientSecret } = inputCredentials; try { const { data: clientData } = await request.post( @@ -255,6 +378,57 @@ export const validateAzureClientSecretsConnectionCredentials = async (config: TA }); } } + } + case AzureClientSecretsConnectionMethod.Certificate: { + const { tenantId, certificateBody, privateKey, clientId } = inputCredentials; + try { + const clientAssertion = generateClientAssertion(clientId, tenantId, privateKey, certificateBody); + + const tokenEndpoint = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`; + + const params = new URLSearchParams({ + client_id: clientId, + client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + client_assertion: clientAssertion, + scope: "https://graph.microsoft.com/.default", + grant_type: "client_credentials" + }); + + const response = await request.post(tokenEndpoint, params.toString(), { + headers: { + "Content-Type": "application/x-www-form-urlencoded" + } + }); + + return { + tenantId, + clientId, + certificateBody, + privateKey, + accessToken: response.data.access_token, + expiresAt: Date.now() + response.data.expires_in * 1000 + }; + } catch (e: unknown) { + if (e instanceof AxiosError) { + throw new BadRequestError({ + message: `Failed to get access token: ${ + (e?.response?.data as { error_description?: string })?.error_description || "Unknown error" + }` + }); + } else if (e instanceof BadRequestError) { + throw e; + } else { + logger.error( + e, + "validateAzureClientSecretsConnectionCredentials: Failed to get access token using certificate authentication" + ); + throw new InternalServerError({ + message: "Failed to get access token" + }); + } + } + } + default: throw new InternalServerError({ message: `Unhandled Azure connection method: ${method as AzureClientSecretsConnectionMethod}` diff --git a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-schemas.ts b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-schemas.ts index d9f178a06..dd387894e 100644 --- a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-schemas.ts +++ b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-schemas.ts @@ -48,6 +48,31 @@ export const AzureClientSecretsConnectionClientSecretInputCredentialsSchema = z. .describe(AppConnections.CREDENTIALS.AZURE_CLIENT_SECRETS.tenantId) }); +export const AzureClientSecretsConnectionCertificateInputCredentialsSchema = z.object({ + tenantId: z + .string() + .uuid() + .trim() + .min(1, "Tenant ID required") + .describe(AppConnections.CREDENTIALS.AZURE_CLIENT_SECRETS.tenantId), + clientId: z + .string() + .uuid() + .trim() + .min(1, "Client ID required") + .describe(AppConnections.CREDENTIALS.AZURE_CLIENT_SECRETS.clientId), + certificateBody: z + .string() + .trim() + .min(1, "Certificate body required") + .describe(AppConnections.CREDENTIALS.AZURE_CLIENT_SECRETS.certificateBody), + privateKey: z + .string() + .trim() + .min(1, "Private Key required") + .describe(AppConnections.CREDENTIALS.AZURE_CLIENT_SECRETS.privateKey) +}); + export const AzureClientSecretsConnectionClientSecretOutputCredentialsSchema = z.object({ clientId: z.string(), clientSecret: z.string(), @@ -56,6 +81,15 @@ export const AzureClientSecretsConnectionClientSecretOutputCredentialsSchema = z expiresAt: z.number() }); +export const AzureClientSecretsConnectionCertificateOutputCredentialsSchema = z.object({ + clientId: z.string(), + tenantId: z.string(), + certificateBody: z.string(), + privateKey: z.string(), + accessToken: z.string(), + expiresAt: z.number() +}); + export const ValidateAzureClientSecretsConnectionCredentialsSchema = z.discriminatedUnion("method", [ z.object({ method: z @@ -72,6 +106,14 @@ export const ValidateAzureClientSecretsConnectionCredentialsSchema = z.discrimin credentials: AzureClientSecretsConnectionClientSecretInputCredentialsSchema.describe( AppConnections.CREATE(AppConnection.AzureClientSecrets).credentials ) + }), + z.object({ + method: z + .literal(AzureClientSecretsConnectionMethod.Certificate) + .describe(AppConnections.CREATE(AppConnection.AzureClientSecrets).method), + credentials: AzureClientSecretsConnectionCertificateInputCredentialsSchema.describe( + AppConnections.CREATE(AppConnection.AzureClientSecrets).credentials + ) }) ]); @@ -84,7 +126,8 @@ export const UpdateAzureClientSecretsConnectionSchema = z credentials: z .union([ AzureClientSecretsConnectionOAuthInputCredentialsSchema, - AzureClientSecretsConnectionClientSecretInputCredentialsSchema + AzureClientSecretsConnectionClientSecretInputCredentialsSchema, + AzureClientSecretsConnectionCertificateInputCredentialsSchema ]) .optional() .describe(AppConnections.UPDATE(AppConnection.AzureClientSecrets).credentials) @@ -105,6 +148,10 @@ export const AzureClientSecretsConnectionSchema = z.intersection( z.object({ method: z.literal(AzureClientSecretsConnectionMethod.ClientSecret), credentials: AzureClientSecretsConnectionClientSecretOutputCredentialsSchema + }), + z.object({ + method: z.literal(AzureClientSecretsConnectionMethod.Certificate), + credentials: AzureClientSecretsConnectionCertificateOutputCredentialsSchema }) ]) ); @@ -122,6 +169,13 @@ export const SanitizedAzureClientSecretsConnectionSchema = z.discriminatedUnion( clientId: true, tenantId: true }) + }), + BaseAzureClientSecretsConnectionSchema.extend({ + method: z.literal(AzureClientSecretsConnectionMethod.Certificate), + credentials: AzureClientSecretsConnectionCertificateOutputCredentialsSchema.pick({ + tenantId: true, + clientId: true + }) }) ]); diff --git a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-types.ts b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-types.ts index 1ad5a3411..e8a66cbd9 100644 --- a/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-types.ts +++ b/backend/src/services/app-connection/azure-client-secrets/azure-client-secrets-connection-types.ts @@ -4,6 +4,7 @@ import { DiscriminativePick } from "@app/lib/types"; import { AppConnection } from "../app-connection-enums"; import { + AzureClientSecretsConnectionCertificateOutputCredentialsSchema, AzureClientSecretsConnectionClientSecretOutputCredentialsSchema, AzureClientSecretsConnectionOAuthOutputCredentialsSchema, AzureClientSecretsConnectionSchema, @@ -35,6 +36,10 @@ export type TAzureClientSecretsConnectionClientSecretCredentials = z.infer< typeof AzureClientSecretsConnectionClientSecretOutputCredentialsSchema >; +export type TAzureClientSecretsConnectionCertificateCredentials = z.infer< + typeof AzureClientSecretsConnectionCertificateOutputCredentialsSchema +>; + export interface ExchangeCodeAzureResponse { token_type: string; scope: string; diff --git a/backend/src/services/auth/auth-type.ts b/backend/src/services/auth/auth-type.ts index 497414a60..ef54ac0be 100644 --- a/backend/src/services/auth/auth-type.ts +++ b/backend/src/services/auth/auth-type.ts @@ -41,6 +41,7 @@ export enum ActorType { // would extend to AWS, Azure, ... IDENTITY = "identity", Machine = "machine", SCIM_CLIENT = "scimClient", + ACME_ACCOUNT = "acmeAccount", UNKNOWN_USER = "unknownUser" } diff --git a/backend/src/services/certificate-profile/certificate-profile-dal.ts b/backend/src/services/certificate-profile/certificate-profile-dal.ts index b66475bbf..6415145ce 100644 --- a/backend/src/services/certificate-profile/certificate-profile-dal.ts +++ b/backend/src/services/certificate-profile/certificate-profile-dal.ts @@ -65,6 +65,23 @@ export const certificateProfileDALFactory = (db: TDbClient) => { } }; + const findByIdWithOwnerOrgId = async ( + id: string, + tx?: Knex + ): Promise<(TCertificateProfile & { ownerOrgId: string }) | undefined> => { + try { + const certificateProfile = (await (tx || db)(TableName.PkiCertificateProfile) + .join(TableName.Project, `${TableName.PkiCertificateProfile}.projectId`, `${TableName.Project}.id`) + .select(selectAllTableCols(TableName.PkiCertificateProfile)) + .select(db.ref("orgId").withSchema(TableName.Project).as("ownerOrgId")) + .where(`${TableName.PkiCertificateProfile}.id`, id) + .first()) as (TCertificateProfile & { ownerOrgId: string }) | undefined; + return certificateProfile; + } catch (error) { + throw new DatabaseError({ error, name: "Find certificate profile by id with owner org id" }); + } + }; + const findByIdWithConfigs = async (id: string, tx?: Knex): Promise => { try { const query = (tx || db)(TableName.PkiCertificateProfile) @@ -88,6 +105,11 @@ export const certificateProfileDALFactory = (db: TDbClient) => { `${TableName.PkiCertificateProfile}.apiConfigId`, `${TableName.PkiApiEnrollmentConfig}.id` ) + .leftJoin( + TableName.PkiAcmeEnrollmentConfig, + `${TableName.PkiCertificateProfile}.acmeConfigId`, + `${TableName.PkiAcmeEnrollmentConfig}.id` + ) .select(selectAllTableCols(TableName.PkiCertificateProfile)) .select( db.ref("id").withSchema(TableName.CertificateAuthority).as("caId"), @@ -107,7 +129,9 @@ export const certificateProfileDALFactory = (db: TDbClient) => { db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estConfigEncryptedCaChain"), db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigId"), db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigAutoRenew"), - db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigRenewBeforeDays") + db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiConfigRenewBeforeDays"), + db.ref("id").withSchema(TableName.PkiAcmeEnrollmentConfig).as("acmeConfigId"), + db.ref("encryptedEabSecret").withSchema(TableName.PkiAcmeEnrollmentConfig).as("acmeConfigEncryptedEabSecret") ) .where(`${TableName.PkiCertificateProfile}.id`, id) .first(); @@ -134,6 +158,13 @@ export const certificateProfileDALFactory = (db: TDbClient) => { } as TCertificateProfileWithConfigs["apiConfig"]) : undefined; + const acmeConfig = result.acmeConfigId + ? ({ + id: result.acmeConfigId, + encryptedEabSecret: result.acmeConfigEncryptedEabSecret + } as TCertificateProfileWithConfigs["acmeConfig"]) + : undefined; + const certificateAuthority = result.caId && result.caProjectId && result.caStatus && result.caName ? ({ @@ -164,10 +195,12 @@ export const certificateProfileDALFactory = (db: TDbClient) => { enrollmentType: result.enrollmentType as EnrollmentType, estConfigId: result.estConfigId, apiConfigId: result.apiConfigId, + acmeConfigId: result.acmeConfigId, createdAt: result.createdAt, updatedAt: result.updatedAt, estConfig, apiConfig, + acmeConfig, certificateAuthority, certificateTemplate }; @@ -241,6 +274,11 @@ export const certificateProfileDALFactory = (db: TDbClient) => { `${TableName.PkiCertificateProfile}.apiConfigId`, `${TableName.PkiApiEnrollmentConfig}.id` ) + .leftJoin( + TableName.PkiAcmeEnrollmentConfig, + `${TableName.PkiCertificateProfile}.acmeConfigId`, + `${TableName.PkiAcmeEnrollmentConfig}.id` + ) .select(selectAllTableCols(TableName.PkiCertificateProfile)) .select( db.ref("id").withSchema(TableName.PkiEstEnrollmentConfig).as("estId"), @@ -252,7 +290,8 @@ export const certificateProfileDALFactory = (db: TDbClient) => { db.ref("encryptedCaChain").withSchema(TableName.PkiEstEnrollmentConfig).as("estEncryptedCaChain"), db.ref("id").withSchema(TableName.PkiApiEnrollmentConfig).as("apiId"), db.ref("autoRenew").withSchema(TableName.PkiApiEnrollmentConfig).as("apiAutoRenew"), - db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiRenewBeforeDays") + db.ref("renewBeforeDays").withSchema(TableName.PkiApiEnrollmentConfig).as("apiRenewBeforeDays"), + db.ref("id").withSchema(TableName.PkiAcmeEnrollmentConfig).as("acmeId") ); const results = (await query @@ -279,6 +318,12 @@ export const certificateProfileDALFactory = (db: TDbClient) => { } : undefined; + const acmeConfig = result.acmeId + ? { + id: result.acmeId as string + } + : undefined; + const baseProfile = { id: result.id, projectId: result.projectId, @@ -292,7 +337,8 @@ export const certificateProfileDALFactory = (db: TDbClient) => { createdAt: result.createdAt, updatedAt: result.updatedAt, estConfig, - apiConfig + apiConfig, + acmeConfig }; return baseProfile as TCertificateProfileWithConfigs; @@ -416,6 +462,24 @@ export const certificateProfileDALFactory = (db: TDbClient) => { } }; + const getLatestActiveCertificateForProfile = async (profileId: string, tx?: Knex) => { + try { + const now = new Date(); + + const certificate = await (tx || db)(TableName.Certificate) + .where("profileId", profileId) + .where("status", "active") + .where("notAfter", ">", now) + .whereNull("revokedAt") + .orderBy("createdAt", "desc") + .first(); + + return certificate; + } catch (error) { + throw new DatabaseError({ error, name: "Get latest active certificate by profile" }); + } + }; + const isProfileInUse = async (profileId: string, tx?: Knex) => { try { const doc = await (tx || db)(TableName.Certificate).where("profileId", profileId).count("*").first(); @@ -432,12 +496,14 @@ export const certificateProfileDALFactory = (db: TDbClient) => { updateById, deleteById, findById, + findByIdWithOwnerOrgId, findByIdWithConfigs, findBySlugAndProjectId, findByProjectId, countByProjectId, findByNameAndProjectId, getCertificatesByProfile, + getLatestActiveCertificateForProfile, isProfileInUse }; }; diff --git a/backend/src/services/certificate-profile/certificate-profile-schemas.ts b/backend/src/services/certificate-profile/certificate-profile-schemas.ts index 8ac494fe6..bf88593bd 100644 --- a/backend/src/services/certificate-profile/certificate-profile-schemas.ts +++ b/backend/src/services/certificate-profile/certificate-profile-schemas.ts @@ -27,7 +27,8 @@ export const createCertificateProfileSchema = z autoRenew: z.boolean().default(false), renewBeforeDays: z.number().min(1).max(30).optional() }) - .optional() + .optional(), + acmeConfig: z.object({}).optional() }) .refine( (data) => { @@ -38,6 +39,9 @@ export const createCertificateProfileSchema = z if (data.apiConfig) { return false; } + if (data.acmeConfig) { + return false; + } } if (data.enrollmentType === EnrollmentType.API) { if (!data.apiConfig) { @@ -46,6 +50,20 @@ export const createCertificateProfileSchema = z 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 true; }, 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 bb30b8d5c..9d9ab5947 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.test.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.test.ts @@ -9,7 +9,12 @@ import type { TPermissionServiceFactory } from "@app/ee/services/permission/perm import { ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { ActorType, AuthMethod } from "../auth/auth-type"; +import type { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal"; +import type { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal"; +import type { TCertificateAuthorityCertDALFactory } from "../certificate-authority/certificate-authority-cert-dal"; +import type { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal"; import type { TCertificateTemplateV2DALFactory } from "../certificate-template-v2/certificate-template-v2-dal"; +import { TAcmeEnrollmentConfigDALFactory } from "../enrollment-config/acme-enrollment-config-dal"; import type { TApiEnrollmentConfigDALFactory } from "../enrollment-config/api-enrollment-config-dal"; import type { TEstEnrollmentConfigDALFactory } from "../enrollment-config/est-enrollment-config-dal"; import type { TKmsServiceFactory } from "../kms/kms-service"; @@ -142,6 +147,17 @@ describe("CertificateProfileService", () => { delete: vi.fn() } as unknown as TEstEnrollmentConfigDALFactory; + const mockAcmeEnrollmentConfigDAL = { + create: vi.fn().mockResolvedValue({ id: "acme-config-123" }), + findById: vi.fn(), + updateById: vi.fn(), + transaction: vi.fn(), + find: vi.fn(), + findOne: vi.fn(), + update: vi.fn(), + delete: vi.fn() + } as unknown as TAcmeEnrollmentConfigDALFactory; + const mockPermissionService = { getProjectPermission: vi.fn().mockResolvedValue({ permission: { @@ -166,6 +182,54 @@ describe("CertificateProfileService", () => { transaction: vi.fn() } as unknown as Pick; + const mockCertificateBodyDAL = { + create: vi.fn(), + findById: vi.fn(), + updateById: vi.fn(), + deleteById: vi.fn(), + transaction: vi.fn(), + find: vi.fn(), + findOne: vi.fn(), + update: vi.fn(), + delete: vi.fn() + } as unknown as TCertificateBodyDALFactory; + + const mockCertificateSecretDAL = { + create: vi.fn(), + findById: vi.fn(), + updateById: vi.fn(), + deleteById: vi.fn(), + transaction: vi.fn(), + find: vi.fn(), + findOne: vi.fn(), + update: vi.fn(), + delete: vi.fn() + } as unknown as TCertificateSecretDALFactory; + + const mockCertificateAuthorityDAL = { + create: vi.fn(), + findById: vi.fn(), + updateById: vi.fn(), + deleteById: vi.fn(), + transaction: vi.fn(), + find: vi.fn(), + findOne: vi.fn(), + update: vi.fn(), + delete: vi.fn() + } as unknown as TCertificateAuthorityDALFactory; + + const mockCertificateAuthorityCertDAL = { + create: vi.fn(), + findById: vi.fn(), + updateById: vi.fn(), + deleteById: vi.fn(), + transaction: vi.fn(), + find: vi.fn(), + findOne: vi.fn(), + update: vi.fn(), + delete: vi.fn() + } as unknown as TCertificateAuthorityCertDALFactory; + beforeEach(() => { vi.spyOn(ForbiddenError, "from").mockReturnValue({ throwUnlessCan: vi.fn() @@ -182,6 +246,11 @@ describe("CertificateProfileService", () => { certificateTemplateV2DAL: mockCertificateTemplateV2DAL, apiEnrollmentConfigDAL: mockApiEnrollmentConfigDAL, estEnrollmentConfigDAL: mockEstEnrollmentConfigDAL, + acmeEnrollmentConfigDAL: mockAcmeEnrollmentConfigDAL, + certificateBodyDAL: mockCertificateBodyDAL, + certificateSecretDAL: mockCertificateSecretDAL, + certificateAuthorityDAL: mockCertificateAuthorityDAL, + certificateAuthorityCertDAL: mockCertificateAuthorityCertDAL, permissionService: mockPermissionService, kmsService: mockKmsService, projectDAL: mockProjectDAL @@ -234,6 +303,7 @@ describe("CertificateProfileService", () => { certificateTemplateId: "template-123", apiConfigId: "api-config-123", estConfigId: null, + acmeConfigId: null, projectId: "project-123" }, undefined diff --git a/backend/src/services/certificate-profile/certificate-profile-service.ts b/backend/src/services/certificate-profile/certificate-profile-service.ts index f858a8d4f..66a23a0e7 100644 --- a/backend/src/services/certificate-profile/certificate-profile-service.ts +++ b/backend/src/services/certificate-profile/certificate-profile-service.ts @@ -4,19 +4,26 @@ import * as x509 from "@peculiar/x509"; import { ActionProjectType } from "@app/db/schemas"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { + ProjectPermissionCertificateActions, ProjectPermissionCertificateProfileActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { buildUrl } from "@app/ee/services/pki-acme/pki-acme-fns"; import { extractX509CertFromChain } from "@app/lib/certificates/extract-certificate"; import { getConfig } from "@app/lib/config/env"; import { crypto } from "@app/lib/crypto/cryptography"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { ActorAuthMethod, ActorType } from "../auth/auth-type"; -import { isCertChainValid } from "../certificate/certificate-fns"; +import { TCertificateBodyDALFactory } from "../certificate/certificate-body-dal"; +import { getCertificateCredentials, isCertChainValid } from "../certificate/certificate-fns"; +import { TCertificateSecretDALFactory } from "../certificate/certificate-secret-dal"; +import { TCertificateAuthorityCertDALFactory } from "../certificate-authority/certificate-authority-cert-dal"; +import { TCertificateAuthorityDALFactory } from "../certificate-authority/certificate-authority-dal"; import { TCertificateTemplateV2DALFactory } from "../certificate-template-v2/certificate-template-v2-dal"; +import { TAcmeEnrollmentConfigDALFactory } from "../enrollment-config/acme-enrollment-config-dal"; import { TApiEnrollmentConfigDALFactory } from "../enrollment-config/api-enrollment-config-dal"; -import { TApiConfigData, TEstConfigData } from "../enrollment-config/enrollment-config-types"; +import { TAcmeConfigData, TApiConfigData, TEstConfigData } from "../enrollment-config/enrollment-config-types"; import { TEstEnrollmentConfigDALFactory } from "../enrollment-config/est-enrollment-config-dal"; import { TKmsServiceFactory } from "../kms/kms-service"; import { TProjectDALFactory } from "../project/project-dal"; @@ -31,6 +38,36 @@ import { TCertificateProfileWithConfigs } from "./certificate-profile-types"; +const generateAndEncryptAcmeEabSecret = async ( + projectId: string, + kmsService: Pick, + projectDAL: Pick +) => { + try { + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId, + projectDAL, + kmsService + }); + + const kmsEncryptor = await kmsService.encryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + + const appCfg = getConfig(); + const secret = crypto.randomBytes(32).toString("hex"); + const secretHash = await crypto.hashing().createHash(secret, appCfg.SALT_ROUNDS); + + const { cipherTextBlob } = await kmsEncryptor({ + plainText: Buffer.from(secretHash) + }); + + return { encryptedEabSecret: cipherTextBlob }; + } catch (error) { + throw new BadRequestError({ message: `Failed to generate ACME EAB secret: ${(error as Error).message}` }); + } +}; + const validateAndEncryptPemCaChain = async ( caChain: string, projectId: string, @@ -95,9 +132,13 @@ const decryptCaChain = async ( } }; -export type TCertificateProfileCreateData = Omit & { +export type TCertificateProfileCreateData = Omit< + TCertificateProfileInsert, + "estConfigId" | "apiConfigId" | "acmeConfigId" +> & { estConfig?: TEstConfigData; apiConfig?: TApiConfigData; + acmeConfig?: TAcmeConfigData; }; type TCertificateProfileServiceFactoryDep = { @@ -105,6 +146,11 @@ type TCertificateProfileServiceFactoryDep = { certificateTemplateV2DAL: TCertificateTemplateV2DALFactory; apiEnrollmentConfigDAL: TApiEnrollmentConfigDALFactory; estEnrollmentConfigDAL: TEstEnrollmentConfigDALFactory; + acmeEnrollmentConfigDAL: TAcmeEnrollmentConfigDALFactory; + certificateBodyDAL: Pick; + certificateSecretDAL: Pick; + certificateAuthorityDAL: Pick; + certificateAuthorityCertDAL: Pick; permissionService: Pick; kmsService: Pick; projectDAL: Pick; @@ -124,6 +170,9 @@ export const certificateProfileServiceFactory = ({ certificateTemplateV2DAL, apiEnrollmentConfigDAL, estEnrollmentConfigDAL, + acmeEnrollmentConfigDAL, + certificateBodyDAL, + certificateSecretDAL, permissionService, kmsService, projectDAL @@ -188,11 +237,14 @@ export const certificateProfileServiceFactory = ({ message: "API enrollment requires API configuration" }); } + // TODO: acme type currently doesn't require config obj, but add a check in the future if + // we have options // Create enrollment configs and profile const profile = await certificateProfileDAL.transaction(async (tx) => { let estConfigId: string | null = null; let apiConfigId: string | null = null; + let acmeConfigId: string | null = null; if (data.enrollmentType === EnrollmentType.EST && data.estConfig) { const appCfg = getConfig(); @@ -228,16 +280,21 @@ export const certificateProfileServiceFactory = ({ tx ); apiConfigId = apiConfig.id; + } else if (data.enrollmentType === EnrollmentType.ACME && data.acmeConfig) { + const { encryptedEabSecret } = await generateAndEncryptAcmeEabSecret(projectId, kmsService, projectDAL); + const acmeConfig = await acmeEnrollmentConfigDAL.create({ encryptedEabSecret }, tx); + acmeConfigId = acmeConfig.id; } // Create the profile with the created config IDs - const { estConfig, apiConfig, ...profileData } = data; + const { estConfig, apiConfig, acmeConfig, ...profileData } = data; const profileResult = await certificateProfileDAL.create( { ...profileData, projectId, estConfigId, - apiConfigId + apiConfigId, + acmeConfigId }, tx ); @@ -439,6 +496,12 @@ export const certificateProfileServiceFactory = ({ profile.estConfig.caChain = ""; } } + if (profile.enrollmentType === EnrollmentType.ACME && profile.acmeConfig) { + profile.acmeConfig.directoryUrl = buildUrl(profile.id, "/directory"); + if (profile.acmeConfig.encryptedEabSecret) { + profile.acmeConfig.encryptedEabSecret = undefined; + } + } return { ...profile, @@ -574,7 +637,10 @@ export const certificateProfileServiceFactory = ({ const result: TCertificateProfileWithConfigs = { ...converted, estConfig: decryptedEstConfig, - apiConfig: profileWithConfigs.apiConfig + apiConfig: profileWithConfigs.apiConfig, + acmeConfig: profileWithConfigs.acmeConfig + ? { ...profileWithConfigs.acmeConfig, directoryUrl: buildUrl(profile.id, "/directory") } + : undefined }; return result; @@ -674,6 +740,106 @@ export const certificateProfileServiceFactory = ({ return certificates; }; + const getLatestActiveCertificateBundle = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + profileId + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + profileId: string; + }) => { + const profile = await certificateProfileDAL.findById(profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: profile.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.Read, + ProjectPermissionSub.CertificateProfiles + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.Read, + ProjectPermissionSub.Certificates + ); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateActions.ReadPrivateKey, + ProjectPermissionSub.Certificates + ); + + const cert = await certificateProfileDAL.getLatestActiveCertificateForProfile(profileId); + + if (!cert) { + return null; + } + + const certBody = await certificateBodyDAL.findOne({ certId: cert.id }); + + const certificateManagerKeyId = await getProjectKmsCertificateKeyId({ + projectId: cert.projectId, + projectDAL, + kmsService + }); + + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKeyId + }); + const decryptedCert = await kmsDecryptor({ + cipherTextBlob: certBody.encryptedCertificate + }); + + const certObj = new x509.X509Certificate(decryptedCert); + const certificate = certObj.toString("pem"); + + const decryptedCertChain = await kmsDecryptor({ + cipherTextBlob: certBody.encryptedCertificateChain! + }); + + const certificateChain = decryptedCertChain.toString(); + + let privateKey = null; + try { + const { certPrivateKey } = await getCertificateCredentials({ + certId: cert.id, + projectId: cert.projectId, + certificateSecretDAL, + projectDAL, + kmsService + }); + privateKey = certPrivateKey; + } catch (error) { + // Private key might not exist for ACME certificates or other external workflows + // where the key is generated client-side + if (error instanceof NotFoundError) { + privateKey = null; + } else { + throw error; + } + } + + return { + certificate, + certificateChain, + privateKey, + profile, + certObj: cert + }; + }; + const getEstConfigurationByProfile = async ( params: | { @@ -737,6 +903,59 @@ export const certificateProfileServiceFactory = ({ }; }; + const revealAcmeEabSecret = async ({ + actor, + actorId, + actorAuthMethod, + actorOrgId, + profileId + }: { + actor: ActorType; + actorId: string; + actorAuthMethod: ActorAuthMethod; + actorOrgId: string; + profileId: string; + }) => { + const profile = await certificateProfileDAL.findByIdWithConfigs(profileId); + if (!profile) { + throw new NotFoundError({ message: "Certificate profile not found" }); + } + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: profile.projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionCertificateProfileActions.RevealAcmeEabSecret, + ProjectPermissionSub.CertificateProfiles + ); + + if (profile.enrollmentType !== EnrollmentType.ACME) { + throw new ForbiddenRequestError({ + message: "Profile is not configured for ACME enrollment" + }); + } + if (!profile.acmeConfig) { + throw new NotFoundError({ message: "ACME configuration not found for this profile" }); + } + + const certificateManagerKmsId = await getProjectKmsCertificateKeyId({ + projectId: profile.projectId, + projectDAL, + kmsService + }); + + const kmsDecryptor = await kmsService.decryptWithKmsKey({ + kmsId: certificateManagerKmsId + }); + const eabSecret = await kmsDecryptor({ cipherTextBlob: profile.acmeConfig.encryptedEabSecret! }); + return { eabKid: profile.id, eabSecret: eabSecret.toString("base64url") }; + }; + return { createProfile, updateProfile, @@ -746,6 +965,8 @@ export const certificateProfileServiceFactory = ({ listProfiles, deleteProfile, getProfileCertificates, - getEstConfigurationByProfile + getLatestActiveCertificateBundle, + getEstConfigurationByProfile, + revealAcmeEabSecret }; }; diff --git a/backend/src/services/certificate-profile/certificate-profile-types.ts b/backend/src/services/certificate-profile/certificate-profile-types.ts index 5dac470c8..4a3339857 100644 --- a/backend/src/services/certificate-profile/certificate-profile-types.ts +++ b/backend/src/services/certificate-profile/certificate-profile-types.ts @@ -6,7 +6,8 @@ import { export enum EnrollmentType { API = "api", - EST = "est" + EST = "est", + ACME = "acme" } export type TCertificateProfile = Omit & { @@ -28,6 +29,7 @@ export type TCertificateProfileUpdate = Omit { getTemplateV2ById: vi.fn() }; + const mockAcmeAccountDAL: Pick = { + findById: vi.fn() + }; + const mockInternalCaService: Pick = { signCertFromCa: vi.fn(), @@ -132,6 +137,7 @@ describe("CertificateV3Service", () => { certificateAuthorityDAL: mockCertificateAuthorityDAL, certificateProfileDAL: mockCertificateProfileDAL, certificateTemplateV2Service: mockCertificateTemplateV2Service, + acmeAccountDAL: mockAcmeAccountDAL, internalCaService: mockInternalCaService, permissionService: mockPermissionService, certificateSyncDAL: { @@ -697,6 +703,7 @@ describe("CertificateV3Service", () => { profileId, csr: mockCSR, validity: mockValidity, + enrollmentType: EnrollmentType.API, ...mockActor }); @@ -731,6 +738,7 @@ describe("CertificateV3Service", () => { profileId, csr: mockCSR, validity: mockValidity, + enrollmentType: EnrollmentType.API, ...mockActor }) ).rejects.toThrow(ForbiddenRequestError); @@ -740,6 +748,7 @@ describe("CertificateV3Service", () => { profileId, csr: mockCSR, validity: mockValidity, + enrollmentType: EnrollmentType.API, ...mockActor }) ).rejects.toThrow("Profile is not configured for api enrollment"); diff --git a/backend/src/services/certificate-v3/certificate-v3-service.ts b/backend/src/services/certificate-v3/certificate-v3-service.ts index 51c79f135..bce5350c3 100644 --- a/backend/src/services/certificate-v3/certificate-v3-service.ts +++ b/backend/src/services/certificate-v3/certificate-v3-service.ts @@ -9,6 +9,7 @@ import { ProjectPermissionCertificateProfileActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { TPkiAcmeAccountDALFactory } from "@app/ee/services/pki-acme/pki-acme-account-dal"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type"; import { TCertificateDALFactory } from "@app/services/certificate/certificate-dal"; @@ -70,6 +71,7 @@ type TCertificateV3ServiceFactoryDep = { certificateSecretDAL: Pick; certificateAuthorityDAL: Pick; certificateProfileDAL: Pick; + acmeAccountDAL: Pick; certificateTemplateV2Service: Pick< TCertificateTemplateV2ServiceFactory, "validateCertificateRequest" | "getTemplateV2ById" @@ -93,6 +95,7 @@ const validateProfileAndPermissions = async ( actorAuthMethod: ActorAuthMethod, actorOrgId: string, certificateProfileDAL: Pick, + acmeAccountDAL: Pick, permissionService: Pick, requiredEnrollmentType: EnrollmentType ) => { @@ -107,6 +110,19 @@ const validateProfileAndPermissions = async ( }); } + if (actor === ActorType.ACME_ACCOUNT && requiredEnrollmentType === EnrollmentType.ACME) { + const account = await acmeAccountDAL.findById(actorId); + if (!account) { + throw new NotFoundError({ message: "ACME account not found" }); + } + if (account.profileId !== profile.id) { + throw new ForbiddenRequestError({ + message: "ACME account is not associated with this profile" + }); + } + return profile; + } + const { permission } = await permissionService.getProjectPermission({ actor, actorId, @@ -336,6 +352,7 @@ export const certificateV3ServiceFactory = ({ certificateSecretDAL, certificateAuthorityDAL, certificateProfileDAL, + acmeAccountDAL, certificateTemplateV2Service, internalCaService, permissionService, @@ -358,6 +375,7 @@ export const certificateV3ServiceFactory = ({ actorAuthMethod, actorOrgId, certificateProfileDAL, + acmeAccountDAL, permissionService, EnrollmentType.API ); @@ -484,7 +502,8 @@ export const certificateV3ServiceFactory = ({ actor, actorId, actorAuthMethod, - actorOrgId + actorOrgId, + enrollmentType }: TSignCertificateFromProfileDTO): Promise> => { const profile = await validateProfileAndPermissions( profileId, @@ -493,8 +512,9 @@ export const certificateV3ServiceFactory = ({ actorAuthMethod, actorOrgId, certificateProfileDAL, + acmeAccountDAL, permissionService, - EnrollmentType.API + enrollmentType ); const ca = await certificateAuthorityDAL.findByIdWithAssociatedCa(profile.caId); @@ -595,6 +615,7 @@ export const certificateV3ServiceFactory = ({ actorAuthMethod, actorOrgId, certificateProfileDAL, + acmeAccountDAL, permissionService, EnrollmentType.API ); @@ -654,7 +675,7 @@ export const certificateV3ServiceFactory = ({ status: CertificateOrderStatus.VALID })), authorizations: [], - finalize: `/api/v3/certificates/orders/${orderId}/completed`, + finalize: `/api/v3/pki/certificates/orders/${orderId}/completed`, certificate: certificateResult.certificate, projectId: certificateResult.projectId, profileName: certificateResult.profileName diff --git a/backend/src/services/certificate-v3/certificate-v3-types.ts b/backend/src/services/certificate-v3/certificate-v3-types.ts index a62a25b73..8a2cf70f7 100644 --- a/backend/src/services/certificate-v3/certificate-v3-types.ts +++ b/backend/src/services/certificate-v3/certificate-v3-types.ts @@ -6,6 +6,7 @@ import { CertKeyUsageType, CertSubjectAlternativeNameType } from "../certificate-common/certificate-constants"; +import { EnrollmentType } from "../certificate-profile/certificate-profile-types"; export type TIssueCertificateFromProfileDTO = { profileId: string; @@ -35,6 +36,7 @@ export type TSignCertificateFromProfileDTO = { }; notBefore?: Date; notAfter?: Date; + enrollmentType: EnrollmentType; } & Omit; export type TOrderCertificateFromProfileDTO = { diff --git a/backend/src/services/enrollment-config/acme-enrollment-config-dal.ts b/backend/src/services/enrollment-config/acme-enrollment-config-dal.ts new file mode 100644 index 000000000..afa8f17ef --- /dev/null +++ b/backend/src/services/enrollment-config/acme-enrollment-config-dal.ts @@ -0,0 +1,61 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify } from "@app/lib/knex"; + +import { TAcmeEnrollmentConfigInsert, TAcmeEnrollmentConfigUpdate } from "./enrollment-config-types"; + +export type TAcmeEnrollmentConfigDALFactory = ReturnType; + +export const acmeEnrollmentConfigDALFactory = (db: TDbClient) => { + const acmeEnrollmentConfigOrm = ormify(db, TableName.PkiAcmeEnrollmentConfig); + + const create = async (data: TAcmeEnrollmentConfigInsert, tx?: Knex) => { + try { + const result = await (tx || db)(TableName.PkiAcmeEnrollmentConfig).insert(data).returning("*"); + const [acmeConfig] = result; + + if (!acmeConfig) { + throw new Error("Failed to create ACME enrollment config"); + } + + return acmeConfig; + } catch (error) { + throw new DatabaseError({ error, name: "Create ACME enrollment config" }); + } + }; + + const updateById = async (id: string, data: TAcmeEnrollmentConfigUpdate, tx?: Knex) => { + try { + const result = await (tx || db)(TableName.PkiAcmeEnrollmentConfig).where({ id }).update(data).returning("*"); + const [acmeConfig] = result; + + if (!acmeConfig) { + return null; + } + + return acmeConfig; + } catch (error) { + throw new DatabaseError({ error, name: "Update ACME enrollment config" }); + } + }; + + const findById = async (id: string, tx?: Knex) => { + try { + const acmeConfig = await (tx || db)(TableName.PkiAcmeEnrollmentConfig).where({ id }).first(); + + return acmeConfig || null; + } catch (error) { + throw new DatabaseError({ error, name: "Find ACME enrollment config by id" }); + } + }; + + return { + ...acmeEnrollmentConfigOrm, + create, + updateById, + findById + }; +}; diff --git a/backend/src/services/enrollment-config/enrollment-config-types.ts b/backend/src/services/enrollment-config/enrollment-config-types.ts index d2e03e4da..7fe5a475d 100644 --- a/backend/src/services/enrollment-config/enrollment-config-types.ts +++ b/backend/src/services/enrollment-config/enrollment-config-types.ts @@ -1,3 +1,8 @@ +import { + TPkiAcmeEnrollmentConfigs, + TPkiAcmeEnrollmentConfigsInsert, + TPkiAcmeEnrollmentConfigsUpdate +} from "@app/db/schemas/pki-acme-enrollment-configs"; import { TPkiApiEnrollmentConfigs, TPkiApiEnrollmentConfigsInsert, @@ -17,6 +22,10 @@ export type TApiEnrollmentConfig = TPkiApiEnrollmentConfigs; export type TApiEnrollmentConfigInsert = TPkiApiEnrollmentConfigsInsert; export type TApiEnrollmentConfigUpdate = TPkiApiEnrollmentConfigsUpdate; +export type TAcmeEnrollmentConfig = TPkiAcmeEnrollmentConfigs; +export type TAcmeEnrollmentConfigInsert = TPkiAcmeEnrollmentConfigsInsert; +export type TAcmeEnrollmentConfigUpdate = TPkiAcmeEnrollmentConfigsUpdate; + export interface TEstConfigData { disableBootstrapCaValidation: boolean; passphrase: string; @@ -27,3 +36,5 @@ export interface TApiConfigData { autoRenew: boolean; renewBeforeDays?: number; } + +export interface TAcmeConfigData {} diff --git a/backend/src/services/microsoft-teams/project-microsoft-teams-config-dal.ts b/backend/src/services/microsoft-teams/project-microsoft-teams-config-dal.ts index 918b96e89..b1f9fec02 100644 --- a/backend/src/services/microsoft-teams/project-microsoft-teams-config-dal.ts +++ b/backend/src/services/microsoft-teams/project-microsoft-teams-config-dal.ts @@ -1,16 +1,20 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; +import { TableName, TMicrosoftTeamsIntegrations } from "@app/db/schemas"; +import { TProjectMicrosoftTeamsConfigs } from "@app/db/schemas/project-microsoft-teams-configs"; import { ormify, selectAllTableCols } from "@app/lib/knex"; export type TProjectMicrosoftTeamsConfigDALFactory = ReturnType; +export type TProjectMicrosoftTeamsConfigWithIntegrations = TProjectMicrosoftTeamsConfigs & TMicrosoftTeamsIntegrations; export const projectMicrosoftTeamsConfigDALFactory = (db: TDbClient) => { const projectMicrosoftTeamsConfigOrm = ormify(db, TableName.ProjectMicrosoftTeamsConfigs); const getIntegrationDetailsByProject = (projectId: string, tx?: Knex) => { - return (tx || db.replicaNode())(TableName.ProjectMicrosoftTeamsConfigs) + return (tx || db.replicaNode())( + TableName.ProjectMicrosoftTeamsConfigs + ) .join( TableName.MicrosoftTeamsIntegrations, `${TableName.ProjectMicrosoftTeamsConfigs}.microsoftTeamsIntegrationId`, diff --git a/backend/src/services/pki-alert-v2/pki-alert-channel-dal.ts b/backend/src/services/pki-alert-v2/pki-alert-channel-dal.ts new file mode 100644 index 000000000..b16432a34 --- /dev/null +++ b/backend/src/services/pki-alert-v2/pki-alert-channel-dal.ts @@ -0,0 +1,61 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName, TPkiAlertChannels, TPkiAlertChannelsInsert } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +export type TPkiAlertChannelDALFactory = ReturnType; + +export const pkiAlertChannelDALFactory = (db: TDbClient) => { + const pkiAlertChannelOrm = ormify(db, TableName.PkiAlertChannels); + + const insertMany = async (data: TPkiAlertChannelsInsert[], tx?: Knex): Promise => { + try { + if (!data.length) return []; + + const serializedData = data.map((item) => ({ + ...item, + config: item.config ? JSON.stringify(item.config) : null + })); + + const res = await (tx || db)(TableName.PkiAlertChannels).insert(serializedData).returning("*"); + + return res as TPkiAlertChannels[]; + } catch (error) { + throw new DatabaseError({ error, name: "InsertMany" }); + } + }; + + const findByAlertId = async (alertId: string, tx?: Knex): Promise => { + try { + const channels = await (tx || db.replicaNode())(TableName.PkiAlertChannels) + .where(`${TableName.PkiAlertChannels}.alertId`, alertId) + .select(selectAllTableCols(TableName.PkiAlertChannels)) + .orderBy(`${TableName.PkiAlertChannels}.createdAt`, "asc"); + + return channels as TPkiAlertChannels[]; + } catch (error) { + throw new DatabaseError({ error, name: "FindByAlertId" }); + } + }; + + const deleteByAlertId = async (alertId: string, tx?: Knex): Promise => { + try { + const deletedCount = await (tx || db)(TableName.PkiAlertChannels) + .where(`${TableName.PkiAlertChannels}.alertId`, alertId) + .del(); + + return deletedCount; + } catch (error) { + throw new DatabaseError({ error, name: "DeleteByAlertId" }); + } + }; + + return { + ...pkiAlertChannelOrm, + insertMany, + findByAlertId, + deleteByAlertId + }; +}; diff --git a/backend/src/services/pki-alert-v2/pki-alert-history-dal.ts b/backend/src/services/pki-alert-v2/pki-alert-history-dal.ts new file mode 100644 index 000000000..ceb2820c4 --- /dev/null +++ b/backend/src/services/pki-alert-v2/pki-alert-history-dal.ts @@ -0,0 +1,110 @@ +import { TDbClient } from "@app/db"; +import { TableName, TPkiAlertHistory } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +export type TPkiAlertHistoryDALFactory = ReturnType; + +export const pkiAlertHistoryDALFactory = (db: TDbClient) => { + const pkiAlertHistoryOrm = ormify(db, TableName.PkiAlertHistory); + + const createWithCertificates = async ( + alertId: string, + certificateIds: string[], + options?: { + hasNotificationSent?: boolean; + notificationError?: string; + } + ): Promise => { + try { + return await db.transaction(async (tx) => { + const historyRecords = await tx(TableName.PkiAlertHistory) + .insert({ + alertId, + hasNotificationSent: options?.hasNotificationSent || false, + notificationError: options?.notificationError + }) + .returning("*"); + + const historyRecord = historyRecords[0]; + + if (certificateIds.length > 0) { + const certificateAssociations = certificateIds.map((certificateId) => ({ + alertHistoryId: historyRecord.id, + certificateId + })); + + await tx(TableName.PkiAlertHistoryCertificate).insert(certificateAssociations); + } + + return historyRecord; + }); + } catch (error) { + throw new DatabaseError({ error, name: "CreateWithCertificates" }); + } + }; + + const findByAlertId = async ( + alertId: string, + options?: { + limit?: number; + offset?: number; + } + ): Promise => { + try { + let query = db + .replicaNode() + .select(selectAllTableCols(TableName.PkiAlertHistory)) + .from(TableName.PkiAlertHistory) + .where(`${TableName.PkiAlertHistory}.alertId`, alertId) + .orderBy(`${TableName.PkiAlertHistory}.triggeredAt`, "desc"); + + if (options?.limit) { + query = query.limit(options.limit); + } + + if (options?.offset) { + query = query.offset(options.offset); + } + + const results = await query; + return results as TPkiAlertHistory[]; + } catch (error) { + throw new DatabaseError({ error, name: "FindByAlertId" }); + } + }; + + const findRecentlyAlertedCertificates = async ( + alertId: string, + certificateIds: string[], + withinHours = 24 + ): Promise => { + try { + if (certificateIds.length === 0) return []; + + const cutoffDate = new Date(); + cutoffDate.setHours(cutoffDate.getHours() - withinHours); + + const results = (await db + .replicaNode() + .select("cert.certificateId") + .from(`${TableName.PkiAlertHistory} as hist`) + .join(`${TableName.PkiAlertHistoryCertificate} as cert`, "hist.id", "cert.alertHistoryId") + .where("hist.alertId", alertId) + .where("hist.hasNotificationSent", true) + .where("hist.triggeredAt", ">=", cutoffDate) + .whereIn("cert.certificateId", certificateIds)) as Array<{ certificateId: string }>; + + return results.map((row) => row.certificateId); + } catch (error) { + throw new DatabaseError({ error, name: "FindRecentlyAlertedCertificates" }); + } + }; + + return { + ...pkiAlertHistoryOrm, + createWithCertificates, + findByAlertId, + findRecentlyAlertedCertificates + }; +}; diff --git a/backend/src/services/pki-alert-v2/pki-alert-v2-dal.ts b/backend/src/services/pki-alert-v2/pki-alert-v2-dal.ts new file mode 100644 index 000000000..d10c1689b --- /dev/null +++ b/backend/src/services/pki-alert-v2/pki-alert-v2-dal.ts @@ -0,0 +1,556 @@ +import { Knex } from "knex"; + +import { TDbClient } from "@app/db"; +import { TableName, TPkiAlertsV2, TPkiAlertsV2Insert, TPkiAlertsV2Update } from "@app/db/schemas"; +import { DatabaseError } from "@app/lib/errors"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +import { + applyCaFilters, + applyCertificateFilters, + requiresProfileJoin, + sanitizeLikeInput, + shouldIncludeCAs +} from "./pki-alert-v2-filter-utils"; +import { CertificateOrigin, TCertificatePreview, TPkiFilterRule } from "./pki-alert-v2-types"; + +export type TPkiAlertV2DALFactory = ReturnType; + +export const pkiAlertV2DALFactory = (db: TDbClient) => { + const pkiAlertV2Orm = ormify(db, TableName.PkiAlertsV2); + + const create = async (data: TPkiAlertsV2Insert, tx?: Knex): Promise => { + try { + const serializedData = { + ...data, + filters: data.filters ? JSON.stringify(data.filters) : null + }; + const [res] = await (tx || db)(TableName.PkiAlertsV2).insert(serializedData).returning("*"); + + return res; + } catch (error) { + throw new DatabaseError({ error, name: "Create" }); + } + }; + + const updateById = async (id: string, data: TPkiAlertsV2Update, tx?: Knex): Promise => { + try { + const serializedData: Record = { + ...data, + filters: data.filters !== undefined ? JSON.stringify(data.filters) : undefined + }; + Object.keys(serializedData).forEach((key) => { + if (serializedData[key] === undefined) { + delete serializedData[key]; + } + }); + + const [res] = await (tx || db)(TableName.PkiAlertsV2).where({ id }).update(serializedData).returning("*"); + + return res; + } catch (error) { + throw new DatabaseError({ error, name: "UpdateById" }); + } + }; + + const findById = async (id: string, tx?: Knex): Promise => { + try { + const [res] = await (tx || db.replicaNode())(TableName.PkiAlertsV2).where({ id }).select("*"); + + if (!res) return null; + + return res; + } catch (error) { + throw new DatabaseError({ error, name: "FindById" }); + } + }; + + type TChannelResult = { + id: string; + alertId: string; + channelType: string; + config: unknown; + enabled: boolean; + createdAt: Date; + updatedAt: Date; + }; + + type TAlertWithChannels = TPkiAlertsV2 & { + channels: TChannelResult[]; + }; + + const findByIdWithChannels = async (alertId: string, tx?: Knex): Promise => { + try { + const [alert] = (await (tx || db.replicaNode()) + .select(selectAllTableCols(TableName.PkiAlertsV2)) + .from(TableName.PkiAlertsV2) + .where(`${TableName.PkiAlertsV2}.id`, alertId)) as TPkiAlertsV2[]; + + if (!alert) return null; + + const channels = (await (tx || db.replicaNode()) + .select(selectAllTableCols(TableName.PkiAlertChannels)) + .from(TableName.PkiAlertChannels) + .where(`${TableName.PkiAlertChannels}.alertId`, alertId)) as TChannelResult[]; + + return { + ...alert, + channels: channels || [] + } as TAlertWithChannels; + } catch (error) { + throw new DatabaseError({ error, name: "FindByIdWithChannels" }); + } + }; + + const findByProjectIdWithCount = async ( + projectId: string, + filters?: { + search?: string; + eventType?: string; + enabled?: boolean; + limit?: number; + offset?: number; + }, + tx?: Knex + ): Promise<{ alerts: TAlertWithChannels[]; total: number }> => { + try { + let countQuery = (tx || db.replicaNode()) + .count("* as count") + .from(TableName.PkiAlertsV2) + .where(`${TableName.PkiAlertsV2}.projectId`, projectId); + + if (filters?.search) { + countQuery = countQuery.whereILike(`${TableName.PkiAlertsV2}.name`, `%${sanitizeLikeInput(filters.search)}%`); + } + + if (filters?.eventType) { + countQuery = countQuery.where(`${TableName.PkiAlertsV2}.eventType`, filters.eventType); + } + + if (filters?.enabled !== undefined) { + countQuery = countQuery.where(`${TableName.PkiAlertsV2}.enabled`, filters.enabled); + } + + let alertQuery = (tx || db.replicaNode()) + .select(selectAllTableCols(TableName.PkiAlertsV2)) + .from(TableName.PkiAlertsV2) + .where(`${TableName.PkiAlertsV2}.projectId`, projectId); + + if (filters?.search) { + alertQuery = alertQuery.whereILike(`${TableName.PkiAlertsV2}.name`, `%${sanitizeLikeInput(filters.search)}%`); + } + + if (filters?.eventType) { + alertQuery = alertQuery.where(`${TableName.PkiAlertsV2}.eventType`, filters.eventType); + } + + if (filters?.enabled !== undefined) { + alertQuery = alertQuery.where(`${TableName.PkiAlertsV2}.enabled`, filters.enabled); + } + + alertQuery = alertQuery.orderBy(`${TableName.PkiAlertsV2}.createdAt`, "desc"); + + if (filters?.limit) { + alertQuery = alertQuery.limit(filters.limit); + } + + if (filters?.offset) { + alertQuery = alertQuery.offset(filters.offset); + } + + const [countResult, alerts] = await Promise.all([countQuery, alertQuery]); + + const total = parseInt((countResult[0] as { count: string }).count, 10); + + const alertIds = (alerts as TPkiAlertsV2[]).map((alert) => alert.id); + const channels = (await (tx || db.replicaNode()) + .select(selectAllTableCols(TableName.PkiAlertChannels)) + .from(TableName.PkiAlertChannels) + .whereIn(`${TableName.PkiAlertChannels}.alertId`, alertIds)) as TChannelResult[]; + + const channelsByAlertId = channels.reduce( + (acc, channel) => { + if (!acc[channel.alertId]) { + acc[channel.alertId] = []; + } + acc[channel.alertId].push(channel); + return acc; + }, + {} as Record + ); + + const alertsWithChannels: TAlertWithChannels[] = (alerts as TPkiAlertsV2[]).map((alert) => ({ + ...alert, + channels: channelsByAlertId[alert.id] || [] + })); + + return { alerts: alertsWithChannels, total }; + } catch (error) { + throw new DatabaseError({ error, name: "FindByProjectIdWithCount" }); + } + }; + + const findByProjectId = async ( + projectId: string, + filters?: { + search?: string; + eventType?: string; + enabled?: boolean; + limit?: number; + offset?: number; + }, + tx?: Knex + ): Promise => { + const result = await findByProjectIdWithCount(projectId, filters, tx); + return result.alerts; + }; + + const countByProjectId = async ( + projectId: string, + filters?: { + search?: string; + eventType?: string; + enabled?: boolean; + }, + tx?: Knex + ): Promise => { + try { + let query = (tx || db.replicaNode()) + .count("* as count") + .from(TableName.PkiAlertsV2) + .where(`${TableName.PkiAlertsV2}.projectId`, projectId); + + if (filters?.search) { + query = query.whereILike(`${TableName.PkiAlertsV2}.name`, `%${sanitizeLikeInput(filters.search)}%`); + } + + if (filters?.eventType) { + query = query.where(`${TableName.PkiAlertsV2}.eventType`, filters.eventType); + } + + if (filters?.enabled !== undefined) { + query = query.where(`${TableName.PkiAlertsV2}.enabled`, filters.enabled); + } + + const result = await query; + return parseInt((result[0] as { count: string }).count, 10); + } catch (error) { + throw new DatabaseError({ error, name: "CountByProjectId" }); + } + }; + + const getDistinctProjectIds = async ( + filters?: { + enabled?: boolean; + }, + tx?: Knex + ): Promise => { + try { + let query = (tx || db.replicaNode()).distinct(`${TableName.PkiAlertsV2}.projectId`).from(TableName.PkiAlertsV2); + + if (filters?.enabled !== undefined) { + query = query.where(`${TableName.PkiAlertsV2}.enabled`, filters.enabled); + } + + const result = await query; + return result.map((row: { projectId: string }) => row.projectId); + } catch (error) { + throw new DatabaseError({ error, name: "GetDistinctProjectIds" }); + } + }; + + const findMatchingCertificates = async ( + projectId: string, + filters: TPkiFilterRule[] = [], + options?: { + limit?: number; + offset?: number; + alertBefore?: string; + showFutureMatches?: boolean; + showCurrentMatches?: boolean; + showPreview?: boolean; + excludeAlerted?: boolean; + alertId?: string; + }, + tx?: Knex + ): Promise<{ certificates: TCertificatePreview[]; total: number }> => { + try { + const includeCAs = shouldIncludeCAs(filters); + const needsProfileJoin = requiresProfileJoin(filters); + const limit = options?.limit || 10; + const offset = options?.offset || 0; + + let caTotalCount = 0; + let certTotalCount = 0; + + if (includeCAs) { + let caCountQuery = (tx || db.replicaNode()) + .count("* as count") + .from(TableName.CertificateAuthority) + .innerJoin( + `${TableName.InternalCertificateAuthority} as ica`, + `${TableName.CertificateAuthority}.id`, + `ica.caId` + ); + + caCountQuery = applyCaFilters(caCountQuery, filters, projectId) as typeof caCountQuery; + + if (options?.alertBefore) { + if (options.showFutureMatches) { + caCountQuery = caCountQuery + .whereRaw(`ica."notAfter" > NOW() + ?::interval`, [options.alertBefore]) + .whereRaw(`ica."notAfter" > NOW()`); + } else if (options.showCurrentMatches) { + caCountQuery = caCountQuery + .whereRaw(`ica."notAfter" > NOW()`) + .whereRaw(`ica."notAfter" <= NOW() + ?::interval`, [options.alertBefore]); + } else { + caCountQuery = caCountQuery + .whereRaw(`ica."notAfter" > NOW()`) + .whereRaw(`ica."notAfter" <= NOW() + ?::interval`, [options.alertBefore]); + } + } + + const caCountResult = await caCountQuery; + caTotalCount = parseInt((caCountResult[0] as { count: string }).count, 10); + } + + let certCountQuery = (tx || db.replicaNode()).count("* as count").from(TableName.Certificate); + certCountQuery = applyCertificateFilters(certCountQuery, filters, projectId) as typeof certCountQuery; + + if (options?.showPreview) { + certCountQuery = certCountQuery + .whereRaw(`"${TableName.Certificate}"."notAfter" > NOW()`) + .whereNot(`${TableName.Certificate}.status`, "revoked"); + } else if (options?.alertBefore) { + if (options.showFutureMatches) { + certCountQuery = certCountQuery + .whereRaw(`"${TableName.Certificate}"."notAfter" > NOW() + ?::interval`, [options.alertBefore]) + .whereRaw(`"${TableName.Certificate}"."notAfter" > NOW()`) + .whereNot(`${TableName.Certificate}.status`, "revoked"); + } else if (options.showCurrentMatches) { + certCountQuery = certCountQuery + .whereRaw(`"${TableName.Certificate}"."notAfter" > NOW()`) + .whereRaw(`"${TableName.Certificate}"."notAfter" <= NOW() + ?::interval`, [options.alertBefore]) + .whereNot(`${TableName.Certificate}.status`, "revoked"); + } else { + certCountQuery = certCountQuery + .whereRaw(`"${TableName.Certificate}"."notAfter" > NOW()`) + .whereRaw(`"${TableName.Certificate}"."notAfter" <= NOW() + ?::interval`, [options.alertBefore]) + .whereNot(`${TableName.Certificate}.status`, "revoked"); + } + } + + if (options?.excludeAlerted && options?.alertId) { + certCountQuery = certCountQuery.whereNotExists( + (tx || db.replicaNode()) + .select("*") + .from(TableName.PkiAlertHistory) + .join( + TableName.PkiAlertHistoryCertificate, + `${TableName.PkiAlertHistory}.id`, + `${TableName.PkiAlertHistoryCertificate}.alertHistoryId` + ) + .where(`${TableName.PkiAlertHistory}.alertId`, options.alertId) + .whereRaw(`"${TableName.PkiAlertHistoryCertificate}"."certificateId" = "${TableName.Certificate}".id`) + ); + } + + const certCountResult = await certCountQuery; + certTotalCount = parseInt((certCountResult[0] as { count: string }).count, 10); + + const totalCount = caTotalCount + certTotalCount; + let results: TCertificatePreview[] = []; + + const fetchCertificates = async (certLimit: number, certOffset: number) => { + const selectColumns = [ + `${TableName.Certificate}.id`, + `${TableName.Certificate}.serialNumber`, + `${TableName.Certificate}.commonName`, + `${TableName.Certificate}.altNames as san`, + `${TableName.Certificate}.notBefore`, + `${TableName.Certificate}.notAfter`, + `${TableName.Certificate}.status`, + `${TableName.Certificate}.profileId`, + `${TableName.Certificate}.pkiSubscriberId` + ]; + + if (needsProfileJoin) { + selectColumns.push("profile.name as profileName"); + } + + let certificateQuery = (tx || db.replicaNode()).select(selectColumns).from(TableName.Certificate); + + certificateQuery = applyCertificateFilters(certificateQuery, filters, projectId) as typeof certificateQuery; + + if (options?.showPreview) { + certificateQuery = certificateQuery + .whereRaw(`"${TableName.Certificate}"."notAfter" > NOW()`) + .whereNot(`${TableName.Certificate}.status`, "revoked"); + } else if (options?.alertBefore) { + if (options.showFutureMatches) { + certificateQuery = certificateQuery + .whereRaw(`"${TableName.Certificate}"."notAfter" > NOW() + ?::interval`, [options.alertBefore]) + .whereRaw(`"${TableName.Certificate}"."notAfter" > NOW()`) + .whereNot(`${TableName.Certificate}.status`, "revoked"); + } else if (options.showCurrentMatches) { + certificateQuery = certificateQuery + .whereRaw(`"${TableName.Certificate}"."notAfter" > NOW()`) + .whereRaw(`"${TableName.Certificate}"."notAfter" <= NOW() + ?::interval`, [options.alertBefore]) + .whereNot(`${TableName.Certificate}.status`, "revoked"); + } else { + certificateQuery = certificateQuery + .whereRaw(`"${TableName.Certificate}"."notAfter" > NOW()`) + .whereRaw(`"${TableName.Certificate}"."notAfter" <= NOW() + ?::interval`, [options.alertBefore]) + .whereNot(`${TableName.Certificate}.status`, "revoked"); + } + } + + if (options?.excludeAlerted && options?.alertId) { + certificateQuery = certificateQuery.whereNotExists( + (tx || db.replicaNode()) + .select("*") + .from(TableName.PkiAlertHistory) + .join( + TableName.PkiAlertHistoryCertificate, + `${TableName.PkiAlertHistory}.id`, + `${TableName.PkiAlertHistoryCertificate}.alertHistoryId` + ) + .where(`${TableName.PkiAlertHistory}.alertId`, options.alertId) + .whereRaw(`"${TableName.PkiAlertHistoryCertificate}"."certificateId" = "${TableName.Certificate}".id`) + ); + } + + certificateQuery = certificateQuery + .orderBy(`${TableName.Certificate}.notAfter`, "asc") + .limit(certLimit) + .offset(certOffset); + + const certificates = await certificateQuery; + const formattedCertificates: TCertificatePreview[] = ( + certificates as Array<{ + id: string; + serialNumber: string; + commonName: string; + san: string[] | null; + profileId: string | null; + pkiSubscriberId: string | null; + profileName?: string | null; + notBefore: Date; + notAfter: Date; + status: string; + }> + ).map((cert) => { + let enrollmentType = CertificateOrigin.UNKNOWN; + if (cert.profileId) { + enrollmentType = CertificateOrigin.PROFILE; + } else if (cert.pkiSubscriberId) { + enrollmentType = CertificateOrigin.IMPORT; + } + + return { + id: cert.id, + serialNumber: cert.serialNumber, + commonName: cert.commonName, + san: Array.isArray(cert.san) ? cert.san : [], + profileName: cert.profileName || null, + enrollmentType, + notBefore: cert.notBefore, + notAfter: cert.notAfter, + status: cert.status + }; + }); + + results = [...results, ...formattedCertificates]; + }; + + if (offset < caTotalCount) { + const caLimit = Math.min(limit, caTotalCount - offset); + const caOffset = offset; + + let caQuery = (tx || db.replicaNode()) + .select( + `${TableName.CertificateAuthority}.id`, + `ica.serialNumber`, + `ica.commonName`, + `ica.notBefore`, + `ica.notAfter` + ) + .from(TableName.CertificateAuthority) + .innerJoin( + `${TableName.InternalCertificateAuthority} as ica`, + `${TableName.CertificateAuthority}.id`, + `ica.caId` + ); + + caQuery = applyCaFilters(caQuery, filters, projectId) as typeof caQuery; + + if (options?.alertBefore) { + if (options.showFutureMatches) { + caQuery = caQuery + .whereRaw(`ica."notAfter" > NOW() + ?::interval`, [options.alertBefore]) + .whereRaw(`ica."notAfter" > NOW()`); + } else { + caQuery = caQuery + .whereRaw(`ica."notAfter" > NOW()`) + .whereRaw(`ica."notAfter" <= NOW() + ?::interval`, [options.alertBefore]); + } + } + + caQuery = caQuery.orderBy(`ica.notAfter`, "asc").limit(caLimit).offset(caOffset); + + const cas = await caQuery; + const formattedCAs: TCertificatePreview[] = ( + cas as Array<{ + id: string; + serialNumber: string; + commonName: string; + notBefore: Date; + notAfter: Date; + }> + ).map((ca) => ({ + id: ca.id, + serialNumber: ca.serialNumber, + commonName: ca.commonName, + san: [], + profileName: null, + enrollmentType: CertificateOrigin.CA, + notBefore: ca.notBefore, + notAfter: ca.notAfter, + status: "active" + })); + + results = [...results, ...formattedCAs]; + + const remainingLimit = limit - caLimit; + if (remainingLimit > 0 && certTotalCount > 0) { + const certOffset = 0; + await fetchCertificates(remainingLimit, certOffset); + } + } else { + const certOffset = offset - caTotalCount; + await fetchCertificates(limit, certOffset); + } + + return { + certificates: results, + total: totalCount + }; + } catch (error) { + throw new DatabaseError({ error, name: "FindMatchingCertificates" }); + } + }; + + return { + ...pkiAlertV2Orm, + create, + updateById, + findById, + findByIdWithChannels, + findByProjectId, + findByProjectIdWithCount, + countByProjectId, + getDistinctProjectIds, + findMatchingCertificates + }; +}; diff --git a/backend/src/services/pki-alert-v2/pki-alert-v2-filter-utils.ts b/backend/src/services/pki-alert-v2/pki-alert-v2-filter-utils.ts new file mode 100644 index 000000000..cb7f9cf72 --- /dev/null +++ b/backend/src/services/pki-alert-v2/pki-alert-v2-filter-utils.ts @@ -0,0 +1,358 @@ +import { Knex } from "knex"; +import RE2 from "re2"; + +import { TableName } from "@app/db/schemas"; +import { logger } from "@app/lib/logger"; + +import { PkiFilterField, PkiFilterOperator, TPkiFilterRule } from "./pki-alert-v2-types"; + +export const sanitizeLikeInput = (input: string): string => { + const allowedCharsRegex = new RE2("^[a-zA-Z0-9\\s\\-_\\.@\\*]+$"); + if (!allowedCharsRegex.test(input)) { + throw new Error( + "Invalid characters in input. Only alphanumeric characters, spaces, hyphens, underscores, dots, @ and * are allowed." + ); + } + + const backslashRegex = new RE2("\\\\", "g"); + const percentRegex = new RE2("%", "g"); + const underscoreRegex = new RE2("_", "g"); + const quoteRegex = new RE2("'", "g"); + + return input + .replace(backslashRegex, "\\\\\\\\") + .replace(percentRegex, "\\%") + .replace(underscoreRegex, "\\_") + .replace(quoteRegex, "''"); +}; + +export const parseTimeToPostgresInterval = (duration: string): string => { + if (duration.length > 32) { + throw new Error(`Invalid duration format: ${duration}. Use format like '30d', '1w', '3m', '1y'`); + } + + const durationRegex = new RE2("^(\\d+)([dwmy])$"); + const match = durationRegex.exec(duration); + + if (!match) { + throw new Error(`Invalid duration format: ${duration}. Use format like '30d', '1w', '3m', '1y'`); + } + + const [, value, unit] = match; + const amount = parseInt(value, 10); + + if (amount <= 0 || amount > 9999) { + throw new Error(`Duration value out of range: ${duration}. Must be between 1 and 9999.`); + } + + const unitMap = { + d: "days", + w: "weeks", + m: "months", + y: "years" + }; + + return `${amount} ${unitMap[unit as keyof typeof unitMap]}`; +}; + +export const parseTimeToDays = (timeStr: string): number => { + const alertBeforeRegex = new RE2("^(\\d+)([dwmy])$"); + const match = alertBeforeRegex.exec(timeStr); + if (!match) { + return 0; + } + + const [, value, unit] = match; + const amount = parseInt(value, 10); + + if (amount <= 0 || amount > 9999) { + return 0; + } + + switch (unit) { + case "d": + return amount; + case "w": + return amount * 7; + case "m": + return amount * 30; + case "y": + return amount * 365; + default: + return 0; + } +}; + +const applyProfileNameFilter = (query: Knex.QueryBuilder, filter: TPkiFilterRule): Knex.QueryBuilder => { + const { value } = filter; + + switch (filter.operator) { + case PkiFilterOperator.EQUALS: + return query.where("profile.slug", value as string); + + case PkiFilterOperator.MATCHES: + if (Array.isArray(value)) { + return query.whereIn("profile.slug", value); + } + return query.whereILike("profile.slug", `%${sanitizeLikeInput(String(value))}%`); + + case PkiFilterOperator.CONTAINS: + if (Array.isArray(value)) { + return query.where((builder) => { + value.forEach((v, index) => { + const sanitizedValue = sanitizeLikeInput(String(v)); + if (index === 0) { + void builder.whereILike("profile.slug", `%${sanitizedValue}%`); + } else { + void builder.orWhereILike("profile.slug", `%${sanitizedValue}%`); + } + }); + }); + } + return query.whereILike("profile.slug", `%${sanitizeLikeInput(String(value))}%`); + + case PkiFilterOperator.STARTS_WITH: + return query.whereILike("profile.slug", `${sanitizeLikeInput(String(value))}%`); + + case PkiFilterOperator.ENDS_WITH: + return query.whereILike("profile.slug", `%${sanitizeLikeInput(String(value))}`); + + default: + logger.warn(`Unsupported operator for profile_name: ${String(filter.operator)}`); + return query; + } +}; + +const applyCommonNameFilter = (query: Knex.QueryBuilder, filter: TPkiFilterRule): Knex.QueryBuilder => { + const { value } = filter; + const columnName = `${TableName.Certificate}.commonName`; + + switch (filter.operator) { + case PkiFilterOperator.EQUALS: + return query.where(columnName, value as string); + + case PkiFilterOperator.MATCHES: + if (Array.isArray(value)) { + return query.whereIn(columnName, value); + } + return query.whereILike(columnName, `%${sanitizeLikeInput(String(value))}%`); + + case PkiFilterOperator.CONTAINS: + if (Array.isArray(value)) { + return query.where((builder) => { + value.forEach((v, index) => { + const sanitizedValue = sanitizeLikeInput(String(v)); + if (index === 0) { + void builder.whereILike(columnName, `%${sanitizedValue}%`); + } else { + void builder.orWhereILike(columnName, `%${sanitizedValue}%`); + } + }); + }); + } + return query.whereILike(columnName, `%${sanitizeLikeInput(String(value))}%`); + + case PkiFilterOperator.STARTS_WITH: + return query.whereILike(columnName, `${sanitizeLikeInput(String(value))}%`); + + case PkiFilterOperator.ENDS_WITH: + return query.whereILike(columnName, `%${sanitizeLikeInput(String(value))}`); + + default: + logger.warn(`Unsupported operator for common_name: ${String(filter.operator)}`); + return query; + } +}; + +const applySanFilter = (query: Knex.QueryBuilder, filter: TPkiFilterRule): Knex.QueryBuilder => { + const { value } = filter; + const columnName = `${TableName.Certificate}.altNames`; + + switch (filter.operator) { + case PkiFilterOperator.EQUALS: + return query.whereJsonSupersetOf(columnName, [value as string]); + + case PkiFilterOperator.MATCHES: + if (Array.isArray(value)) { + return query.where((builder) => { + value.forEach((v, index) => { + const sanitizedValue = `%"${String(v)}"%`; + if (index === 0) { + void builder.whereRaw(`??."altNames"::text ILIKE ?`, [TableName.Certificate, sanitizedValue]); + } else { + void builder.orWhereRaw(`??."altNames"::text ILIKE ?`, [TableName.Certificate, sanitizedValue]); + } + }); + }); + } + { + const sanitizedValue = `%"${String(value)}"%`; + return query.whereRaw(`??."altNames"::text ILIKE ?`, [TableName.Certificate, sanitizedValue]); + } + + case PkiFilterOperator.CONTAINS: + return applySanFilter(query, { ...filter, operator: PkiFilterOperator.MATCHES }); + + case PkiFilterOperator.STARTS_WITH: { + const startsWithValue = `%"${String(value)}%`; + return query.whereRaw(`??."altNames"::text ILIKE ?`, [TableName.Certificate, startsWithValue]); + } + + case PkiFilterOperator.ENDS_WITH: { + const endsWithValue = `%${String(value)}"%`; + return query.whereRaw(`??."altNames"::text ILIKE ?`, [TableName.Certificate, endsWithValue]); + } + + default: + logger.warn(`Unsupported operator for SAN: ${String(filter.operator)}`); + return query; + } +}; + +export const shouldIncludeCAs = (filters: TPkiFilterRule[]): boolean => { + return filters.some((filter) => filter.field === PkiFilterField.INCLUDE_CAS && filter.value === true); +}; + +const applyCaCommonNameFilter = (query: Knex.QueryBuilder, filter: TPkiFilterRule): Knex.QueryBuilder => { + const { value } = filter; + const columnName = "ica.commonName"; + + switch (filter.operator) { + case PkiFilterOperator.EQUALS: + return query.where(columnName, value as string); + + case PkiFilterOperator.MATCHES: + if (Array.isArray(value)) { + return query.whereIn(columnName, value); + } + return query.whereILike(columnName, `%${sanitizeLikeInput(String(value))}%`); + + case PkiFilterOperator.CONTAINS: + if (Array.isArray(value)) { + return query.where((builder) => { + value.forEach((v, index) => { + if (index === 0) { + void builder.whereILike(columnName, `%${sanitizeLikeInput(String(v))}%`); + } else { + void builder.orWhereILike(columnName, `%${sanitizeLikeInput(String(v))}%`); + } + }); + }); + } + return query.whereILike(columnName, `%${sanitizeLikeInput(String(value))}%`); + + case PkiFilterOperator.STARTS_WITH: + return query.whereILike(columnName, `${sanitizeLikeInput(String(value))}%`); + + case PkiFilterOperator.ENDS_WITH: + return query.whereILike(columnName, `%${sanitizeLikeInput(String(value))}`); + + default: + logger.warn(`Unsupported operator for CA common_name: ${String(filter.operator)}`); + return query; + } +}; + +export const applyCaFilters = ( + query: Knex.QueryBuilder, + filters: TPkiFilterRule[], + projectId: string +): Knex.QueryBuilder => { + let filteredQuery = query.where(`${TableName.CertificateAuthority}.projectId`, projectId).whereNotNull("ica.caId"); // Only include CAs that have internal CA data + + filters.forEach((filter) => { + switch (filter.field) { + case PkiFilterField.COMMON_NAME: + filteredQuery = applyCaCommonNameFilter(filteredQuery, filter); + break; + + default: + break; + } + }); + + return filteredQuery; +}; + +export const validateFilterRules = (filters: TPkiFilterRule[]): void => { + for (const filter of filters) { + if (!Object.values(PkiFilterField).includes(filter.field)) { + throw new Error(`Invalid filter field: ${filter.field}`); + } + + if (!Object.values(PkiFilterOperator).includes(filter.operator)) { + throw new Error(`Invalid filter operator: ${filter.operator}`); + } + + switch (filter.field) { + case PkiFilterField.INCLUDE_CAS: + if (typeof filter.value !== "boolean") { + throw new Error("include_cas filter value must be boolean"); + } + break; + + case PkiFilterField.PROFILE_NAME: + case PkiFilterField.COMMON_NAME: + case PkiFilterField.SAN: + if (filter.operator === PkiFilterOperator.CONTAINS || filter.operator === PkiFilterOperator.MATCHES) { + if (!Array.isArray(filter.value) && typeof filter.value !== "string") { + throw new Error( + `${filter.field} filter value must be string or array of strings for ${filter.operator} operator` + ); + } + } else if (typeof filter.value !== "string") { + throw new Error(`${filter.field} filter value must be string for ${filter.operator} operator`); + } + break; + + default: + break; + } + } +}; + +export const requiresProfileJoin = (filters: TPkiFilterRule[]): boolean => { + return filters.some((filter) => filter.field === PkiFilterField.PROFILE_NAME); +}; + +export const applyCertificateFilters = ( + query: Knex.QueryBuilder, + filters: TPkiFilterRule[], + projectId: string +): Knex.QueryBuilder => { + let filteredQuery = query.where(`${TableName.Certificate}.projectId`, projectId); + + const needsProfileJoin = requiresProfileJoin(filters); + if (needsProfileJoin) { + filteredQuery = filteredQuery.leftJoin( + `${TableName.PkiCertificateProfile} as profile`, + `${TableName.Certificate}.profileId`, + "profile.id" + ); + } + + filters.forEach((filter) => { + switch (filter.field) { + case PkiFilterField.PROFILE_NAME: + filteredQuery = applyProfileNameFilter(filteredQuery, filter); + break; + + case PkiFilterField.COMMON_NAME: + filteredQuery = applyCommonNameFilter(filteredQuery, filter); + break; + + case PkiFilterField.SAN: + filteredQuery = applySanFilter(filteredQuery, filter); + break; + + case PkiFilterField.INCLUDE_CAS: + break; + + default: + logger.warn(`Unknown filter field: ${String(filter.field)}`); + break; + } + }); + + return filteredQuery; +}; diff --git a/backend/src/services/pki-alert-v2/pki-alert-v2-queue.ts b/backend/src/services/pki-alert-v2/pki-alert-v2-queue.ts new file mode 100644 index 000000000..81b4bc9ef --- /dev/null +++ b/backend/src/services/pki-alert-v2/pki-alert-v2-queue.ts @@ -0,0 +1,220 @@ +/* eslint-disable no-await-in-loop */ + +import { getConfig } from "@app/lib/config/env"; +import { logger } from "@app/lib/logger"; +import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; + +import { TPkiAlertHistoryDALFactory } from "./pki-alert-history-dal"; +import { TPkiAlertV2DALFactory } from "./pki-alert-v2-dal"; +import { parseTimeToDays, parseTimeToPostgresInterval } from "./pki-alert-v2-filter-utils"; +import { TPkiAlertV2ServiceFactory } from "./pki-alert-v2-service"; +import { CertificateOrigin, PkiAlertEventType, TPkiFilterRule } from "./pki-alert-v2-types"; + +type TPkiAlertV2QueueServiceFactoryDep = { + queueService: TQueueServiceFactory; + pkiAlertV2Service: Pick; + pkiAlertV2DAL: Pick; + pkiAlertHistoryDAL: Pick; +}; + +export type TPkiAlertV2QueueServiceFactory = ReturnType; + +export const pkiAlertV2QueueServiceFactory = ({ + queueService, + pkiAlertV2Service, + pkiAlertV2DAL, + pkiAlertHistoryDAL +}: TPkiAlertV2QueueServiceFactoryDep) => { + const appCfg = getConfig(); + const calculateDeduplicationWindow = (alertBefore: string): number => { + const alertDays = parseTimeToDays(alertBefore); + + if (alertDays === 0) { + return 24; + } + + if (alertDays <= 1) { + return 8; + } + if (alertDays <= 7) { + return 24; + } + if (alertDays <= 30) { + return 48; + } + if (alertDays <= 90) { + return 168; + } + return 720; + }; + + const getAllProjectsWithAlerts = async (): Promise => { + try { + const projectIds = await pkiAlertV2DAL.getDistinctProjectIds({ enabled: true }); + + logger.info(`Found ${projectIds.length} projects with PKI alerts`); + return projectIds; + } catch (error) { + logger.error(error, "Failed to get projects with alerts"); + return []; + } + }; + + const evaluateAlert = async ( + alert: { + id: string; + name: string; + eventType: string; + alertBefore: string; + filters: TPkiFilterRule[]; + }, + projectId: string + ): Promise<{ shouldNotify: boolean; certificateIds: string[] }> => { + if (alert.eventType !== PkiAlertEventType.EXPIRATION) { + return { shouldNotify: false, certificateIds: [] }; + } + + try { + const result = await pkiAlertV2DAL.findMatchingCertificates(projectId, alert.filters, { + limit: 1000, + alertBefore: parseTimeToPostgresInterval(alert.alertBefore), + showCurrentMatches: true + }); + + if (result.certificates.length === 0) { + return { shouldNotify: false, certificateIds: [] }; + } + + const allCertificateIds = result.certificates + .filter((cert) => cert.enrollmentType !== CertificateOrigin.CA) + .map((cert) => cert.id); + + const deduplicationHours = calculateDeduplicationWindow(alert.alertBefore); + const recentlyAlertedIds = await pkiAlertHistoryDAL.findRecentlyAlertedCertificates( + alert.id, + allCertificateIds, + deduplicationHours + ); + + const certificateIds = allCertificateIds.filter((certId) => !recentlyAlertedIds.includes(certId)); + + if (certificateIds.length === 0) { + logger.debug( + `All ${allCertificateIds.length} matching certificates for alert ${alert.id} were already alerted within the last ${deduplicationHours} hours` + ); + return { shouldNotify: false, certificateIds: [] }; + } + + logger.debug( + `Alert ${alert.id}: Found ${allCertificateIds.length} expiring certificates, ${recentlyAlertedIds.length} already alerted recently, ${certificateIds.length} new to alert` + ); + + return { + shouldNotify: true, + certificateIds + }; + } catch (error) { + logger.error(error, `Failed to evaluate alert ${alert.id}`); + return { shouldNotify: false, certificateIds: [] }; + } + }; + + const processProjectAlerts = async ( + projectId: string + ): Promise<{ alertsProcessed: number; notificationsSent: number }> => { + logger.info(`Processing alerts for project: ${projectId}`); + + const alerts = await pkiAlertV2DAL.findByProjectId(projectId, { + enabled: true, + limit: 1000 + }); + + let alertsProcessed = 0; + let notificationsSent = 0; + + for (const alert of alerts) { + const typedAlert = alert as { + id: string; + name: string; + eventType: string; + alertBefore: string; + filters: TPkiFilterRule[]; + }; + try { + const { shouldNotify, certificateIds } = await evaluateAlert(typedAlert, projectId); + + if (shouldNotify && certificateIds.length > 0) { + await pkiAlertV2Service.sendAlertNotifications(typedAlert.id, certificateIds); + notificationsSent += 1; + logger.info( + `Sent notification for alert ${typedAlert.id} (${typedAlert.name}) with ${certificateIds.length} certificates` + ); + } + + alertsProcessed += 1; + } catch (error) { + logger.error(error, `Failed to process alert ${typedAlert.id} (${typedAlert.name})`); + } + } + + logger.info( + `Completed processing ${alertsProcessed} alerts for project ${projectId}, sent ${notificationsSent} notifications` + ); + + return { alertsProcessed, notificationsSent }; + }; + + const processDailyAlerts = async () => { + logger.info("Starting daily PKI alert processing..."); + + const allProjects = await getAllProjectsWithAlerts(); + + let totalAlertsProcessed = 0; + let totalNotificationsSent = 0; + + for (const projectId of allProjects) { + try { + const { alertsProcessed, notificationsSent } = await processProjectAlerts(projectId); + totalAlertsProcessed += alertsProcessed; + totalNotificationsSent += notificationsSent; + } catch (error) { + logger.error(error, `Failed to process alerts for project ${projectId}`); + } + } + + logger.info( + `Daily PKI alert processing completed. Processed ${totalAlertsProcessed} alerts, sent ${totalNotificationsSent} notifications.` + ); + }; + + const init = async () => { + if (appCfg.isSecondaryInstance) { + return; + } + + await queueService.startPg( + QueueJobs.DailyPkiAlertV2Processing, + async () => { + try { + logger.info(`${QueueJobs.DailyPkiAlertV2Processing}: queue task started`); + await processDailyAlerts(); + logger.info(`${QueueJobs.DailyPkiAlertV2Processing}: queue task completed successfully`); + } catch (error) { + logger.error(error, `${QueueJobs.DailyPkiAlertV2Processing}: queue task failed`); + throw error; + } + }, + { + batchSize: 1, + workerCount: 1, + pollingIntervalSeconds: 60 + } + ); + + await queueService.schedulePg(QueueJobs.DailyPkiAlertV2Processing, "0 0 * * *", undefined, { tz: "UTC" }); + }; + + return { + init + }; +}; diff --git a/backend/src/services/pki-alert-v2/pki-alert-v2-service.ts b/backend/src/services/pki-alert-v2/pki-alert-v2-service.ts new file mode 100644 index 000000000..2deb18c62 --- /dev/null +++ b/backend/src/services/pki-alert-v2/pki-alert-v2-service.ts @@ -0,0 +1,507 @@ +import { ForbiddenError } from "@casl/ability"; + +import { ActionProjectType } from "@app/db/schemas"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; +import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; +import { BadRequestError, NotFoundError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; +import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; + +import { TPkiAlertChannelDALFactory } from "./pki-alert-channel-dal"; +import { TPkiAlertHistoryDALFactory } from "./pki-alert-history-dal"; +import { TPkiAlertV2DALFactory } from "./pki-alert-v2-dal"; +import { parseTimeToDays, parseTimeToPostgresInterval } from "./pki-alert-v2-filter-utils"; +import { + CertificateOrigin, + PkiAlertChannelType, + PkiAlertEventType, + TAlertV2Response, + TChannelConfig, + TCreateAlertV2DTO, + TDeleteAlertV2DTO, + TEmailChannelConfig, + TGetAlertV2DTO, + TListAlertsV2DTO, + TListAlertsV2Response, + TListCurrentMatchingCertificatesDTO, + TListMatchingCertificatesDTO, + TListMatchingCertificatesResponse, + TPkiFilterRule, + TUpdateAlertV2DTO +} from "./pki-alert-v2-types"; + +type TPkiAlertV2ServiceFactoryDep = { + pkiAlertV2DAL: Pick< + TPkiAlertV2DALFactory, + | "create" + | "findById" + | "findByIdWithChannels" + | "updateById" + | "deleteById" + | "findByProjectId" + | "findByProjectIdWithCount" + | "countByProjectId" + | "findMatchingCertificates" + | "transaction" + >; + pkiAlertChannelDAL: Pick; + pkiAlertHistoryDAL: Pick; + permissionService: Pick; + smtpService: Pick; +}; + +export type TPkiAlertV2ServiceFactory = ReturnType; + +export const pkiAlertV2ServiceFactory = ({ + pkiAlertV2DAL, + pkiAlertChannelDAL, + pkiAlertHistoryDAL, + permissionService, + smtpService +}: TPkiAlertV2ServiceFactoryDep) => { + type TAlertWithChannels = { + id: string; + name: string; + description: string; + eventType: string; + alertBefore: string; + filters: TPkiFilterRule[]; + enabled: boolean; + projectId: string; + createdAt: Date; + updatedAt: Date; + channels?: Array<{ + id: string; + channelType: string; + config: unknown; + enabled: boolean; + createdAt: Date; + updatedAt: Date; + }>; + }; + + const formatAlertResponse = (alert: TAlertWithChannels): TAlertV2Response => { + return { + id: alert.id, + name: alert.name, + description: alert.description, + eventType: alert.eventType as PkiAlertEventType, + alertBefore: alert.alertBefore, + filters: alert.filters, + enabled: alert.enabled, + projectId: alert.projectId, + channels: (alert.channels || []).map((channel) => ({ + id: channel.id, + channelType: channel.channelType as PkiAlertChannelType, + config: channel.config as TChannelConfig, + enabled: channel.enabled, + createdAt: channel.createdAt, + updatedAt: channel.updatedAt + })), + createdAt: alert.createdAt, + updatedAt: alert.updatedAt + }; + }; + + const createAlert = async ({ + projectId, + name, + description, + eventType, + alertBefore, + filters, + enabled = true, + channels, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TCreateAlertV2DTO): Promise => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Create, ProjectPermissionSub.PkiAlerts); + + try { + parseTimeToPostgresInterval(alertBefore); + } catch (error) { + throw new BadRequestError({ message: "Invalid alertBefore format. Use format like '30d', '1w', '3m', '1y'" }); + } + + return pkiAlertV2DAL.transaction(async (tx) => { + const alert = await pkiAlertV2DAL.create( + { + projectId, + name, + description, + eventType, + alertBefore, + filters, + enabled + }, + tx + ); + + const channelInserts = channels.map((channel) => ({ + alertId: alert.id, + channelType: channel.channelType, + config: channel.config, + enabled: channel.enabled + })); + + await pkiAlertChannelDAL.insertMany(channelInserts, tx); + + const completeAlert = await pkiAlertV2DAL.findByIdWithChannels(alert.id, tx); + if (!completeAlert) { + throw new NotFoundError({ message: "Failed to retrieve created alert" }); + } + + return formatAlertResponse(completeAlert as TAlertWithChannels); + }); + }; + + const getAlertById = async ({ + alertId, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TGetAlertV2DTO): Promise => { + const alert = await pkiAlertV2DAL.findByIdWithChannels(alertId); + if (!alert) throw new NotFoundError({ message: `Alert with ID '${alertId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: (alert as { projectId: string }).projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.PkiAlerts); + + return formatAlertResponse(alert as TAlertWithChannels); + }; + + const listAlerts = async ({ + projectId, + search, + eventType, + enabled, + limit = 20, + offset = 0, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TListAlertsV2DTO): Promise => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.PkiAlerts); + + const filters = { search, eventType, enabled, limit, offset }; + + const { alerts, total } = await pkiAlertV2DAL.findByProjectIdWithCount(projectId, filters); + + return { + alerts: alerts.map((alert) => formatAlertResponse(alert as TAlertWithChannels)), + total + }; + }; + + const updateAlert = async ({ + alertId, + name, + description, + eventType, + alertBefore, + filters, + enabled, + channels, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TUpdateAlertV2DTO): Promise => { + let alert = await pkiAlertV2DAL.findById(alertId); + if (!alert) throw new NotFoundError({ message: `Alert with ID '${alertId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: (alert as { projectId: string }).projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.PkiAlerts); + + if (alertBefore) { + try { + parseTimeToPostgresInterval(alertBefore); + } catch (error) { + throw new BadRequestError({ message: "Invalid alertBefore format. Use format like '30d', '1w', '3m', '1y'" }); + } + } + + const updateData: { + name?: string; + description?: string; + eventType?: PkiAlertEventType; + alertBefore?: string; + filters?: TPkiFilterRule[]; + enabled?: boolean; + } = {}; + if (name !== undefined) updateData.name = name; + if (description !== undefined) updateData.description = description; + if (eventType !== undefined) updateData.eventType = eventType; + if (alertBefore !== undefined) updateData.alertBefore = alertBefore; + if (filters !== undefined) updateData.filters = filters; + if (enabled !== undefined) updateData.enabled = enabled; + + return pkiAlertV2DAL.transaction(async (tx) => { + alert = await pkiAlertV2DAL.updateById(alertId, updateData, tx); + + if (channels) { + await pkiAlertChannelDAL.deleteByAlertId(alertId, tx); + + const channelInserts = channels.map((channel) => ({ + alertId, + channelType: channel.channelType, + config: channel.config, + enabled: channel.enabled + })); + + await pkiAlertChannelDAL.insertMany(channelInserts, tx); + } + + const completeAlert = await pkiAlertV2DAL.findByIdWithChannels(alertId, tx); + if (!completeAlert) { + throw new NotFoundError({ message: "Failed to retrieve updated alert" }); + } + + return formatAlertResponse(completeAlert as TAlertWithChannels); + }); + }; + + const deleteAlert = async ({ + alertId, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TDeleteAlertV2DTO): Promise => { + const alert = await pkiAlertV2DAL.findByIdWithChannels(alertId); + if (!alert) throw new NotFoundError({ message: `Alert with ID '${alertId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: (alert as { projectId: string }).projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Delete, ProjectPermissionSub.PkiAlerts); + + const formattedAlert = formatAlertResponse(alert as TAlertWithChannels); + await pkiAlertV2DAL.deleteById(alertId); + + return formattedAlert; + }; + + const listMatchingCertificates = async ({ + alertId, + limit = 20, + offset = 0, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TListMatchingCertificatesDTO): Promise => { + const alert = await pkiAlertV2DAL.findById(alertId); + if (!alert) throw new NotFoundError({ message: `Alert with ID '${alertId}' not found` }); + + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId: (alert as { projectId: string }).projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.PkiAlerts); + + const options: { + limit: number; + offset: number; + showPreview?: boolean; + excludeAlerted?: boolean; + alertId?: string; + } = { + limit, + offset, + showPreview: true, + excludeAlerted: (alert as { eventType: string }).eventType === PkiAlertEventType.EXPIRATION, + alertId + }; + + const result = await pkiAlertV2DAL.findMatchingCertificates( + (alert as { projectId: string }).projectId, + (alert as { filters: TPkiFilterRule[] }).filters, + options + ); + + return { + certificates: result.certificates, + total: result.total + }; + }; + + const listCurrentMatchingCertificates = async ({ + projectId, + filters, + alertBefore, + limit = 20, + offset = 0, + actorId, + actorAuthMethod, + actor, + actorOrgId + }: TListCurrentMatchingCertificatesDTO): Promise => { + const { permission } = await permissionService.getProjectPermission({ + actor, + actorId, + projectId, + actorAuthMethod, + actorOrgId, + actionProjectType: ActionProjectType.CertificateManager + }); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.PkiAlerts); + + try { + parseTimeToPostgresInterval(alertBefore); + } catch (error) { + throw new BadRequestError({ message: "Invalid alertBefore format. Use format like '30d', '1w', '3m', '1y'" }); + } + + const options: { + limit: number; + offset: number; + showPreview?: boolean; + alertBefore?: string; + } = { + limit, + offset, + showPreview: true, + alertBefore: parseTimeToPostgresInterval(alertBefore) + }; + + const result = await pkiAlertV2DAL.findMatchingCertificates(projectId, filters, options); + + return { + certificates: result.certificates, + total: result.total + }; + }; + + const sendAlertNotifications = async (alertId: string, certificateIds: string[]) => { + const alert = await pkiAlertV2DAL.findByIdWithChannels(alertId); + if (!alert || !(alert as { enabled: boolean }).enabled) return; + + const channels = + (alert as { channels?: Array<{ enabled: boolean; channelType: string; config: unknown }> }).channels?.filter( + (channel: { enabled: boolean; channelType: string; config: unknown }) => channel.enabled + ) || []; + if (channels.length === 0) return; + + const { certificates } = await pkiAlertV2DAL.findMatchingCertificates( + (alert as { projectId: string }).projectId, + (alert as { filters: TPkiFilterRule[] }).filters, + { + alertBefore: parseTimeToPostgresInterval((alert as { alertBefore: string }).alertBefore) + } + ); + + const matchingCertificates = certificates.filter( + (cert) => certificateIds.includes(cert.id) && cert.enrollmentType !== CertificateOrigin.CA + ); + + if (matchingCertificates.length === 0) return; + + let hasNotificationSent = false; + let notificationError: string | undefined; + + try { + const emailChannels = channels.filter( + (channel: { enabled: boolean; channelType: string; config: unknown }) => + channel.channelType === PkiAlertChannelType.EMAIL + ); + + const alertBeforeDays = parseTimeToDays((alert as { alertBefore: string }).alertBefore); + const alertName = (alert as { name: string }).name; + + const emailPromises = emailChannels.map((channel) => { + const config = channel.config as TEmailChannelConfig; + + return smtpService.sendMail({ + recipients: config.recipients, + subjectLine: `Infisical Certificate Alert - ${alertName}`, + substitutions: { + alertName, + alertBeforeDays, + projectId: (alert as { projectId: string }).projectId, + items: matchingCertificates.map((cert) => ({ + type: "Certificate", + friendlyName: cert.commonName, + serialNumber: cert.serialNumber, + expiryDate: cert.notAfter.toLocaleDateString() + })) + }, + template: SmtpTemplates.PkiExpirationAlert + }); + }); + + await Promise.all(emailPromises); + + hasNotificationSent = true; + } catch (error) { + notificationError = error instanceof Error ? error.message : "Unknown error occurred"; + logger.error(error, `Failed to send notifications for alert ${alertId}`); + } + + await pkiAlertHistoryDAL.createWithCertificates(alertId, certificateIds, { + hasNotificationSent, + notificationError + }); + }; + + return { + createAlert, + getAlertById, + listAlerts, + updateAlert, + deleteAlert, + listMatchingCertificates, + listCurrentMatchingCertificates, + sendAlertNotifications + }; +}; diff --git a/backend/src/services/pki-alert-v2/pki-alert-v2-types.ts b/backend/src/services/pki-alert-v2/pki-alert-v2-types.ts new file mode 100644 index 000000000..fe6055402 --- /dev/null +++ b/backend/src/services/pki-alert-v2/pki-alert-v2-types.ts @@ -0,0 +1,205 @@ +import RE2 from "re2"; +import { z } from "zod"; + +import { TGenericPermission } from "@app/lib/types"; + +const createSecureNameValidator = () => { + // Validates name format: lowercase alphanumeric characters with optional hyphens + // Pattern: starts and ends with alphanumeric, allows hyphens between segments + // Examples: "my-alert", "alert1", "test-alert-2" + const nameRegex = new RE2("^[a-z0-9]+(?:-[a-z0-9]+)*$"); + return (value: string) => nameRegex.test(value); +}; + +export const createSecureAlertBeforeValidator = () => { + // Validates alertBefore duration format: number followed by time unit + // Pattern: one or more digits followed by d(days), w(weeks), m(months), or y(years) + // Examples: "30d", "2w", "6m", "1y" + const alertBeforeRegex = new RE2("^\\d+[dwmy]$"); + return (value: string) => { + if (value.length > 32) return false; + return alertBeforeRegex.test(value); + }; +}; + +export enum PkiAlertEventType { + EXPIRATION = "expiration", + RENEWAL = "renewal", + ISSUANCE = "issuance", + REVOCATION = "revocation" +} + +export enum PkiAlertChannelType { + EMAIL = "email", + WEBHOOK = "webhook", + SLACK = "slack" +} + +export enum PkiFilterOperator { + EQUALS = "equals", + MATCHES = "matches", + CONTAINS = "contains", + STARTS_WITH = "starts_with", + ENDS_WITH = "ends_with" +} + +export enum PkiFilterField { + PROFILE_NAME = "profile_name", + COMMON_NAME = "common_name", + SAN = "san", + INCLUDE_CAS = "include_cas" +} + +export enum CertificateOrigin { + UNKNOWN = "unknown", + PROFILE = "profile", + IMPORT = "import", + CA = "ca" +} + +export const PkiFilterRuleSchema = z.object({ + field: z.nativeEnum(PkiFilterField), + operator: z.nativeEnum(PkiFilterOperator), + value: z.union([z.string(), z.array(z.string()), z.boolean()]) +}); + +export type TPkiFilterRule = z.infer; + +export const PkiFiltersSchema = z.array(PkiFilterRuleSchema); +export type TPkiFilters = z.infer; + +export const EmailChannelConfigSchema = z.object({ + recipients: z.array(z.string().email()).min(1).max(10) +}); + +export const WebhookChannelConfigSchema = z.object({ + url: z.string().url(), + method: z.enum(["POST", "PUT"]).default("POST"), + headers: z.record(z.string()).optional() +}); + +export const SlackChannelConfigSchema = z.object({ + webhookUrl: z.string().url(), + channel: z.string().optional(), + mentionUsers: z.array(z.string()).optional() +}); + +export const ChannelConfigSchema = z.union([ + EmailChannelConfigSchema, + WebhookChannelConfigSchema, + SlackChannelConfigSchema +]); + +export type TEmailChannelConfig = z.infer; +export type TWebhookChannelConfig = z.infer; +export type TSlackChannelConfig = z.infer; +export type TChannelConfig = z.infer; + +export const CreateChannelSchema = z.object({ + channelType: z.nativeEnum(PkiAlertChannelType), + config: ChannelConfigSchema, + enabled: z.boolean().default(true) +}); + +export type TCreateChannel = z.infer; + +export const CreatePkiAlertV2Schema = z.object({ + name: z + .string() + .min(1) + .max(255) + .refine(createSecureNameValidator(), "Must be a valid name (lowercase, numbers, hyphens only)"), + description: z.string().max(1000).optional(), + eventType: z.nativeEnum(PkiAlertEventType), + alertBefore: z.string().refine(createSecureAlertBeforeValidator(), "Must be in format like '30d', '1w', '3m', '1y'"), + filters: PkiFiltersSchema, + enabled: z.boolean().default(true), + channels: z.array(CreateChannelSchema).min(1, "At least one channel is required") +}); + +export type TCreatePkiAlertV2 = z.infer; + +export const UpdatePkiAlertV2Schema = CreatePkiAlertV2Schema.partial(); +export type TUpdatePkiAlertV2 = z.infer; + +export type TCreateAlertV2DTO = TGenericPermission & { + projectId: string; +} & TCreatePkiAlertV2; + +export type TUpdateAlertV2DTO = TGenericPermission & { + alertId: string; +} & TUpdatePkiAlertV2; + +export type TGetAlertV2DTO = TGenericPermission & { + alertId: string; +}; + +export type TDeleteAlertV2DTO = TGenericPermission & { + alertId: string; +}; + +export type TListAlertsV2DTO = TGenericPermission & { + projectId: string; + search?: string; + eventType?: PkiAlertEventType; + enabled?: boolean; + limit?: number; + offset?: number; +}; + +export type TListMatchingCertificatesDTO = TGenericPermission & { + alertId: string; + limit?: number; + offset?: number; +}; + +export type TListCurrentMatchingCertificatesDTO = TGenericPermission & { + projectId: string; + filters: TPkiFilters; + alertBefore: string; + limit?: number; + offset?: number; +}; + +export type TCertificatePreview = { + id: string; + serialNumber: string; + commonName: string; + san: string[]; + profileName: string | null; + enrollmentType: CertificateOrigin | null; + notBefore: Date; + notAfter: Date; + status: string; +}; + +export type TAlertV2Response = { + id: string; + name: string; + description: string | null; + eventType: PkiAlertEventType; + alertBefore: string; + filters: TPkiFilters; + enabled: boolean; + projectId: string; + channels: Array<{ + id: string; + channelType: PkiAlertChannelType; + config: TChannelConfig; + enabled: boolean; + createdAt: Date; + updatedAt: Date; + }>; + createdAt: Date; + updatedAt: Date; +}; + +export type TListAlertsV2Response = { + alerts: TAlertV2Response[]; + total: number; +}; + +export type TListMatchingCertificatesResponse = { + certificates: TCertificatePreview[]; + total: number; +}; diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index 25052ba9a..727cc9aa9 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -1605,8 +1605,14 @@ export const projectServiceFactory = ({ isAccessRequestNotificationEnabled, accessRequestChannels, isSecretRequestNotificationEnabled, - secretRequestChannels - }: TUpdateProjectWorkflowIntegration) => { + secretRequestChannels, + secretSyncErrorChannels, + isSecretSyncErrorNotificationEnabled + }: TUpdateProjectWorkflowIntegration & { + // workaround intersection type while we don't have the microsoft teams integration for failed secret syncs + isSecretSyncErrorNotificationEnabled?: boolean; + secretSyncErrorChannels?: string; + }) => { const project = await projectDAL.findById(projectId); if (!project) { throw new NotFoundError({ @@ -1628,6 +1634,7 @@ export const projectServiceFactory = ({ const sanitizedAccessRequestChannels = validateSlackChannelsField.parse(accessRequestChannels); const sanitizedSecretRequestChannels = validateSlackChannelsField.parse(secretRequestChannels); + const sanitizedSecretSyncErrorChannels = validateSlackChannelsField.parse(secretSyncErrorChannels); const slackIntegration = await slackIntegrationDAL.findByIdWithWorkflowIntegrationDetails(integrationId); @@ -1665,7 +1672,9 @@ export const projectServiceFactory = ({ isAccessRequestNotificationEnabled, accessRequestChannels: sanitizedAccessRequestChannels, isSecretRequestNotificationEnabled, - secretRequestChannels: sanitizedSecretRequestChannels + secretRequestChannels: sanitizedSecretRequestChannels, + isSecretSyncErrorNotificationEnabled, + secretSyncErrorChannels: sanitizedSecretSyncErrorChannels }, tx ); @@ -1678,7 +1687,9 @@ export const projectServiceFactory = ({ isAccessRequestNotificationEnabled, accessRequestChannels: sanitizedAccessRequestChannels, isSecretRequestNotificationEnabled, - secretRequestChannels: sanitizedSecretRequestChannels + secretRequestChannels: sanitizedSecretRequestChannels, + isSecretSyncErrorNotificationEnabled, + secretSyncErrorChannels: sanitizedSecretSyncErrorChannels }, tx ); @@ -1688,6 +1699,7 @@ export const projectServiceFactory = ({ ...updatedWorkflowIntegration, accessRequestChannels: sanitizedAccessRequestChannels, secretRequestChannels: sanitizedSecretRequestChannels, + secretSyncErrorChannels: sanitizedSecretSyncErrorChannels, integrationId: slackIntegration.id, integration: WorkflowIntegration.SLACK } as const; diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index 2b75b1bc7..9e0745236 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -185,8 +185,10 @@ export type TUpdateProjectWorkflowIntegration = ( integration: WorkflowIntegration.SLACK; isAccessRequestNotificationEnabled: boolean; isSecretRequestNotificationEnabled: boolean; + isSecretSyncErrorNotificationEnabled: boolean; accessRequestChannels?: string; secretRequestChannels?: string; + secretSyncErrorChannels?: string; } | { integrationId: string; diff --git a/backend/src/services/secret-sync/secret-sync-queue.ts b/backend/src/services/secret-sync/secret-sync-queue.ts index e83a0033f..f6e23dded 100644 --- a/backend/src/services/secret-sync/secret-sync-queue.ts +++ b/backend/src/services/secret-sync/secret-sync-queue.ts @@ -10,6 +10,8 @@ import { TLicenseServiceFactory } from "@app/ee/services/license/license-service import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; import { getConfig } from "@app/lib/config/env"; import { logger } from "@app/lib/logger"; +import { triggerWorkflowIntegrationNotification } from "@app/lib/workflow-integrations/trigger-notification"; +import { TriggerFeature } from "@app/lib/workflow-integrations/types"; import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue"; import { SecretNameSchema } from "@app/server/lib/schemas"; import { decryptAppConnectionCredentials } from "@app/services/app-connection/app-connection-fns"; @@ -62,8 +64,11 @@ import { SmtpTemplates, TSmtpService } from "@app/services/smtp/smtp-service"; import { TAppConnectionDALFactory } from "../app-connection/app-connection-dal"; import { TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service"; +import { TMicrosoftTeamsServiceFactory } from "../microsoft-teams/microsoft-teams-service"; +import { TProjectMicrosoftTeamsConfigDALFactory } from "../microsoft-teams/project-microsoft-teams-config-dal"; import { TNotificationServiceFactory } from "../notification/notification-service"; import { NotificationType } from "../notification/notification-types"; +import { TProjectSlackConfigDALFactory } from "../slack/project-slack-config-dal"; export type TSecretSyncQueueFactory = ReturnType; @@ -104,6 +109,9 @@ type TSecretSyncQueueFactoryDep = { gatewayService: Pick; gatewayV2Service: Pick; notificationService: Pick; + projectSlackConfigDAL: Pick; + projectMicrosoftTeamsConfigDAL: Pick; + microsoftTeamsService: Pick; }; type SecretSyncActionJob = Job< @@ -147,7 +155,10 @@ export const secretSyncQueueFactory = ({ licenseService, gatewayService, gatewayV2Service, - notificationService + notificationService, + projectSlackConfigDAL, + projectMicrosoftTeamsConfigDAL, + microsoftTeamsService }: TSecretSyncQueueFactoryDep) => { const appCfg = getConfig(); @@ -921,34 +932,65 @@ export const secretSyncQueueFactory = ({ break; } - const syncPath = `/projects/secret-management/${projectId}/integrations/secret-syncs/${destination}/${secretSync.id}`; + const baseProjectPath = `/projects/secret-management/${projectId}`; + const overviewPath = `${baseProjectPath}/overview`; + const syncPath = `${baseProjectPath}/integrations/secret-syncs/${destination}/${secretSync.id}`; - await notificationService.createUserNotifications( - projectAdmins.map((admin) => ({ - userId: admin.userId, - orgId: project.orgId, - type: NotificationType.SECRET_SYNC_FAILED, - title: `Secret Sync Failed to ${actionLabel} Secrets`, - body: `Your **${syncDestination}** sync **${name}** failed to complete${failureMessage ? `: \`${failureMessage}\`` : ""}`, - link: syncPath - })) - ); + const notifications = [ + triggerWorkflowIntegrationNotification({ + input: { + notification: { + type: TriggerFeature.SECRET_SYNC_ERROR, + payload: { + syncName: name, + syncDestination, + failureMessage: failureMessage || "An unknown error occurred", + syncUrl: `${appCfg.SITE_URL}${syncPath}`, + syncActionLabel: actionLabel, + environment: environment?.name || "-", + secretPath: folder?.path || "-", + projectName: project.name, + projectPath: overviewPath + } + }, + projectId + }, + dependencies: { + projectDAL, + projectSlackConfigDAL, + kmsService, + microsoftTeamsService, + projectMicrosoftTeamsConfigDAL + } + }), + notificationService.createUserNotifications( + projectAdmins.map((admin) => ({ + userId: admin.userId, + orgId: project.orgId, + type: NotificationType.SECRET_SYNC_FAILED, + title: `Secret Sync Failed to ${actionLabel} Secrets`, + body: `Your **${syncDestination}** sync **${name}** failed to complete${failureMessage ? `: \`${failureMessage}\`` : ""}`, + link: syncPath + })) + ), + smtpService.sendMail({ + recipients: projectAdmins.map((member) => member.user.email!).filter(Boolean), + template: SmtpTemplates.SecretSyncFailed, + subjectLine: `Secret Sync Failed to ${actionLabel} Secrets`, + substitutions: { + syncName: name, + syncDestination, + content: `Your ${syncDestination} Sync named "${name}" failed while attempting to ${action.toLowerCase()} secrets.`, + failureMessage, + secretPath: folder?.path, + environment: environment?.name, + projectName: project.name, + syncUrl: `${appCfg.SITE_URL}${syncPath}` + } + }) + ]; - await smtpService.sendMail({ - recipients: projectAdmins.map((member) => member.user.email!).filter(Boolean), - template: SmtpTemplates.SecretSyncFailed, - subjectLine: `Secret Sync Failed to ${actionLabel} Secrets`, - substitutions: { - syncName: name, - syncDestination, - content: `Your ${syncDestination} Sync named "${name}" failed while attempting to ${action.toLowerCase()} secrets.`, - failureMessage, - secretPath: folder?.path, - environment: environment?.name, - projectName: project.name, - syncUrl: `${appCfg.SITE_URL}${syncPath}` - } - }); + await Promise.allSettled(notifications); }; const queueSecretSyncsSyncSecretsByPath = async ({ diff --git a/backend/src/services/slack/project-slack-config-dal.ts b/backend/src/services/slack/project-slack-config-dal.ts index 276442b1b..4b5b2146e 100644 --- a/backend/src/services/slack/project-slack-config-dal.ts +++ b/backend/src/services/slack/project-slack-config-dal.ts @@ -1,16 +1,17 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; +import { TableName, TProjectSlackConfigs, TSlackIntegrations } from "@app/db/schemas"; import { ormify, selectAllTableCols } from "@app/lib/knex"; export type TProjectSlackConfigDALFactory = ReturnType; +export type TProjectSlackConfigWithIntegrations = TProjectSlackConfigs & TSlackIntegrations; export const projectSlackConfigDALFactory = (db: TDbClient) => { const projectSlackConfigOrm = ormify(db, TableName.ProjectSlackConfigs); const getIntegrationDetailsByProject = (projectId: string, tx?: Knex) => { - return (tx || db.replicaNode())(TableName.ProjectSlackConfigs) + return (tx || db.replicaNode())(TableName.ProjectSlackConfigs) .join( TableName.SlackIntegrations, `${TableName.ProjectSlackConfigs}.slackIntegrationId`, diff --git a/backend/src/services/slack/slack-fns.ts b/backend/src/services/slack/slack-fns.ts index 88db1a84d..aaeb28916 100644 --- a/backend/src/services/slack/slack-fns.ts +++ b/backend/src/services/slack/slack-fns.ts @@ -8,6 +8,9 @@ import { TNotification, TriggerFeature } from "@app/lib/workflow-integrations/ty import { KmsDataKey } from "../kms/kms-types"; import { TSendSlackNotificationDTO } from "./slack-types"; +const COMPANY_BRAND_COLOR = "#e0ed34"; +const ERROR_COLOR = "#e74c3c"; + export const fetchSlackChannels = async (botKey: string) => { const slackChannels: { name: string; @@ -48,13 +51,9 @@ const buildSlackPayload = (notification: TNotification) => { const messageBody = `A secret approval request has been opened by ${payload.userEmail}. *Environment*: ${payload.environment} *Secret path*: ${payload.secretPath || "/"} -*Secret Key${payload.secretKeys.length > 1 ? "s" : ""}*: ${payload.secretKeys.join(", ")} +*Secret Key${payload.secretKeys.length > 1 ? "s" : ""}*: ${payload.secretKeys.join(", ")}`; -View the complete details <${appCfg.SITE_URL}/projects/secret-management/${payload.projectId}/approval?requestId=${ - payload.requestId - }|here>.`; - - const payloadBlocks = [ + const headerBlocks = [ { type: "header", text: { @@ -62,37 +61,52 @@ View the complete details <${appCfg.SITE_URL}/projects/secret-management/${paylo text: "Secret approval request", emoji: true } - }, + } + ]; + + const payloadBlocks = [ { type: "section", text: { type: "mrkdwn", text: messageBody } + }, + { + type: "actions", + elements: [ + { + type: "button", + text: { + type: "plain_text", + text: "View request", + emoji: true + }, + style: "primary", + url: payload.approvalUrl + } + ] } ]; return { + headerBlocks, payloadMessage: messageBody, - payloadBlocks + payloadBlocks, + color: COMPANY_BRAND_COLOR }; } case TriggerFeature.ACCESS_REQUEST: { const { payload } = notification; - const messageBody = `${payload.requesterFullName} (${payload.requesterEmail}) has requested ${ - payload.isTemporary ? "temporary" : "permanent" - } access to ${payload.secretPath} in the ${payload.environment} environment of ${payload.projectName}. - -The following permissions are requested: ${payload.permissions.join(", ")} + const projectUrl = `${appCfg.SITE_URL}${payload.projectPath}/overview`; + const accessType = payload.isTemporary ? "temporary" : "permanent"; + const permissionsFormatted = payload.permissions.map((p) => `*${p}*`).join(", "); -View the request and approve or deny it <${payload.approvalUrl}|here>.${ - payload.note - ? ` -User Note: ${payload.note}` - : "" + const messageBody = `${payload.requesterFullName} (${payload.requesterEmail}) has requested ${accessType} access to ${payload.secretPath} in the ${payload.environment} environment of ${payload.projectName}.\n\nThe following permissions are requested: ${payload.permissions.join(", ")}${ + payload.note ? `\n\nUser note: ${payload.note}` : "" }`; - const payloadBlocks = [ + const headerBlocks = [ { type: "header", text: { @@ -100,37 +114,54 @@ User Note: ${payload.note}` text: "New access approval request pending for review", emoji: true } - }, + } + ]; + + const payloadBlocks = [ { type: "section", text: { type: "mrkdwn", - text: messageBody + text: `*${payload.requesterFullName}* (${payload.requesterEmail}) has requested *${accessType}* access to *${payload.secretPath}* in the *${payload.environment}* environment of *<${projectUrl}|${payload.projectName}>*.\n\nThe following permissions are requested: ${permissionsFormatted}${ + payload.note ? `\n\n*User note:* ${payload.note}` : "" + }` } + }, + { + type: "actions", + elements: [ + { + type: "button", + text: { + type: "plain_text", + text: "View request", + emoji: true + }, + style: "primary", + url: payload.approvalUrl + } + ] } ]; return { + headerBlocks, payloadMessage: messageBody, - payloadBlocks + payloadBlocks, + color: COMPANY_BRAND_COLOR }; } case TriggerFeature.ACCESS_REQUEST_UPDATED: { const { payload } = notification; - const messageBody = `${payload.editorFullName} (${payload.editorEmail}) has updated the ${ - payload.isTemporary ? "temporary" : "permanent" - } access request from ${payload.requesterFullName} (${payload.requesterEmail}) to ${payload.secretPath} in the ${payload.environment} environment of ${payload.projectName}. - -The following permissions are requested: ${payload.permissions.join(", ")} + const projectUrl = `${appCfg.SITE_URL}${payload.projectPath}/overview`; + const accessType = payload.isTemporary ? "temporary" : "permanent"; + const permissionsFormatted = payload.permissions.map((p) => `*${p}*`).join(", "); -View the request and approve or deny it <${payload.approvalUrl}|here>.${ - payload.editNote - ? ` -Editor Note: ${payload.editNote}` - : "" + const messageBody = `${payload.editorFullName} (${payload.editorEmail}) has updated the ${accessType} access request from ${payload.requesterFullName} (${payload.requesterEmail}) to ${payload.secretPath} in the ${payload.environment} environment of ${payload.projectName}.\n\nThe following permissions are requested: ${payload.permissions.join(", ")}${ + payload.editNote ? `\n\nEditor Note: ${payload.editNote}` : "" }`; - const payloadBlocks = [ + const headerBlocks = [ { type: "header", text: { @@ -138,19 +169,89 @@ Editor Note: ${payload.editNote}` text: "Updated access approval request pending for review", emoji: true } - }, + } + ]; + + const payloadBlocks = [ { type: "section", text: { type: "mrkdwn", - text: messageBody + text: `*${payload.editorFullName}* (${payload.editorEmail}) has updated the *${accessType}* access request from *${payload.requesterFullName}* (${payload.requesterEmail}) to *${payload.secretPath}* in the *${payload.environment}* environment of *<${projectUrl}|${payload.projectName}>*.\n\nThe following permissions are requested: ${permissionsFormatted}${ + payload.editNote ? `\n\n*Editor Note:* ${payload.editNote}` : "" + }` } + }, + { + type: "actions", + elements: [ + { + type: "button", + text: { + type: "plain_text", + text: "View request", + emoji: true + }, + style: "primary", + url: payload.approvalUrl + } + ] + } + ]; + + return { + headerBlocks, + payloadMessage: messageBody, + payloadBlocks, + color: COMPANY_BRAND_COLOR + }; + } + case TriggerFeature.SECRET_SYNC_ERROR: { + const { payload } = notification; + const projectUrl = `${appCfg.SITE_URL}${payload.projectPath}`; + const messageBody = `Secret sync ${payload.syncName} for ${payload.syncDestination} failed on ${payload.syncActionLabel}\n\n\nEnvironment: ${payload.environment}\n\n\nSecret Path: ${payload.secretPath}\n\n\nProject: ${payload.projectName} (${projectUrl})\n\n\nReason:\n${payload.failureMessage}`; + + const headerBlocks = [ + { + type: "header", + text: { + type: "plain_text", + text: `Secret sync ${payload.syncName} for ${payload.syncDestination} failed on ${payload.syncActionLabel}`, + emoji: true + } + } + ]; + + const payloadBlocks = [ + { + type: "section", + text: { + type: "mrkdwn", + text: `*Environment:* ${payload.environment}\n\n*Secret Path:* ${payload.secretPath}\n\n*Project:* <${projectUrl}|${payload.projectName}>\n\n*Reason:* ${payload.failureMessage}` + } + }, + { + type: "actions", + elements: [ + { + type: "button", + text: { + type: "plain_text", + text: "Open secret sync", + emoji: true + }, + style: "primary", + url: payload.syncUrl + } + ] } ]; return { payloadMessage: messageBody, - payloadBlocks + headerBlocks, + payloadBlocks, + color: ERROR_COLOR }; } default: { @@ -177,15 +278,22 @@ export const sendSlackNotification = async ({ }).toString("utf8"); const slackWebClient = new WebClient(botKey); - const { payloadMessage, payloadBlocks } = buildSlackPayload(notification); + const { payloadMessage, payloadBlocks, color, headerBlocks } = buildSlackPayload(notification); for await (const conversationId of targetChannelIds) { // we send both text and blocks for compatibility with barebone clients + await slackWebClient.chat .postMessage({ channel: conversationId, text: payloadMessage, - blocks: payloadBlocks + blocks: headerBlocks, + attachments: [ + { + color, + blocks: payloadBlocks + } + ] }) .catch((err) => logger.error(err)); } diff --git a/backend/src/services/smtp/emails/PkiExpirationAlertTemplate.tsx b/backend/src/services/smtp/emails/PkiExpirationAlertTemplate.tsx index a09a125a2..f7a89f4e1 100644 --- a/backend/src/services/smtp/emails/PkiExpirationAlertTemplate.tsx +++ b/backend/src/services/smtp/emails/PkiExpirationAlertTemplate.tsx @@ -1,11 +1,12 @@ -import { Heading, Hr, Section, Text } from "@react-email/components"; -import React, { Fragment } from "react"; +import { Heading, Section, Text } from "@react-email/components"; +import { BaseButton } from "./BaseButton"; import { BaseEmailWrapper, BaseEmailWrapperProps } from "./BaseEmailWrapper"; interface PkiExpirationAlertTemplateProps extends Omit { alertName: string; alertBeforeDays: number; + projectId: string; items: { type: string; friendlyName: string; serialNumber: string; expiryDate: string }[]; } @@ -13,44 +14,56 @@ export const PkiExpirationAlertTemplate = ({ alertName, siteUrl, alertBeforeDays, + projectId, items }: PkiExpirationAlertTemplateProps) => { + const formatDate = (dateStr: string) => { + try { + return new Date(dateStr).toLocaleDateString("en-US", { + year: "numeric", + month: "long", + day: "numeric" + }); + } catch { + return dateStr; + } + }; + + const certificateText = items.length === 1 ? "certificate" : "certificates"; + const daysText = alertBeforeDays === 1 ? "1 day" : `${alertBeforeDays} days`; + + const message = `Alert ${alertName}: You have ${items.length === 1 ? "one" : items.length} ${certificateText} that will expire in ${daysText}.`; + return ( - + - CA/Certificate Expiration Notice + Certificate Expiration Notice -
- Hello, - - This is an automated alert for {alertName} triggered for CAs/Certificates expiring in{" "} - {alertBeforeDays} days. - - - Expiring Items: + +
+ + Alert {alertName}: You have{" "} + {items.length === 1 ? "one" : items.length} {certificateText} that will expire in {daysText}. +
+
+ Expiring certificates: {items.map((item) => ( - -
- {item.type}: - {item.friendlyName} - Serial Number: - {item.serialNumber} - Expires On: - {item.expiryDate} -
+
+ {item.friendlyName} + Serial: {item.serialNumber} + Expires: {formatDate(item.expiryDate)} +
))} -
- - Please take the necessary actions to renew these items before they expire. - - - For more details, please log in to your Infisical account and check your PKI management section. - +
+ +
+ + View Certificate Alerts +
); @@ -59,11 +72,22 @@ export const PkiExpirationAlertTemplate = ({ export default PkiExpirationAlertTemplate; PkiExpirationAlertTemplate.PreviewProps = { - alertBeforeDays: 5, + alertBeforeDays: 7, items: [ - { type: "CA", friendlyName: "Example CA", serialNumber: "1234567890", expiryDate: "2032-01-01" }, - { type: "Certificate", friendlyName: "Example Certificate", serialNumber: "2345678901", expiryDate: "2032-01-01" } + { + type: "Certificate", + friendlyName: "api.production.company.com", + serialNumber: "4B:3E:2F:A1:D6:7C:89:45:B2:E8:7F:1A:3D:9C:5E:8B", + expiryDate: "2025-11-12" + }, + { + type: "Certificate", + friendlyName: "web.company.com", + serialNumber: "8A:7F:1C:E4:92:B5:D3:68:F1:A2:7E:9B:4C:6D:5A:3F", + expiryDate: "2025-11-10" + } ], - alertName: "My PKI Alert", + alertName: "Production SSL Certificate Expiration Alert", + projectId: "c3b0ef29-915b-4cb1-8684-65b91b7fe02d", siteUrl: "https://infisical.com" } as PkiExpirationAlertTemplateProps; diff --git a/docs/api-reference/endpoints/certificate-authorities/issue-cert.mdx b/docs/api-reference/endpoints/certificate-authorities/issue-cert.mdx deleted file mode 100644 index 045cada58..000000000 --- a/docs/api-reference/endpoints/certificate-authorities/issue-cert.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Issue certificate" -openapi: "POST /api/v1/pki/ca/{caId}/issue-certificate" ---- diff --git a/docs/api-reference/endpoints/certificate-authorities/sign-cert.mdx b/docs/api-reference/endpoints/certificate-authorities/sign-cert.mdx deleted file mode 100644 index 95c8d8c65..000000000 --- a/docs/api-reference/endpoints/certificate-authorities/sign-cert.mdx +++ /dev/null @@ -1,4 +0,0 @@ ---- -title: "Sign certificate" -openapi: "POST /api/v1/pki/ca/{caId}/sign-certificate" ---- diff --git a/docs/api-reference/endpoints/certificate-profiles/get-latest-active-bundle.mdx b/docs/api-reference/endpoints/certificate-profiles/get-latest-active-bundle.mdx new file mode 100644 index 000000000..aa033418d --- /dev/null +++ b/docs/api-reference/endpoints/certificate-profiles/get-latest-active-bundle.mdx @@ -0,0 +1,4 @@ +--- +title: "Get Latest Active Certificate Bundle" +openapi: "GET /api/v1/pki/certificate-profiles/{id}/certificates/latest-active-bundle" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificates/issue-certificate.mdx b/docs/api-reference/endpoints/certificates/issue-certificate.mdx index 90a79a4af..13a464b67 100644 --- a/docs/api-reference/endpoints/certificates/issue-certificate.mdx +++ b/docs/api-reference/endpoints/certificates/issue-certificate.mdx @@ -1,4 +1,4 @@ --- title: "Issue Certificate" -openapi: "POST /api/v1/pki/certificates/issue-certificate" +openapi: "POST /api/v3/pki/certificates/issue-certificate" --- diff --git a/docs/api-reference/endpoints/certificates/renew.mdx b/docs/api-reference/endpoints/certificates/renew.mdx new file mode 100644 index 000000000..b44424369 --- /dev/null +++ b/docs/api-reference/endpoints/certificates/renew.mdx @@ -0,0 +1,4 @@ +--- +title: "Renew Certificate" +openapi: "POST /api/v3/pki/certificates/{certificateId}/renew" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/certificates/sign-certificate.mdx b/docs/api-reference/endpoints/certificates/sign-certificate.mdx index 3132d5846..7291025fc 100644 --- a/docs/api-reference/endpoints/certificates/sign-certificate.mdx +++ b/docs/api-reference/endpoints/certificates/sign-certificate.mdx @@ -1,4 +1,4 @@ --- title: "Sign Certificate" -openapi: "POST /api/v1/pki/certificates/sign-certificate" +openapi: "POST /api/v3/pki/certificates/sign-certificate" --- diff --git a/docs/api-reference/endpoints/certificates/update-config.mdx b/docs/api-reference/endpoints/certificates/update-config.mdx new file mode 100644 index 000000000..70520bf68 --- /dev/null +++ b/docs/api-reference/endpoints/certificates/update-config.mdx @@ -0,0 +1,4 @@ +--- +title: "Update Certificate Config" +openapi: "PATCH /api/v3/pki/certificates/{certificateId}/config" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/pki/syncs/add-certificates.mdx b/docs/api-reference/endpoints/pki/syncs/add-certificates.mdx new file mode 100644 index 000000000..c7b21996e --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/add-certificates.mdx @@ -0,0 +1,4 @@ +--- +title: "Add Certificates to Sync" +openapi: "POST /api/v1/pki/syncs/{pkiSyncId}/certificates" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/pki/syncs/list-certificates.mdx b/docs/api-reference/endpoints/pki/syncs/list-certificates.mdx new file mode 100644 index 000000000..eaece0a2d --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/list-certificates.mdx @@ -0,0 +1,4 @@ +--- +title: "List Sync Certificates" +openapi: "GET /api/v1/pki/syncs/{pkiSyncId}/certificates" +--- \ No newline at end of file diff --git a/docs/api-reference/endpoints/pki/syncs/remove-certificates.mdx b/docs/api-reference/endpoints/pki/syncs/remove-certificates.mdx new file mode 100644 index 000000000..99c8bfe28 --- /dev/null +++ b/docs/api-reference/endpoints/pki/syncs/remove-certificates.mdx @@ -0,0 +1,4 @@ +--- +title: "Remove Certificates from Sync" +openapi: "DELETE /api/v1/pki/syncs/{pkiSyncId}/certificates" +--- \ No newline at end of file diff --git a/docs/docs.json b/docs/docs.json index 3fb6914af..8cce3197d 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -706,37 +706,69 @@ "item": "Infisical PKI", "groups": [ { - "group": "Infisical PKI", + "group": "Certificate Management", "pages": [ "documentation/platform/pki/overview", - "documentation/platform/pki/private-ca", - "documentation/platform/pki/external-ca", - "documentation/platform/pki/subscribers", - "documentation/platform/pki/certificates", - "documentation/platform/pki/acme-ca", - "documentation/platform/pki/azure-adcs", - "documentation/platform/pki/est", - "documentation/platform/pki/alerting", { - "group": "Integrations", + "group": "Concepts", "pages": [ - "documentation/platform/pki/pki-issuer", - "documentation/platform/pki/integration-guides/gloo-mesh" + "documentation/platform/pki/concepts/certificate-mgmt", + "documentation/platform/pki/concepts/certificate-lifecycle" + ] + } + ] + }, + { + "group": "Product Reference", + "pages": [ + { + "group": "Certificate Authorities", + "pages": [ + "documentation/platform/pki/ca/overview", + "documentation/platform/pki/ca/private-ca", + "documentation/platform/pki/ca/external-ca" ] }, { - "group": "Certificate Syncs", + "group": "Certificates", "pages": [ - "documentation/platform/pki/certificate-syncs/overview", - { - "group": "Syncs", - "pages": [ - "documentation/platform/pki/certificate-syncs/aws-certificate-manager", - "documentation/platform/pki/certificate-syncs/azure-key-vault" - ] - } + "documentation/platform/pki/certificates/overview", + "documentation/platform/pki/certificates/profiles", + "documentation/platform/pki/certificates/templates", + "documentation/platform/pki/certificates/certificates" ] - } + }, + { + "group": "Enrollment Methods", + "pages": [ + "documentation/platform/pki/enrollment-methods/overview", + "documentation/platform/pki/enrollment-methods/api", + "documentation/platform/pki/enrollment-methods/est" + ] + }, + "documentation/platform/pki/alerting" + ] + }, + { + "group": "Infrastructure Integrations", + "pages": [ + "documentation/platform/pki/pki-issuer", + "documentation/platform/pki/integration-guides/gloo-mesh" + ] + }, + { + "group": "Certificate Syncs", + "pages": [ + "documentation/platform/pki/certificate-syncs/overview", + "documentation/platform/pki/certificate-syncs/aws-certificate-manager", + "documentation/platform/pki/certificate-syncs/azure-key-vault" + ] + }, + { + "group": "External CA Integrations", + "pages": [ + "documentation/platform/pki/ca/acme-ca", + "documentation/platform/pki/ca/azure-adcs" ] } ] @@ -2553,8 +2585,6 @@ "api-reference/endpoints/certificate-authorities/cert", "api-reference/endpoints/certificate-authorities/sign-intermediate", "api-reference/endpoints/certificate-authorities/import-cert", - "api-reference/endpoints/certificate-authorities/issue-cert", - "api-reference/endpoints/certificate-authorities/sign-cert", "api-reference/endpoints/certificate-authorities/crl" ] }, @@ -2563,13 +2593,15 @@ "pages": [ "api-reference/endpoints/certificates/list", "api-reference/endpoints/certificates/read", + "api-reference/endpoints/certificates/issue-certificate", + "api-reference/endpoints/certificates/sign-certificate", + "api-reference/endpoints/certificates/renew", + "api-reference/endpoints/certificates/update-config", "api-reference/endpoints/certificates/revoke", "api-reference/endpoints/certificates/delete", "api-reference/endpoints/certificates/cert-body", "api-reference/endpoints/certificates/bundle", - "api-reference/endpoints/certificates/private-key", - "api-reference/endpoints/certificates/issue-certificate", - "api-reference/endpoints/certificates/sign-certificate" + "api-reference/endpoints/certificates/private-key" ] }, { @@ -2606,10 +2638,14 @@ { "group": "Certificate Profiles", "pages": [ + "api-reference/endpoints/certificate-profiles/list", "api-reference/endpoints/certificate-profiles/create", "api-reference/endpoints/certificate-profiles/update", "api-reference/endpoints/certificate-profiles/get-by-id", - "api-reference/endpoints/certificate-profiles/delete" + "api-reference/endpoints/certificate-profiles/get-by-slug", + "api-reference/endpoints/certificate-profiles/delete", + "api-reference/endpoints/certificate-profiles/list-certificates", + "api-reference/endpoints/certificate-profiles/get-latest-active-bundle" ] }, { @@ -2618,6 +2654,9 @@ "api-reference/endpoints/pki/syncs/list", "api-reference/endpoints/pki/syncs/get-by-id", "api-reference/endpoints/pki/syncs/options", + "api-reference/endpoints/pki/syncs/list-certificates", + "api-reference/endpoints/pki/syncs/add-certificates", + "api-reference/endpoints/pki/syncs/remove-certificates", { "group": "AWS Certificate Manager", "pages": [ @@ -2989,6 +3028,30 @@ { "source": "/sdks/languages/csharp", "destination": "/sdks/languages/dotnet" + }, + { + "source": "/documentation/platform/pki/private-ca", + "destination": "/documentation/platform/pki/ca/private-ca" + }, + { + "source": "/documentation/platform/pki/external-ca", + "destination": "/documentation/platform/pki/ca/external-ca" + }, + { + "source": "/documentation/platform/pki/acme-ca", + "destination": "/documentation/platform/pki/ca/acme-ca" + }, + { + "source": "/documentation/platform/pki/azure-adcs", + "destination": "/documentation/platform/pki/ca/azure-adcs" + }, + { + "source": "/documentation/platform/pki/certificates", + "destination": "/documentation/platform/pki/certificates/certificates" + }, + { + "source": "/documentation/platform/pki/est", + "destination": "/documentation/platform/pki/enrollment-methods/est" } ] } diff --git a/docs/documentation/platform/pki/acme-ca.mdx b/docs/documentation/platform/pki/ca/acme-ca.mdx similarity index 98% rename from docs/documentation/platform/pki/acme-ca.mdx rename to docs/documentation/platform/pki/ca/acme-ca.mdx index bf130da9e..bcdd4f4a9 100644 --- a/docs/documentation/platform/pki/acme-ca.mdx +++ b/docs/documentation/platform/pki/ca/acme-ca.mdx @@ -1,5 +1,5 @@ --- -title: "Certificates with ACME CA" +title: "ACME-compatible CA" description: "Learn how to automatically provision and manage TLS certificates using ACME Certificate Authorities like Let's Encrypt with Infisical PKI" --- @@ -257,6 +257,7 @@ In the following steps, we explore how to set up ACME Certificate Authority inte - Downloaded directly from the Infisical UI - Retrieved via the Infisical API for programmatic access using the [latest certificate bundle endpoint](/api-reference/endpoints/pki/subscribers/get-latest-cert-bundle) + ## Example: Let's Encrypt Integration @@ -264,19 +265,23 @@ In the following steps, we explore how to set up ACME Certificate Authority inte Let's Encrypt is a free, automated, and open Certificate Authority that provides domain-validated SSL/TLS certificates. Here's how the integration works with Infisical: ### Production Environment + - **Directory URL**: `https://acme-v02.api.letsencrypt.org/directory` - **Rate Limits**: 50 certificates per registered domain per week - **Certificate Validity**: 90 days with automatic renewal - **Trusted By**: All major browsers and operating systems ### Staging Environment (for testing) + - **Directory URL**: `https://acme-staging-v02.api.letsencrypt.org/directory` - **Rate Limits**: Much higher limits for testing - **Certificate Validity**: 90 days (not trusted by browsers) - **Use Case**: Testing your ACME integration without hitting production rate limits - Always test your ACME integration using Let's Encrypt's staging environment first. This allows you to verify your DNS configuration and certificate issuance process without consuming your production rate limits. + Always test your ACME integration using Let's Encrypt's staging environment + first. This allows you to verify your DNS configuration and certificate + issuance process without consuming your production rate limits. ## Example: DigiCert Integration @@ -289,7 +294,9 @@ DigiCert is a leading commercial Certificate Authority providing a wide range of - **Trusted By**: All major browsers and operating systems. - When integrating with DigiCert ACME, ensure you have obtained the necessary External Account Binding (EAB) Key Identifier (KID) and HMAC Key from your DigiCert account. + When integrating with DigiCert ACME, ensure you have obtained the necessary + External Account Binding (EAB) Key Identifier (KID) and HMAC Key from your + DigiCert account. ## FAQ @@ -303,11 +310,13 @@ DigiCert is a leading commercial Certificate Authority providing a wide range of - Can be fully automated without manual intervention Support for additional DNS providers is planned for future releases. + Yes! ACME CAs like Let's Encrypt support wildcard certificates (e.g., `*.example.com`) when using DNS-01 validation. Simply specify the wildcard domain in your subscriber configuration. Note that wildcard certificates still require DNS-01 validation - HTTP-01 validation cannot be used for wildcard certificates. + Most ACME providers issue certificates with 90-day validity periods. This shorter validity period is designed to: @@ -317,6 +326,7 @@ DigiCert is a leading commercial Certificate Authority providing a wide range of - Ensure systems stay up-to-date with certificate management practices When configured, Infisical automatically handles certificate renewal for subscribers. + Yes! You can register multiple ACME CAs in the same project: @@ -326,5 +336,6 @@ DigiCert is a leading commercial Certificate Authority providing a wide range of - Backup providers for redundancy Each subscriber can be configured to use a specific ACME CA based on your requirements. + diff --git a/docs/documentation/platform/pki/azure-adcs.mdx b/docs/documentation/platform/pki/ca/azure-adcs.mdx similarity index 64% rename from docs/documentation/platform/pki/azure-adcs.mdx rename to docs/documentation/platform/pki/ca/azure-adcs.mdx index 23df3ed3b..3bcb4b912 100644 --- a/docs/documentation/platform/pki/azure-adcs.mdx +++ b/docs/documentation/platform/pki/ca/azure-adcs.mdx @@ -1,5 +1,5 @@ --- -title: "Certificates with Azure ADCS" +title: "Microsoft AD CS" description: "Learn how to issue and manage certificates using Microsoft Active Directory Certificate Services (ADCS) with Infisical." --- @@ -10,7 +10,7 @@ Issue and manage certificates using Microsoft Active Directory Certificate Servi Before setting up ADCS integration, ensure you have: - Microsoft Active Directory Certificate Services (ADCS) server running and accessible -- Domain administrator account with certificate management permissions +- Domain administrator account with certificate management permissions - ADCS web enrollment enabled on your server - Network connectivity from Infisical to the ADCS server - **IP whitelisting**: Your ADCS server must allow connections from Infisical's IP addresses @@ -24,67 +24,64 @@ This section walks you through the complete end-to-end process of setting up Azu - In your Infisical project, go to your **Certificate Project** → **Certificate Authority** to access the external CAs page. - - ![External CA Page](/images/platform/pki/azure-adcs/azure-adcs-external-ca-page.png) + In your Infisical project, go to your **Certificate Project** → + **Certificate Authority** to access the external CAs page. ![External CA + Page](/images/platform/pki/azure-adcs/azure-adcs-external-ca-page.png) - - - Click **Create CA** and configure: - - **Type**: Choose **Active Directory Certificate Services (AD CS)** - - **Name**: Friendly name for this CA (e.g., "Production ADCS CA") - - **App Connection**: Choose your ADCS connection from the dropdown - - ![External CA Form](/images/platform/pki/azure-adcs/azure-adcs-external-ca-form.png) - - - - Once created, your Azure ADCS Certificate Authority will appear in the list and be ready for use. - - ![External CA Created](/images/platform/pki/azure-adcs/azure-adcs-external-ca-created.png) - - - - Go to **Subscribers** to access the subscribers page. - - ![Subscribers Page](/images/platform/pki/azure-adcs/azure-adcs-subscribers-page.png) - - - - Click **Add Subscriber** and configure: - - **Name**: Unique subscriber name (e.g., "web-server-certs") - - **Certificate Authority**: Select your ADCS CA - - **Common Name**: Certificate CN (e.g., "api.example.com") - - **Certificate Template**: Select from dynamically loaded ADCS templates - - **Subject Alternative Names**: DNS names, IP addresses, or email addresses - - **TTL**: Certificate validity period (e.g., "1y" for 1 year) - - **Additional Subject Fields**: Organization, OU, locality, state, country, email (if required by template) - - ![Subscribers Form](/images/platform/pki/azure-adcs/azure-adcs-subscribers-form.png) - - - - Your subscriber is now created and ready to issue certificates. - - ![Subscriber Created](/images/platform/pki/azure-adcs/azure-adcs-subscribers-created.png) - - - - Click into your subscriber and click **Order Certificate** to generate a new certificate using your ADCS template. - - ![Issue New Certificate](/images/platform/pki/azure-adcs/azure-adcs-subscriber-issue-new-certificate.png) - - - - Your certificate has been successfully issued by the ADCS server and is ready for use. - - ![Certificate Created](/images/platform/pki/azure-adcs/azure-adcs-certificate-created.png) - - + + + Click **Create CA** and configure: - **Type**: Choose **Active Directory + Certificate Services (AD CS)** - **Name**: Friendly name for this CA (e.g., + "Production ADCS CA") - **App Connection**: Choose your ADCS connection from + the dropdown ![External CA + Form](/images/platform/pki/azure-adcs/azure-adcs-external-ca-form.png) + + + + Once created, your Azure ADCS Certificate Authority will appear in the list + and be ready for use. ![External CA + Created](/images/platform/pki/azure-adcs/azure-adcs-external-ca-created.png) + + + + Go to **Subscribers** to access the subscribers page. ![Subscribers + Page](/images/platform/pki/azure-adcs/azure-adcs-subscribers-page.png) + + + + Click **Add Subscriber** and configure: - **Name**: Unique subscriber name + (e.g., "web-server-certs") - **Certificate Authority**: Select your ADCS CA - + **Common Name**: Certificate CN (e.g., "api.example.com") - **Certificate + Template**: Select from dynamically loaded ADCS templates - **Subject + Alternative Names**: DNS names, IP addresses, or email addresses - **TTL**: + Certificate validity period (e.g., "1y" for 1 year) - **Additional Subject + Fields**: Organization, OU, locality, state, country, email (if required by + template) ![Subscribers + Form](/images/platform/pki/azure-adcs/azure-adcs-subscribers-form.png) + + + + Your subscriber is now created and ready to issue certificates. ![Subscriber + Created](/images/platform/pki/azure-adcs/azure-adcs-subscribers-created.png) + + + + Click into your subscriber and click **Order Certificate** to generate a new + certificate using your ADCS template. ![Issue New + Certificate](/images/platform/pki/azure-adcs/azure-adcs-subscriber-issue-new-certificate.png) + + + + Your certificate has been successfully issued by the ADCS server and is ready + for use. ![Certificate + Created](/images/platform/pki/azure-adcs/azure-adcs-certificate-created.png) + + - Navigate to **Certificates** to view detailed information about all issued certificates, including expiration dates, serial numbers, and certificate chains. - - ![Certificates Page](/images/platform/pki/azure-adcs/azure-adcs-certificates-page.png) + Navigate to **Certificates** to view detailed information about all issued + certificates, including expiration dates, serial numbers, and certificate + chains. ![Certificates + Page](/images/platform/pki/azure-adcs/azure-adcs-certificates-page.png) @@ -95,6 +92,7 @@ Infisical automatically retrieves available certificate templates from your ADCS ### Common Template Types ADCS templates you might see include: + - **Web Server**: For SSL/TLS certificates with server authentication - **Computer**: For machine authentication certificates - **User**: For client authentication certificates @@ -106,13 +104,16 @@ ADCS templates you might see include: ### Template Requirements Ensure your ADCS templates are configured with: + - **Enroll permissions** for your connection account - **Auto-enroll permissions** if using automated workflows - **Subject name requirements** matching your certificate requests - **Key usage extensions** appropriate for your use case -**Dynamic Template Discovery**: Infisical queries your ADCS server in real-time to populate available templates. Only templates you have permission to use will be displayed during certificate issuance. + **Dynamic Template Discovery**: Infisical queries your ADCS server in + real-time to populate available templates. Only templates you have permission + to use will be displayed during certificate issuance. ## Certificate Issuance Limitations @@ -120,10 +121,13 @@ Ensure your ADCS templates are configured with: ### Immediate Issuance Only -**Manual Approval Not Supported**: Infisical currently supports only **immediate certificate issuance**. Certificates that require manual approval or are held by ADCS policies cannot be issued through Infisical yet. + **Manual Approval Not Supported**: Infisical currently supports only + **immediate certificate issuance**. Certificates that require manual approval + or are held by ADCS policies cannot be issued through Infisical yet. For successful certificate issuance, ensure your ADCS templates and policies are configured to: + - **Auto-approve** certificate requests without manual intervention - **Not require** administrator approval for the templates you plan to use - **Allow** the connection account to request and receive certificates immediately @@ -131,19 +135,22 @@ For successful certificate issuance, ensure your ADCS templates and policies are ### What Happens with Manual Approval If a certificate request requires manual approval: + 1. The request will be submitted to ADCS successfully 2. Infisical will attempt to retrieve the certificate with exponential backoff (up to 5 retries over ~1 minute) 3. If the certificate is not approved within this timeframe, the request will **fail** 4. **No background polling**: Currently, Infisical does not check for certificates that might be approved hours or days later -**Future Enhancement**: Background polling for delayed certificate approvals is planned for future releases. + **Future Enhancement**: Background polling for delayed certificate approvals + is planned for future releases. ### Certificate Revocation -Certificate revocation is **not supported** by the Azure ADCS connector due to security and complexity considerations. + Certificate revocation is **not supported** by the Azure ADCS connector due to + security and complexity considerations. ## Advanced Configuration @@ -166,28 +173,33 @@ This allows Infisical to control certificate expiration dates directly. ### Common Issues **Certificate Request Denied** + - Verify ADCS template permissions for your connection account - Check template subject name requirements - Ensure template allows the requested key algorithm and size **Revocation Service Unavailable** + - Verify IIS is running and the revocation endpoint is accessible - Check IIS application pool permissions - Test endpoint connectivity from Infisical **Template Not Found** + - Verify template exists on ADCS server and is published - Check that your connection account has enrollment permissions for the template - Ensure the template is properly configured and available in the ADCS web enrollment interface - Templates are dynamically loaded - refresh the PKI Subscriber form if templates don't appear **Certificate Request Pending/Timeout** + - Check if your ADCS template requires manual approval - Infisical only supports immediate issuance - Verify the certificate template is configured for auto-approval - Ensure your connection account has sufficient permissions to request certificates without approval - Review ADCS server policies that might be holding the certificate request **Network Connectivity Issues** + - Verify your ADCS server's firewall allows connections from Infisical - For Infisical Cloud: Ensure Infisical's IP addresses are whitelisted (see [Networking Configuration](/documentation/setup/networking)) - For self-hosted: Whitelist your Infisical server's IP address on the ADCS server @@ -195,11 +207,13 @@ This allows Infisical to control certificate expiration dates directly. - Check for any network security appliances blocking the connection **Authentication Failures** + - Verify ADCS connection credentials - Check domain account permissions - Ensure network connectivity to ADCS server **SSL/TLS Certificate Errors** + - For ADCS servers with self-signed or private certificates: disable "Reject Unauthorized" in the SSL tab of your Azure ADCS app connection, or provide the certificate in PEM format - Common SSL errors: `UNABLE_TO_VERIFY_LEAF_SIGNATURE`, `SELF_SIGNED_CERT_IN_CHAIN`, `CERT_HAS_EXPIRED` - The SSL configuration applies to all HTTPS communications between Infisical and your ADCS server diff --git a/docs/documentation/platform/pki/ca/external-ca.mdx b/docs/documentation/platform/pki/ca/external-ca.mdx new file mode 100644 index 000000000..1dc89ec96 --- /dev/null +++ b/docs/documentation/platform/pki/ca/external-ca.mdx @@ -0,0 +1,50 @@ +--- +title: "External CA" +sidebarTitle: "External CA" +description: "Learn how to connect External Certificate Authorities with Infisical." +--- + +## Concept + +Infisical lets you integrate with External Certificate Authorities (CAs), allowing you to use existing PKI infrastructure or connect to public CAs to issue digital certificates for your end-entities. + +
+ +```mermaid +graph TD + A1[External Public CA
e.g. Let's Encrypt, ZeroSSL, ...] --> Infisical + A2[External Private CA
e.g. AWS Private CA, HashiCorp Vault PKI, ...] --> Infisical +``` + +
+ +As shown above, these CAs commonly fall under two categories: + +- External Private CAs: CAs like AWS Private CA, HashiCorp Vault PKI, Azure ADCS, etc. that are privately owned and are used to issue certificates for internal services; these are often either cloud-hosted private CAs or on-prem / enterprise CAs. +- External Public CAs: CAs like Let's Encrypt, DigiCert, GlobalSign, etc. that are publicly trusted and are used to issue certificates for public-facing services. + +Note that Infisical can also act as an _ACME client_, allowing you to integrate upstream with any ACME-compatible CA to automate certificate issuance and renewal. + +## Workflow + +A typical workflow for integrating an External CA with Infisical consists of choosing the desired External CA type +and specifying the configuration or connection details necessary to connect to the CA. + +The specific steps and requirements vary depending on the External CA type you choose to integrate. + +## Supported External CA Types + +Infisical currently supports the following External CA types out of the box: + +- [ACME CA](/documentation/platform/pki/ca/acme-ca): An ACME-compatible CA that supports the ACME protocol, such as Let's Encrypt, ZeroSSL, Buypass, Digicert, etc. +- [Azure ADCS](/documentation/platform/pki/ca/azure-adcs): A Microsoft Active Directory Certificate Services (ADCS) that supports the ADCS protocol, such as AWS Private CA, Azure ADCS, etc. + +If you don’t see a specific external CA listed here or need a dedicated integration guide, please reach out to sales@infisical.com and we’ll help you set up the integration for your external CA. + +## FAQ + + + + Yes. You can have both Private and External CAs in the same project. + + diff --git a/docs/documentation/platform/pki/ca/overview.mdx b/docs/documentation/platform/pki/ca/overview.mdx new file mode 100644 index 000000000..9a815992a --- /dev/null +++ b/docs/documentation/platform/pki/ca/overview.mdx @@ -0,0 +1,13 @@ +--- +title: "Overview" +sidebarTitle: "Overview" +--- + +Before issuing and managing certificates with Infisical, you'll need to configure a Certificate Authority (CA). + +This is the trusted entity that signs and validates the X.509 certificates used to secure your end-entities. + +Infisical supports two categories of CAs: + +- [Internal CA](/documentation/platform/pki/ca/private-ca): Internally operated root and intermediate CAs managed within Infisical. This is useful if you need complete control over your PKI and are issuing certificates for private networks, internal services, or managed devices. +- [External CA](/documentation/platform/pki/ca/external-ca): Third-party public (e.g. Let's Encrypt, DigiCert) or private (e.g. AWS Private CA, HashiCorp Vault PKI, etc.) CAs that can be integrated with Infisical. This is useful if you want to leverage existing PKI infrastructure or issue publicly trusted certificates. diff --git a/docs/documentation/platform/pki/private-ca.mdx b/docs/documentation/platform/pki/ca/private-ca.mdx similarity index 92% rename from docs/documentation/platform/pki/private-ca.mdx rename to docs/documentation/platform/pki/ca/private-ca.mdx index 7d7ee1220..74913d4cc 100644 --- a/docs/documentation/platform/pki/private-ca.mdx +++ b/docs/documentation/platform/pki/ca/private-ca.mdx @@ -1,13 +1,12 @@ --- -title: "Private CA" -sidebarTitle: "Private CA" +title: "Internal CA" +sidebarTitle: "Internal CA" description: "Learn how to create a Private CA hierarchy with Infisical." --- ## Concept -The first step to creating your Internal PKI is to create a Private Certificate Authority (CA) hierarchy that is a structure of entities -used to issue digital certificates for your [subscribers](/documentation/platform/pki/subscribers). +Infisical lets you build your Internal PKI through a Private Certificate Authority (CA) hierarchy, enabling you to issue and manage digital certificates for your end-entities.
@@ -47,7 +46,7 @@ consisting of an (optional) root CA and an intermediate CA. If you wish to use an external root CA, you can skip this step and head to step 2 to create an intermediate CA. - To create a root CA, head to your Project > Internal PKI > Certificate Authorities and press **Create CA**. + To create a root CA, head to your Certificate Management Project > Certificate Authorities > Internal Certificate Authorities and press **Create CA**. ![pki create ca](/images/platform/pki/ca/ca-create.png) @@ -55,18 +54,17 @@ consisting of an (optional) root CA and an intermediate CA. ![pki create root ca](/images/platform/pki/ca/ca-create-root.png) - Here's some guidance on each field: + Here's some guidance for each field: - Valid Until: The date until which the CA is valid in the date time string format specified [here](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date#date_time_string_format). For example, the following formats would be valid: `YYYY`, `YYYY-MM`, `YYYY-MM-DD`, `YYYY-MM-DDTHH:mm:ss.sssZ`. - Path Length: The maximum number of intermediate CAs that can be chained to this CA. A path of `-1` implies no limit; a path of `0` implies no intermediate CAs can be chained. - Key Algorithm: The type of public key algorithm and size, in bits, of the key pair that the CA creates when it issues a certificate. Supported key algorithms are `RSA 2048`, `RSA 4096`, `ECDSA P-256`, and `ECDSA P-384` with the default being `RSA 2048`. - - Friendly Name: A friendly name for the CA; this is only for display and defaults to the subject of the CA if left empty. + - Name: A slug-friendly name for the CA. - Organization (O): The organization name. - Country (C): The country code. - State or Province Name: The state or province. - Locality Name: The city or locality. - Common Name: The name of the CA. - - Require Template for Certificate Issuance: Whether or not certificates for this CA can only be issued through certificate templates (recommended). The Organization, Country, State or Province Name, Locality Name, and Common Name make up the **Distinguished Name (DN)** or **subject** of the CA. @@ -98,8 +96,7 @@ consisting of an (optional) root CA and an intermediate CA. ![pki cas](/images/platform/pki/ca/cas.png) - Great! You've successfully created a Private CA hierarchy with a root CA and an intermediate CA. - Now check out the [Subscribers](/documentation/platform/pki/subscribers) page to learn more about how to issue X.509 certificates using the intermediate CA. + Great! You've successfully created a Private CA hierarchy with a root CA and an intermediate CA. Now check out the [Certificates section](/documentation/platform/pki/certificates/overview) to learn more about how to issue X.509 certificates using the intermediate CA. 2.3b. If you have an external root CA, select **External CA** for the **Parent CA Type** field. @@ -110,7 +107,7 @@ consisting of an (optional) root CA and an intermediate CA. Finally, press **Install** to import the certificate and certificate chain as part of the installation step for the intermediate CA Great! You've successfully created a Private CA hierarchy with an intermediate CA chained to an external root CA. - Now check out the [Subscribers](/documentation/platform/pki/subscribers) page to learn more about how to issue X.509 certificates using the intermediate CA. + Now check out the [Certificates section](/documentation/platform/pki/certificates/overview) to learn more about how to issue X.509 certificates using the intermediate CA. diff --git a/docs/documentation/platform/pki/certificate-syncs/aws-certificate-manager.mdx b/docs/documentation/platform/pki/certificate-syncs/aws-certificate-manager.mdx index de064bf4e..e33f46f3e 100644 --- a/docs/documentation/platform/pki/certificate-syncs/aws-certificate-manager.mdx +++ b/docs/documentation/platform/pki/certificate-syncs/aws-certificate-manager.mdx @@ -5,82 +5,87 @@ description: "Learn how to configure an AWS Certificate Manager Certificate Sync **Prerequisites:** -- Set up and configure a [Certificate Authority](/documentation/platform/pki/overview) - Create an [AWS Connection](/integrations/app-connections/aws) The AWS Certificate Manager Certificate Sync requires the following ACM permissions to be set on the IAM user/role for Infisical to sync certificates to AWS Certificate Manager: `acm:ListCertificates`, `acm:DescribeCertificate`, `acm:ImportCertificate`, `acm:DeleteCertificate`, and `acm:ListTagsForCertificate`. - These permissions allow Infisical to list, import, tag, and manage certificates in your AWS Certificate Manager service. +These permissions allow Infisical to list, import, tag, and manage certificates in your AWS Certificate Manager service. + - Certificates synced to AWS Certificate Manager will be stored as imported certificates, preserving both the certificate and private key components. + Certificates synced to AWS Certificate Manager will be stored as imported + certificates, preserving both the certificate and private key components. - 1. Navigate to **Project** > **Integrations** and select the **Certificate Syncs** tab. Click on the **Add Sync** button. - ![Certificate Syncs Tab](/images/certificate-syncs/general/certificate-sync-tab.png) + 1. Navigate to **Project** > **Integrations** > **Certificate Syncs** and press **Add Sync**. + ![Certificate Syncs Tab](/images/platform/pki/certificate-syncs/general/create-certificate-sync.png) 2. Select the **AWS Certificate Manager** option. - ![Select ACM](/images/certificate-syncs/aws-certificate-manager/select-acm-option.png) + ![Select ACM](/images/platform/pki/certificate-syncs/aws-certificate-manager/select-acm-option.png) - 3. Configure the **Source** from where certificates should be retrieved, then click **Next**. - ![Configure Source](/images/certificate-syncs/aws-certificate-manager/acm-source.png) + 3. Configure the **Destination** to where certificates should be deployed, then click **Next**. + ![Configure Destination](/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-destination.png) - - **PKI Subscriber**: The PKI subscriber to retrieve certificates from. + - **AWS Connection**: The AWS Connection to authenticate with. + - **AWS Region**: The AWS region where certificates should be stored. - 4. Configure the **Destination** to where certificates should be deployed, then click **Next**. - ![Configure Destination](/images/certificate-syncs/aws-certificate-manager/acm-destination.png) + 4. Configure the **Sync Options** to specify how certificates should be synced, then click **Next**. + ![Configure Options](/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-options.png) - - **AWS Connection**: The AWS Connection to authenticate with. - - **AWS Region**: The AWS region where certificates should be stored. + - **Enable Removal of Expired/Revoked Certificates**: If enabled, Infisical will remove certificates from the destination if they are no longer active in Infisical. + - **Preserve ARN on Renewal**: If enabled, Infisical will sync renewed certificates to the destination under the same ARN as the original synced certificate instead of creating a new certificate with a new ARN. + - **Certificate Name Schema** (Optional): Customize how certificate tags are generated in AWS Certificate Manager. Must include `{{certificateId}}` as a placeholder for the certificate ID to ensure proper certificate identification and management. If not specified, defaults to `Infisical-{{certificateId}}`. + - **Auto-Sync Enabled**: If enabled, certificates will automatically be synced when changes occur. Disable to enforce manual syncing only. - 5. Configure the **Sync Options** to specify how certificates should be synced, then click **Next**. - ![Configure Options](/images/certificate-syncs/aws-certificate-manager/acm-options.png) - - - **Auto-Sync Enabled**: If enabled, certificates will automatically be synced from the source PKI subscriber when changes occur. Disable to enforce manual syncing only. - - **Enable Certificate Removal**: If enabled, Infisical will remove expired certificates from the destination during sync operations. Disable this option if you intend to manage certificate cleanup manually. - - **Certificate Name Schema** (Optional): Customize how certificate tags are generated in AWS Certificate Manager. Must include `{{certificateId}}` as a placeholder for the certificate ID to ensure proper certificate identification and management. If not specified, defaults to `Infisical-{{certificateId}}`. - - - **AWS Certificate Manager Certificate Limits**: AWS Certificate Manager has limits on the number of certificates per account and region. Refer to AWS documentation for current limits. Deleted certificates count toward your quota until they are permanently purged by AWS (typically after 30 days). - - - 6. Configure the **Details** of your AWS Certificate Manager Certificate Sync, then click **Next**. - ![Configure Details](/images/certificate-syncs/aws-certificate-manager/acm-details.png) + 5. Configure the **Details** of your AWS Certificate Manager Certificate Sync, then click **Next**. + ![Configure Details](/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-details.png) - **Name**: The name of your sync. Must be slug-friendly. - **Description**: An optional description for your sync. + 6. Select which certificates should be synced to AWS Certificate Manager. + ![Select Certificates](/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-certificates.png) + 7. Review your AWS Certificate Manager Certificate Sync configuration, then click **Create Sync**. - ![Confirm Configuration](/images/certificate-syncs/aws-certificate-manager/acm-review.png) + ![Confirm Configuration](/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-review.png) 8. If enabled, your AWS Certificate Manager Certificate Sync will begin syncing your certificates to the destination endpoint. - ![Sync Certificates](/images/certificate-syncs/aws-certificate-manager/acm-synced.png) - + ![Sync Certificates](/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-synced.png) To create an **AWS Certificate Manager Certificate Sync**, make an API request to the [Create AWS Certificate Manager Certificate Sync](/api-reference/endpoints/pki/syncs/aws-certificate-manager/create) API endpoint. ### Sample request + + You can optionally specify `certificateIds` during sync creation to immediately add certificates to the sync. + If not provided, you can add certificates later using the certificate management endpoints. + + ```bash Request curl --request POST \ --url https://app.infisical.com/api/v1/pki/syncs/aws-certificate-manager \ + --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data '{ "name": "my-acm-cert-sync", "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", "description": "an example certificate sync", "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", - "subscriberId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", "destination": "aws-certificate-manager", "isAutoSyncEnabled": true, + "certificateIds": [ + "550e8400-e29b-41d4-a716-446655440000", + "660f1234-e29b-41d4-a716-446655440001" + ], "syncOptions": { "canRemoveCertificates": true, + "preserveArnOnRenewal": true, "certificateNameSchema": "myapp-{{certificateId}}" }, "destinationConfig": { @@ -104,10 +109,10 @@ description: "Learn how to configure an AWS Certificate Manager Certificate Sync }, "syncOptions": { "canRemoveCertificates": true, + "preserveArnOnRenewal": true, "certificateNameSchema": "myapp-{{certificateId}}" }, "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", - "subscriberId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", "createdAt": "2023-01-01T00:00:00.000Z", "updatedAt": "2023-01-01T00:00:00.000Z" @@ -115,24 +120,27 @@ description: "Learn how to configure an AWS Certificate Manager Certificate Sync } ``` + ## Certificate Management Your AWS Certificate Manager Certificate Sync will: -- **Automatic Deployment**: Deploy new certificates issued by your PKI subscriber to AWS Certificate Manager -- **Certificate Updates**: Update certificates in AWS Certificate Manager when renewals occur -- **Expiration Handling**: Optionally remove expired certificates from AWS Certificate Manager (if enabled) +- **Automatic Deployment**: Deploy certificates in Infisical to AWS Certificate Manager. +- **Certificate Updates**: Update certificates in AWS Certificate Manager when renewals occur. +- **Expiration Handling**: Optionally remove expired certificates from AWS Certificate Manager (if enabled). - **Tagging**: Automatically tag certificates with an InfisicalCertificate tag for easy identification and management - AWS Certificate Manager Certificate Syncs support both automatic and manual synchronization modes. When auto-sync is enabled, certificates are automatically deployed as they are issued or renewed. + AWS Certificate Manager Certificate Syncs support both automatic and manual + synchronization modes. When auto-sync is enabled, certificates are + automatically deployed as they are issued or renewed. ## Manual Certificate Sync -You can manually trigger certificate synchronization from your PKI subscriber to AWS Certificate Manager using the sync certificates functionality. This is useful for: +You can manually trigger certificate synchronization to AWS Certificate Manager using the sync certificates functionality. This is useful for: - Initial setup when you have existing certificates to deploy - One-time sync of specific certificates @@ -142,5 +150,8 @@ You can manually trigger certificate synchronization from your PKI subscriber to To manually sync certificates, use the [Sync Certificates](/api-reference/endpoints/pki/syncs/aws-certificate-manager/sync-certificates) API endpoint or the manual sync option in the Infisical UI. -AWS Certificate Manager does not support importing certificates back into Infisical due to security limitations where private keys cannot be extracted from AWS Certificate Manager. Only certificates imported into ACM (not AWS-issued certificates) can be managed by the sync. - \ No newline at end of file + AWS Certificate Manager does not support importing certificates back into + Infisical due to security limitations where private keys cannot be extracted + from AWS Certificate Manager. Only certificates imported into ACM (not + AWS-issued certificates) can be managed by the sync. + diff --git a/docs/documentation/platform/pki/certificate-syncs/azure-key-vault.mdx b/docs/documentation/platform/pki/certificate-syncs/azure-key-vault.mdx index 6190ab3c3..cfdbfe136 100644 --- a/docs/documentation/platform/pki/certificate-syncs/azure-key-vault.mdx +++ b/docs/documentation/platform/pki/certificate-syncs/azure-key-vault.mdx @@ -5,47 +5,43 @@ description: "Learn how to configure an Azure Key Vault Certificate Sync for Inf **Prerequisites:** - - Set up and configure a [Certificate Authority](/documentation/platform/pki/overview) - - Create an [Azure Key Vault Connection](/integrations/app-connections/azure-key-vault) - - Ensure your network security policies allow incoming requests from Infisical to this certificate sync provider, if network restrictions apply. +- Create an [Azure Key Vault Connection](/integrations/app-connections/azure-key-vault) +- Ensure your network security policies allow incoming requests from Infisical to this certificate sync provider, if network restrictions apply. The Azure Key Vault Certificate Sync requires the following certificate permissions to be set on the user / service principal for Infisical to sync certificates to Azure Key Vault: `certificates/list`, `certificates/get`, `certificates/import`, `certificates/delete`. - Any role with these permissions would work such as the **Key Vault Certificates Officer** role. +Any role with these permissions would work such as the **Key Vault Certificates Officer** role. + - Certificates synced to Azure Key Vault will be stored as certificate objects, preserving both the certificate and private key components. + Certificates synced to Azure Key Vault will be stored as certificate objects, + preserving both the certificate and private key components. - 1. Navigate to **Project** > **Integrations** and select the **Certificate Syncs** tab. Click on the **Add Sync** button. - ![Certificate Syncs Tab](/images/certificate-syncs/general/certificate-sync-tab.png) + 1. Navigate to **Project** > **Integrations** > **Certificate Syncs** and press **Add Sync**. + ![Certificate Syncs Tab](/images/platform/pki/certificate-syncs/general/create-certificate-sync.png) 2. Select the **Azure Key Vault** option. - ![Select Key Vault](/images/certificate-syncs/azure-key-vault/select-key-vault-option.png) + ![Select Key Vault](/images/platform/pki/certificate-syncs/azure-key-vault/select-akv-option.png) - 3. Configure the **Source** from where certificates should be retrieved, then click **Next**. - ![Configure Source](/images/certificate-syncs/azure-key-vault/vault-source.png) - - - **PKI Subscriber**: The PKI subscriber to retrieve certificates from. - - 4. Configure the **Destination** to where certificates should be deployed, then click **Next**. - ![Configure Destination](/images/certificate-syncs/azure-key-vault/vault-destination.png) + 3. Configure the **Destination** to where certificates should be deployed, then click **Next**. + ![Configure Destination](/images/platform/pki/certificate-syncs/azure-key-vault/akv-destination.png) - **Azure Connection**: The Azure Connection to authenticate with. - **Vault Base URL**: The URL of your Azure Key Vault. -

- 5. Configure the **Sync Options** to specify how certificates should be synced, then click **Next**. - ![Configure Options](/images/certificate-syncs/azure-key-vault/vault-options.png) + 4. Configure the **Sync Options** to specify how certificates should be synced, then click **Next**. + ![Configure Options](/images/platform/pki/certificate-syncs/azure-key-vault/akv-options.png) - - **Auto-Sync Enabled**: If enabled, certificates will automatically be synced from the source PKI subscriber when changes occur. Disable to enforce manual syncing only. - - **Enable Certificate Removal**: If enabled, Infisical will remove expired certificates from the destination during sync operations. Disable this option if you intend to manage certificate cleanup manually. + - **Enable Removal of Expired/Revoked Certificates**: If enabled, Infisical will remove certificates from the destination if they are no longer active in Infisical. + - **Enable Versioning on Renewal**: If enabled, Infisical will sync renewed certificates to the destination under a new version of the original synced certificate instead of creating a new certificate. - **Certificate Name Schema** (Optional): Customize how certificate names are generated in Azure Key Vault. Use `{{certificateId}}` as a placeholder for the certificate ID. If not specified, defaults to `Infisical-{{certificateId}}`. + - **Auto-Sync Enabled**: If enabled, certificates will automatically be synced when changes occur. Disable to enforce manual syncing only. **Azure Key Vault Soft Delete**: When certificates are removed from Azure Key Vault, they are placed in a soft-deleted state rather than being permanently deleted. This means: @@ -53,38 +49,50 @@ description: "Learn how to configure an Azure Key Vault Certificate Sync for Inf - To resync removed certificates, you must either manually **purge** them from Azure Key Vault or **recover** them through the Azure portal/CLI - 6. Configure the **Details** of your Azure Key Vault Certificate Sync, then click **Next**. - ![Configure Details](/images/certificate-syncs/azure-key-vault/vault-details.png) + 5. Configure the **Details** of your Azure Key Vault Certificate Sync, then click **Next**. + ![Configure Details](/images/platform/pki/certificate-syncs/azure-key-vault/akv-details.png) - **Name**: The name of your sync. Must be slug-friendly. - **Description**: An optional description for your sync. + 6. Select which certificates should be synced to Azure Key Vault. + ![Select Certificates](/images/platform/pki/certificate-syncs/azure-key-vault/akv-certificates.png) + 7. Review your Azure Key Vault Certificate Sync configuration, then click **Create Sync**. - ![Confirm Configuration](/images/certificate-syncs/azure-key-vault/vault-review.png) + ![Confirm Configuration](/images/platform/pki/certificate-syncs/azure-key-vault/akv-review.png) 8. If enabled, your Azure Key Vault Certificate Sync will begin syncing your certificates to the destination endpoint. - ![Sync Certificates](/images/certificate-syncs/azure-key-vault/vault-synced.png) - + ![Sync Certificates](/images/platform/pki/certificate-syncs/azure-key-vault/akv-synced.png) To create an **Azure Key Vault Certificate Sync**, make an API request to the [Create Azure Key Vault Certificate Sync](/api-reference/endpoints/pki/syncs/azure-key-vault/create) API endpoint. ### Sample request + + You can optionally specify `certificateIds` during sync creation to immediately add certificates to the sync. + If not provided, you can add certificates later using the certificate management endpoints. + + ```bash Request curl --request POST \ --url https://app.infisical.com/api/v1/pki/syncs/azure-key-vault \ + --header 'Authorization: Bearer ' \ --header 'Content-Type: application/json' \ --data '{ "name": "my-key-vault-cert-sync", "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", "description": "an example certificate sync", "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", - "subscriberId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", "destination": "azure-key-vault", "isAutoSyncEnabled": true, + "certificateIds": [ + "550e8400-e29b-41d4-a716-446655440000", + "660f1234-e29b-41d4-a716-446655440001" + ], "syncOptions": { "canRemoveCertificates": true, + "enableVersioningOnRenewal": true, "certificateNameSchema": "myapp-{{certificateId}}" }, "destinationConfig": { @@ -108,10 +116,10 @@ description: "Learn how to configure an Azure Key Vault Certificate Sync for Inf }, "syncOptions": { "canRemoveCertificates": true, + "enableVersioningOnRenewal": true, "certificateNameSchema": "myapp-{{certificateId}}" }, "projectId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", - "subscriberId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", "connectionId": "3c90c3cc-0d44-4b50-8888-8dd25736052a", "createdAt": "2023-01-01T00:00:00.000Z", "updatedAt": "2023-01-01T00:00:00.000Z" @@ -119,24 +127,27 @@ description: "Learn how to configure an Azure Key Vault Certificate Sync for Inf } ``` + ## Certificate Management Your Azure Key Vault Certificate Sync will: -- **Automatic Deployment**: Deploy new certificates issued by your PKI subscriber to Azure Key Vault +- **Automatic Deployment**: Deploy certificates in Infisical to Azure Key Vault. - **Certificate Updates**: Update certificates in Azure Key Vault when renewals occur - **Expiration Handling**: Optionally remove expired certificates from Azure Key Vault (if enabled) - **Format Preservation**: Maintain certificate format and metadata during sync operations - Azure Key Vault Certificate Syncs support both automatic and manual synchronization modes. When auto-sync is enabled, certificates are automatically deployed as they are issued or renewed. + Azure Key Vault Certificate Syncs support both automatic and manual + synchronization modes. When auto-sync is enabled, certificates are + automatically deployed as they are issued or renewed. ## Manual Certificate Sync -You can manually trigger certificate synchronization from your PKI subscriber to Azure Key Vault using the sync certificates functionality. This is useful for: +You can manually trigger certificate synchronization to Azure Key Vault using the sync certificates functionality. This is useful for: - Initial setup when you have existing certificates to deploy - One-time sync of specific certificates @@ -146,5 +157,7 @@ You can manually trigger certificate synchronization from your PKI subscriber to To manually sync certificates, use the [Sync Certificates](/api-reference/endpoints/pki/syncs/azure-key-vault/sync-certificates) API endpoint or the manual sync option in the Infisical UI. -Azure Key Vault does not support importing certificates back into Infisical due to security limitations where private keys cannot be extracted from Azure Key Vault. - \ No newline at end of file + Azure Key Vault does not support importing certificates back into Infisical + due to security limitations where private keys cannot be extracted from Azure + Key Vault. + diff --git a/docs/documentation/platform/pki/certificate-syncs/overview.mdx b/docs/documentation/platform/pki/certificate-syncs/overview.mdx index db4844a92..d7931d893 100644 --- a/docs/documentation/platform/pki/certificate-syncs/overview.mdx +++ b/docs/documentation/platform/pki/certificate-syncs/overview.mdx @@ -3,17 +3,19 @@ sidebarTitle: "Overview" description: "Learn how to sync certificates from Infisical PKI to third-party services." --- -Certificate Syncs enable you to sync certificates from Infisical PKI to third-party services using [App Connections](/integrations/app-connections/overview). +Certificate Syncs enable you to push certificates from Infisical to third-party services using [App Connections](/integrations/app-connections/overview). - Certificate Syncs are designed to automatically deploy certificates issued by your Certificate Authority to external services, ensuring your certificates are always up-to-date across your infrastructure. + Certificate Syncs are designed to automatically deploy certificates issued by + your Certificate Authority to external services, ensuring your certificates + are always up-to-date across your infrastructure. ## Concept -Certificate Syncs are a project-level resource used to sync certificates, via an [App Connection](/integrations/app-connections/overview), from a particular PKI subscriber (source) -to a third-party service (destination). When new certificates are issued or existing certificates are renewed, changes will automatically be propagated to the destination, ensuring -your certificates are always current. +Certificate Syncs are a project-level resource used to push certificates, via an [App Connection](/integrations/app-connections/overview), from Infisical +to a third-party service (destination). When paired with [server-side auto-renewal](/documentation/platform/pki/certificates/certificates#server-driven-certificate-renewal), renewed certificates are automatically synced to the destination, +ensuring your certificates stay current.
@@ -31,17 +33,15 @@ your certificates are always current. G[Certificate 1] H[Certificate 2] I[Certificate 3] - J[PKI Subscriber] B --> A - C --> J - D --> J - E --> J + C --> B + D --> B + E --> B A --> F F --> G F --> H F --> I - J --> B classDef default fill:#ffffff,stroke:#666,stroke-width:2px,rx:10px,color:black classDef connection fill:#FFF2B2,stroke:#E6C34A,stroke-width:2px,color:black,rx:15px @@ -61,39 +61,50 @@ your certificates are always current. ## Workflow -Configuring a Certificate Sync requires three components: a source PKI subscriber to retrieve certificates from, +Configuring a Certificate Sync requires three components: The certificates that you'd like to push, a destination endpoint to deploy certificates to, and configuration options to determine how your certificates should be synced. Follow these steps to start syncing: - For step-by-step guides on syncing to a particular third-party service, refer to the Certificate Syncs section in the Navigation Bar. + For step-by-step guides on syncing to a particular third-party service, refer + to the Certificate Syncs section in the Navigation Bar. -1. Create App Connection: If you have not already done so, create an [App Connection](/integrations/app-connections/overview) -via the UI or API for the third-party service you intend to sync certificates to. +1. Create App Connection: If you have not already done so, create + an [App Connection](/integrations/app-connections/overview) via the UI or API + for the third-party service you intend to sync certificates to. -2. Create Certificate Sync: Configure a Certificate Sync in the desired project by specifying the following parameters via the UI or API: - - Source: The PKI subscriber you wish to retrieve certificates from. - - Destination: The App Connection to utilize and the destination endpoint to deploy certificates to. These can vary between services. - - Options: Customize how certificates should be synced, including: - - Whether certificates should be removed from the destination when they expire - - Certificate naming schema to control how certificate names are generated in the destination +2. Create Certificate Sync: Configure a Certificate Sync in the + desired project by specifying the following parameters via the UI or API: + + - Destination: The App Connection to utilize and the destination + endpoint to deploy certificates to such as [AWS Certificate Manager](/documentation/platform/pki/certificate-syncs/aws-certificate-manager) + or [Azure Key Vault](/documentation/platform/pki/certificate-syncs/azure-key-vault). + - Certificates: The certificates you wish to push to the destination. + - Options: Customize how certificates should be synced, including: + - Whether certificates should be removed from the destination when they expire. + - Certificate naming schema to control how certificate names are generated in + the destination. - Only certificates managed by Infisical will be affected during sync operations. Certificates not created or - managed by Infisical will remain untouched, and changes made to Infisical-managed certificates directly - in the destination service may be overwritten by future syncs. + Only certificates managed by Infisical will be affected during sync + operations. Certificates not created or managed by Infisical will remain + untouched, and changes made to Infisical-managed certificates directly in the + destination service may be overwritten by future syncs. - Some third-party services do not support removing expired certificates automatically. + Some third-party services do not support removing expired certificates + automatically. -3. Utilize Sync: Any new certificates issued or renewals from the source PKI subscriber will now automatically be propagated to the destination endpoint. +3. Utilize Sync: Selected certificates will now be pushed to the + destination endpoint and automatically redeployed whenever they are renewed. - Infisical is continuously expanding its Certificate Sync third-party service support. If the service you need isn't available, - contact us at team@infisical.com to make a request. + Infisical is continuously expanding its Certificate Sync third-party service + support. If the service you need isn't available, contact us at + team@infisical.com to make a request. ## Certificate Naming @@ -111,32 +122,12 @@ You can customize certificate naming by providing a **Certificate Name Schema** - `{{certificateId}}` - The unique certificate identifier (required) **Examples:** + - `myapp-{{certificateId}}` → `myapp-abc123def456` - `ssl/{{certificateId}}` → `ssl/abc123def456` **Rules:** + - Must include exactly one `{{certificateId}}` placeholder -- Only alphanumeric characters, dashes (-), underscores (_), and slashes (/) are allowed +- Only alphanumeric characters, dashes (-), underscores (\_), and slashes (/) are allowed - Certificate names matching your schema will be managed by Infisical during sync operations - -## Certificate Management - -Certificate Syncs handle the full lifecycle of certificate management: - -- **Automatic Deployment**: New certificates are automatically deployed to configured destinations -- **Renewal Propagation**: Certificate renewals are seamlessly pushed to all connected services -- **Expiration Handling**: Expired certificates can be automatically removed from destinations (service-dependent) -- **Certificate Validation**: Certificates are validated before deployment to ensure integrity - -

- ```mermaid - graph LR - A[Certificate Issued] -->|Deploy| B[Destination Service] - C[Certificate Renewed] -->|Update| B - D[Certificate Expired] -->|Remove| B - style B fill:#F4FFE6,stroke:#96D600,stroke-width:2px,color:black,rx:15px - style A fill:#E6F4FF,stroke:#0096D6,stroke-width:2px,color:black,rx:15px - style C fill:#E6F4FF,stroke:#0096D6,stroke-width:2px,color:black,rx:15px - style D fill:#FFE6E6,stroke:#D63F3F,stroke-width:2px,color:black,rx:15px - ``` -
\ No newline at end of file diff --git a/docs/documentation/platform/pki/certificates/certificates.mdx b/docs/documentation/platform/pki/certificates/certificates.mdx new file mode 100644 index 000000000..b30a53091 --- /dev/null +++ b/docs/documentation/platform/pki/certificates/certificates.mdx @@ -0,0 +1,164 @@ +--- +title: "Certificates" +sidebarTitle: "Certificates" +--- + + + PKI architecture is a complex topic and there are many ways to orchestrate + certificate management including renewal operations. For specific guidance and + access to enterprise features, we recommend reaching out to + sales@infisical.com to schedule a demo. + + +## Concept + +A certificate is the (X.509) leaf certificate issued for a certificate profile. + +Once issued, a certificate is kept track of in the certificate inventory +where you can manage various aspects of its lifecycle including deployment to cloud key stores, server-side auto-renewal behavior, revocation, and more. + +## 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. + +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 below to learn more about how to issue certificates using it. + +- [API](/documentation/platform/pki/enrollment-methods/api): Issue a certificate over UI or by making an API request to Infisical. +- [EST](/documentation/platform/pki/enrollment-methods/est): Issue a certificate over the EST protocol. + +## Guide to Renewing Certificates + +To [renew a certificate](/documentation/platform/pki/concepts/certificate-lifecycle#renewal), you can either request a new certificate from a certificate profile or have the platform +automatically request a new one for you. Whether you pursue a client-driven or server-driven approach is totally dependent on the enrollment method configured on your certificate +profile as well as your infrastructure use-case. + +### Client-Driven Certificate Renewal + +Client-driven certificate renewal is when renewal is initiated client-side by the end-entity consuming the certificate. +This is the most common approach to certificate renewal and is suitable for most use-cases. + +### Server-Driven Certificate Renewal + +Server-driven certificate renewal is when renewal is initiated server-side by Infisical rather than by the end-entity consuming the certificate. +When a certificate considered for auto-renewal meets a specified _renewal days before expiration_ threshold, Infisical reaches out to the issuing CA bound to the [certificate profile](/documentation/platform/pki/certificates/profiles) of the expiring certificate +to request for a new one. +The resulting renewed certificate is stored in the platform and made available to be fetched back or pushed downstream to end-entities or external systems such as cloud key stores. + +Note that server-driven certificate renewal is only available for certificates issued via the [API enrollment method](/documentation/platform/pki/enrollment-methods/api) where key pairs are generated server-side. +A certificate can be considered for auto-renewal at time of issuance if the **Enable Auto-Renewal By Default** option is selected on its [certificate profile](/documentation/platform/pki/certificates/profiles) or after issuance by toggling this option manually. + + + For server-driven certificate renewal workflows, you can programmatically fetch the latest active certificate bundle for a certificate profile using the [Get Latest Active Certificate Bundle](/api-reference/endpoints/certificate-profiles/get-latest-active-bundle) API endpoint. + + This ensures you always retrieve the most current valid certificate, including any that have been automatically renewed, making it particularly useful for deployment pipelines and automation workflows where you don't want to track individual serial numbers. + + +The following examples demonstrate different approaches to certificate renewal: + +- Using the ACME enrollment method, you may connect an ACME client like [certbot](https://certbot.eff.org/) to fetch back and renew certificates for Apache, Nginx, or other server. The ACME client will pursue a client-driven approach and submit certificate requests upon certificate expiration for you, saving renewed certificates back to the server's configuration. +- Using the ACME enrollment method, you may use [cert-manager](https://cert-manager.io/) with Infisical to issue and renew certificates for Kubernetes workloads; cert-manager will pursue a client-driven approach and submit certificate requests upon certificate expiration for you, saving renewed certificates back to Kubernetes secrets. +- Using the API enrollment method, you may push and auto-renew certificates to AWS and Azure using [certificate syncs](/documentation/platform/pki/certificate-syncs/overview). Certificates issued over the API enrollment method, where key pairs are generated server-side, are also eligible for server-side auto-renewal; once renewed, certificates are automatically pushed back to their sync destination. + +## Guide to Revoking Certificates + +In the following steps, we explore how to revoke a X.509 certificate and obtain a Certificate Revocation List (CRL) for a CA. + + + + + + Assuming that you've issued a certificate under a CA, you can revoke it by + selecting the **Revoke Certificate** option for it and specifying the reason + for revocation. + + ![pki revoke certificate](/images/platform/pki/certificate/cert-revoke.png) + + ![pki revoke certificate modal](/images/platform/pki/certificate/cert-revoke-modal.png) + + + + In order to check the revocation status of a certificate, you can check it + against the CRL of a CA by heading to its Issuing CA and downloading the CRL. + + ![pki view crl](/images/platform/pki/ca/ca-crl.png) + + To verify a certificate against the + downloaded CRL with OpenSSL, you can use the following command: + +```bash +openssl verify -crl_check -CAfile chain.pem -CRLfile crl.pem cert.pem +``` + +Note that you can also obtain the CRL from the certificate itself by +referencing the CRL distribution point extension on the certificate. + +To check a certificate against the CRL distribution point specified within it with OpenSSL, you can use the following command: + +```bash +openssl verify -verbose -crl_check -crl_download -CAfile chain.pem cert.pem +``` + + + + + + + + Assuming that you've issued a certificate under a CA, you can revoke it by making an API request to the [Revoke Certificate](/api-reference/endpoints/certificate-authorities/revoke) API endpoint, + specifying the serial number of the certificate and the reason for revocation. + + ### Sample request + + ```bash Request + curl --location --request POST 'https://app.infisical.com/api/v1/pki/certificates//revoke' \ + --header 'Authorization: Bearer ' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "revocationReason": "UNSPECIFIED" + }' + ``` + + ### Sample response + + ```bash Response + { + message: "Successfully revoked certificate", + serialNumber: "...", + revokedAt: "..." + } + ``` + + + In order to check the revocation status of a certificate, you can check it against the CRL of the issuing CA. + To obtain the CRLs of the CA, make an API request to the [List CRLs](/api-reference/endpoints/certificate-authorities/crls) API endpoint. + + ### Sample request + + ```bash Request + curl --location --request GET 'https://app.infisical.com/api/v1/pki/ca//crls' \ + --header 'Authorization: Bearer ' + ``` + + ### Sample response + + ```bash Response + [ + { + id: "...", + crl: "..." + }, + ... + ] + ``` + + To verify a certificate against the CRL with OpenSSL, you can use the following command: + + ```bash + openssl verify -crl_check -CAfile chain.pem -CRLfile crl.pem cert.pem + ``` + + + + + diff --git a/docs/documentation/platform/pki/certificates/overview.mdx b/docs/documentation/platform/pki/certificates/overview.mdx new file mode 100644 index 000000000..a4688388a --- /dev/null +++ b/docs/documentation/platform/pki/certificates/overview.mdx @@ -0,0 +1,15 @@ +--- +title: "Overview" +sidebarTitle: "Overview" +--- + +To issue a certificate with Infisical, you create a certificate profile and a certificate template to go along with it. You then issue a certificate against +a specific profile depending on the enrollment method associated with it. + +There are three components to understand: + +- [Certificate Profile](/documentation/platform/pki/certificates/profiles): A configuration set specifying how certificates should be issued under that profile including the [issuing CA](/documentation/platform/pki/ca/overview), a certificate template, and the [enrollment method](/documentation/platform/pki/enrollment-methods/overview) (such as ACME, EST, API, etc.) used to enroll certificates. + +- [Certificate Template](/documentation/platform/pki/certificates/templates): A policy structure specifying the permitted attributes for requested certificates including subject naming conventions, SAN fields, key usages, and extended key usages. + +- [Certificate](/documentation/platform/pki/certificates/certificate): The actual X.509 certificate issued for a profile. Once issued, a certificate kept track of in the certificate inventory. diff --git a/docs/documentation/platform/pki/certificates/profiles.mdx b/docs/documentation/platform/pki/certificates/profiles.mdx new file mode 100644 index 000000000..ccbef89cd --- /dev/null +++ b/docs/documentation/platform/pki/certificates/profiles.mdx @@ -0,0 +1,28 @@ +--- +title: "Certificate Profiles" +sidebarTitle: "Profiles" +--- + +## Concept + +A certificate profile is a configuration set specifying how leaf certificates should be issued for a group of end-entities including the [issuing CA](/documentation/platform/pki/ca/overview), a [certificate template](/documentation/platform/pki/certificates/templates), and the [enrollment method](/documentation/platform/pki/enrollment-methods/overview) (e.g. ACME, EST, API, etc.) used to enroll certificates. + +You typically request certificates against a certificate profile through its associated enrollment method. Each method defines its own interaction flow which you can read more about in its respective documentation. + +## Guide to Creating a Certificate Profile + +To create a certificate profile, head to your Certificate Management Project > Certificates > Certificate Profiles and press **Create Profile**. + +![pki certificate profile](/images/platform/pki/certificate/cert-profile.png) + +![pki certificate profile modal](/images/platform/pki/certificate/cert-profile-modal.png) + +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. +- 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. + +Depending on which enrollment method you choose, you may be presented with additional enrollment-specific configuration fields. diff --git a/docs/documentation/platform/pki/certificates/templates.mdx b/docs/documentation/platform/pki/certificates/templates.mdx new file mode 100644 index 000000000..38b5570dd --- /dev/null +++ b/docs/documentation/platform/pki/certificates/templates.mdx @@ -0,0 +1,30 @@ +--- +title: "Certificate Templates" +sidebarTitle: "Templates" +--- + +## Concept + +A certificate template is a policy structure specifying permitted attributes for requested certificates. This includes constraints around subject naming conventions, SAN fields, key usages, and extended key usages. + +Each certificate requested against a profile is validated against the template bound to that profile. If the request fails any criteria included in the template, the certificate is not issued. This helps administrators enforce uniformity and security standards across all issued certificates. + +## Guide to Creating a Certificate Template + +To create a certificate template, head to your Certificate Management Project > Certificates > Certificate Templates and press **Create Template**. + +![pki certificate template](/images/platform/pki/certificate/cert-template.png) + +![pki certificate template modal](/images/platform/pki/certificate/cert-template-modal.png) + +Here's some guidance on each field: + +- Template Name: A slug-friendly name for the template such as `tls-server`. +- Description: An optional description for the template. +- Subject Attributes: A list of common names that can be included in the certificate subject. Each row accepts a fixed value or pattern such as `example.com` or `*.example.com` and whether it is allowed or denied. +- Subject Alternative Names (SANs): A list of SANs that can appear in the certificate. Each row accepts a SAN type (e.g. DNS, IP, Email, URI), a fixed value or pattern such as `example.com` or `*.example.com`, and an allow or deny flag. +- Allowed Signature Algorithms: The set of signature algorithms permitted to sign certificates under this template such as `SHA256-RSA`, `SHA512-RSA`, etc. +- Allowed Key Algorithms: The set of public key algorithms permitted for certificate requests such as `RSA-2048`, `RSA-4096`, etc. +- Key Usages: The cryptographic purposes of the certificate such as Digital Signature, Key Encipherment, etc. +- Extended Key Usages: The higher-level intended uses of the certificate such as Server Authentication, Client Authentication, etc. +- Certificate Validity: The maximum lifetime of certificates that can be requested for certificates validated against this template. You can specify both a duration and unit (days, months, or years). diff --git a/docs/documentation/platform/pki/concepts/certificate-lifecycle.mdx b/docs/documentation/platform/pki/concepts/certificate-lifecycle.mdx new file mode 100644 index 000000000..cf19ac867 --- /dev/null +++ b/docs/documentation/platform/pki/concepts/certificate-lifecycle.mdx @@ -0,0 +1,53 @@ +--- +title: "Certificate Lifecycle" +description: "Learn what is the certificate lifecycle and how it works." +--- + +## Certificate Lifecycle + +Typically, a certificate goes through a series of stages during its lifetime from creation to retirement. This is called the certificate lifecycle. The exact names of these stages may vary from vendor to vendor, but they typically include [discovery](/documentation/platform/pki/concepts/certificate-lifecycle#discovery), [enrollment](/documentation/platform/pki/concepts/certificate-lifecycle#enrollment), [deployment](/documentation/platform/pki/concepts/certificate-lifecycle#deployment), [renewal](/documentation/platform/pki/concepts/certificate-lifecycle#renewal), [revocation](/documentation/platform/pki/concepts/certificate-lifecycle#revocation), and [retirement](/documentation/platform/pki/concepts/certificate-lifecycle#retirement). + +Note that not every stage is needed. For instance: + +- You are not required to discover certificates in order to start issuing and managing them. +- You may not need to revoke a certificate explicitly if it expires naturally and is replaced during routine renewal. + +## Discovery + +Certificate discovery is the process of identifying all active and inactive certificates across an environment, including those found on web servers, load balancers, services, and devices. A complete inventory prevents outages from forgotten certificates and creates the foundation for automation and monitoring. + +## Enrollment (Request / Issuance) + +Certificate enrollment is the process of requesting a certificate from a CA and can follow different approaches depending on the system or protocol in use. + +Common approaches to certificate enrollment include: + +- CSR-based enrollment: The client generates a key pair locally and submits a Certificate Signing Request (CSR) to a CA for certificate issuance. +- CSR-less enrollment: The client requests a certificate directly from a CA which may handle key generation internally and return the key pair in the response. + +Enrollment can be manually completed via API or fully automated using protocols like EST or ACME. The choice of enrollment method depends on security requirements, operational constraints, and integration context. + +## Deployment + +Certificate deployment involves installing the issued certificate on the appropriate systems and services, such as web servers, load balancers, or internal endpoints. It can also include distributing or [synchronizing certificates](/documentation/platform/pki/certificate-syncs/overview) to external systems like cloud key stores (e.g., AWS Secrets Manager, Google Secret Manager, Azure Key Vault) so they can be securely consumed by workloads running in the cloud. + +Deployment can happen manually or through automated mechanisms such as configuration pipelines, agents, or webhook integrations. + +## Renewal + +Certificate renewal is the process of requesting a new certificate from a CA before it expires to maintain trust and availability; this process can involve reusing the same key pair or rotating to a new one. + +The renewal process can be server-driven or client-driven: + +- Server-driven: Infisical automatically renews the certificate on your behalf. The renewed certificate is stored in the platform and can be synchronized to external systems such as cloud key stores. +- Client-driven: An external client, such as an agent or workload, initiates the renewal against Infisical. This is useful when key material needs to remain under client control or when rotation is tied to application-specific logic. + +This flexibility allows certificates to be renewed in a way that aligns with different security, automation, and infrastructure models. + +## Revocation + +Certificate revocation is the process of invalidating a certificate to prevent it from being used. This is required when a certificate is compromised, misconfigured, or no longer needed. The CA signals this status to clients through CRLs or OCSP. A new certificate can be issued and deployed if needed. + +## Retirement + +Certificate retirement is the process of removing a certificate from the system. This is typically done when a certificate is no longer needed or has expired. diff --git a/docs/documentation/platform/pki/concepts/certificate-mgmt.mdx b/docs/documentation/platform/pki/concepts/certificate-mgmt.mdx new file mode 100644 index 000000000..efdb8a072 --- /dev/null +++ b/docs/documentation/platform/pki/concepts/certificate-mgmt.mdx @@ -0,0 +1,20 @@ +--- +title: "Certificate Management" +description: "Learn what is certificate management and why it matters for building secure systems." +--- + +## What is a Certificate? + +A (digital) _certificate_ is a file that is tied to a cryptographic key pair and is used to verify the identity of a website, user, device, or service. It helps establish trust and secure, encrypted communication between systems. + +For example, when you visit a website over HTTPS, your browser checks the TLS certificate deployed on the web server or load balancer to make sure it’s really the site it claims to be. If the certificate is valid, your browser establishes an encrypted connection with the server. + +Certificates contain information about the subject (who it identifies), the public key, and a digital signature from the CA that issued the certificate. They also include additional fields such as key usages, validity periods, and extensions that define how and where the certificate can be used. When a certificate expires, the service presenting it is no longer trusted, and clients won't be able to establish a secure connection to the service. + +## What is Certificate Management? + +As infrastructure scales and systems become more distributed, certificates sprawl. Without proper visibility and automation in place, certificates scatter across IT infrastructure, creating blind spots that can lead to service outages when certificates aren't renewed in time. + +To solve certificate sprawl and avoid outages, organizations rely on certificate management: the practice of centralizing and automating the certificate lifecycle from issuance through renewal and revocation. + +A consistent approach makes it easier to keep certificates valid and trusted, reduce operational risk, and maintain secure communication across environments. diff --git a/docs/documentation/platform/pki/enrollment-methods/acme.mdx b/docs/documentation/platform/pki/enrollment-methods/acme.mdx new file mode 100644 index 000000000..559b0cab8 --- /dev/null +++ b/docs/documentation/platform/pki/enrollment-methods/acme.mdx @@ -0,0 +1,8 @@ +--- +title: "Certificate Enrollment via ACME" +sidebarTitle: "ACME" +--- + + + ACME-based certificate enrollment is currently under development and will be included in a future release. + diff --git a/docs/documentation/platform/pki/enrollment-methods/api.mdx b/docs/documentation/platform/pki/enrollment-methods/api.mdx new file mode 100644 index 000000000..dac7b6386 --- /dev/null +++ b/docs/documentation/platform/pki/enrollment-methods/api.mdx @@ -0,0 +1,179 @@ +--- +title: "Certificate Enrollment via API" +sidebarTitle: "API" +--- + +## Concept + +The API enrollment method allows you to issue certificates against a specific certificate profile over Web UI or by making an API request to Infisical. + +## Guide to Certificate Enrollment via API + +In the following steps, we explore how to issue a X.509 certificate using the API enrollment method. + + + + + + + Create a [certificate + profile](/documentation/platform/pki/certificates/profiles) with **API** + selected as the enrollment method. + + Notice that the API enrollment method supports an option called **Enable Auto-Renewal By Default**. + If selected, _eligible_ certificates are automatically considered for server-side auto-renewal based + on a specified renewal days before expiration threshold at the time of issuance; for more information + about server-side auto-renewal, refer to the documentation [here](/documentation/platform/pki/certificates/certificates#guide-to-renewing-certificates). + + + + To create a certificate, head to your Project > Certificates > Certificates and press **Issue**. + + ![pki certificates](/images/platform/pki/certificate/cert-issue.png) + +Here, select the certificate profile from step 1 that will be used to issue the certificate and fill out the rest of the details for the certificate to be issued. + +![pki certificate issue modal](/images/platform/pki/certificate/cert-issue-modal.png) + + + + Once you have created the certificate from step 1, you'll be presented with the certificate details including the **Certificate Body**, **Certificate Chain**, and **Private Key**. + + ![pki certificate body](/images/platform/pki/certificate/cert-body.png) + + + Make sure to download and store the **Private Key** in a secure location as it + will only be displayed once at the time of certificate issuance. The + **Certificate Body** and **Certificate Chain** will remain accessible and can + be copied at any time. + + + + + + + + + + + To create a certificate [profile](/documentation/platform/pki/certificates/profiles), make an API request to the [Create Certificate Profile](/docs/api-reference/endpoints/certificate-profiles/create) API endpoint. + + ### Sample request + + ```bash Request + curl --location --request POST 'https://app.infisical.com/api/v1/pki/certificate-profiles' \ + --header 'Authorization: Bearer ' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "projectId": "", + "caId": "", + "certificateTemplateId": "", + "slug": "my-api-profile", + "description": "Certificate profile for API enrollment", + "enrollmentType": "API", + "apiConfig": { + "autoRenew": true, + "renewBeforeDays": 7 + } + }' + ``` + + ### Sample response + + ```bash Response + { + "certificateProfile": { + "id": "550e8400-e29b-41d4-a716-446655440000", + "projectId": "65f0a4b0-c123-4567-8901-23456789abcd", + "caId": "550e8400-e29b-41d4-a716-446655440000", + "certificateTemplateId": "660f1234-e29b-41d4-a716-446655440001", + "slug": "my-api-profile", + "description": "Certificate profile for API enrollment", + "enrollmentType": "API", + "apiConfigId": "770g2345-e29b-41d4-a716-446655440002", + "createdAt": "2023-01-19T09:44:36.267Z", + "updatedAt": "2023-01-19T09:44:36.267Z" + } + } + ``` + + + + + To issue a certificate against the certificate profile, make an API request to the [Issue Certificate](/api-reference/endpoints/certificates/issue-certificate) API endpoint. + + ### Sample request + + ```bash Request + curl --location --request POST 'https://app.infisical.com/api/v3/pki/certificates/issue-certificate' \ + --header 'Authorization: Bearer ' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "profileId": "", + "commonName": "service.acme.com", + "ttl": "1y", + "signatureAlgorithm": "RSA-SHA256", + "keyAlgorithm": "RSA_2048", + "keyUsages": ["digital_signature", "key_encipherment"], + "extendedKeyUsages": ["server_auth"], + "altNames": [ + { + "type": "DNS", + "value": "service.acme.com" + }, + { + "type": "DNS", + "value": "www.service.acme.com" + } + ] + }' + ``` + + ### Sample response + + ```bash Response + { + "certificate": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----", + "certificateChain": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----", + "issuingCaCertificate": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----", + "privateKey": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC...\n-----END PRIVATE KEY-----", + "serialNumber": "123456789012345678", + "certificateId": "880h3456-e29b-41d4-a716-446655440003" + } + ``` + + + Make sure to store the `privateKey` as it is only returned once here at the time of certificate issuance. The `certificate` and `certificateChain` will remain accessible and can be retrieved at any time. + + + If you have an external private key, you can also issue a certificate by making an API request containing a pem-encoded CSR (Certificate Signing Request) to the [Sign Certificate](/api-reference/endpoints/certificates/sign-certificate) API endpoint. + + ### Sample request + + ```bash Request + curl --location --request POST 'https://app.infisical.com/api/v3/pki/certificates/sign-certificate' \ + --header 'Authorization: Bearer ' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "profileId": "", + "csr": "-----BEGIN CERTIFICATE REQUEST-----\nMIICvDCCAaQCAQAwdzELMAkGA1UEBhMCVVMxDTALBgNVBAgMBE9oaW8...\n-----END CERTIFICATE REQUEST-----", + "ttl": "1y" + }' + ``` + + ### Sample response + + ```bash Response + { + "certificate": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----", + "certificateChain": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----", + "issuingCaCertificate": "-----BEGIN CERTIFICATE-----\nMIIEpDCCAowCCQD...\n-----END CERTIFICATE-----", + "serialNumber": "123456789012345679", + "certificateId": "990i4567-e29b-41d4-a716-446655440004" + } + ``` + + + + + diff --git a/docs/documentation/platform/pki/enrollment-methods/est.mdx b/docs/documentation/platform/pki/enrollment-methods/est.mdx new file mode 100644 index 000000000..a4e463a2f --- /dev/null +++ b/docs/documentation/platform/pki/enrollment-methods/est.mdx @@ -0,0 +1,71 @@ +--- +title: "Certificate Enrollment via EST" +sidebarTitle: "EST" +--- + +## Concept + +The API enrollment method allows you to issue and manage certificates against a specific certificate profile using the [EST protocol](https://en.wikipedia.org/wiki/Enrollment_over_Secure_Transport). +This method is suitable for environments requiring strong authentication and encrypted communication, such as in IoT, enterprise networks, and secure web services. + +Infisical's EST service is based on [RFC 7030](https://datatracker.ietf.org/doc/html/rfc7030) and implements the following endpoints: + +- **cacerts** - provides the necessary CA chain for the client to validate certificates issued by the CA. +- **simpleenroll** - allows an EST client to request a new certificate from Infisical's EST server +- **simplereenroll** - similar to the /simpleenroll endpoint but is used for renewing an existing certificate. + +These EST endpoints are exposed on port 8443 under the .well-known/est path +and structured under `https://app.infisical.com:8443/.well-known/est/{profile_id}/...` + +## Prerequisites + +- Your client devices need to have a bootstrap/pre-installed certificate. +- Your client devices must trust the server certificates used by Infisical's EST server. If the devices are new or lack existing trust configurations, you need to manually establish trust for the appropriate certificates. + + + For Infisical Cloud users, the devices must be configured to trust the [Amazon + root CA certificates](https://www.amazontrust.com/repository). + + +## Guide to Certificate Enrollment via EST + +In the following steps, we explore how to issue a X.509 certificate using the EST enrollment method. + + + + Create a [certificate + profile](/documentation/platform/pki/certificates/profiles) with **EST** + selected as the enrollment method and fill in EST-specific configuration. + + ![pki est config](/images/platform/pki/enrollment-methods/est/est-config.png) + + Here's some guidance on each EST-specific configuration field: + + - Disable Bootstrap CA Validation: Enable this if your devices are not configured with a bootstrap certificate. + - EST Passphrase: This is also used to authenticate your devices with Infisical's EST server. When configuring the clients, use the value defined here as the EST password. + - CA Chain Certificate: This is the certificate chain used to validate your devices' manufacturing/pre-installed certificates. This will be used to authenticate your devices with Infisical's EST server. + + + + Once the EST enrollment method configuration is complete, you can use the ID of the associated certificate profile + `profile_id` as the EST label when enrolling EST clients with Infisical. + + ![pki est label](/images/platform/pki/enrollment-methods/est/est-label.png) + + The complete URL structure of the supported EST endpoints may look like the following: + + - https://app.infisical.com:8443/.well-known/est/{profile_id}/cacerts + - https://app.infisical.com:8443/.well-known/est/{profile_id}/simpleenroll + - https://app.infisical.com:8443/.well-known/est/{profile_id}/simplereenroll + + + + To use the EST passphrase in your clients, configure it as the EST password. The EST username can be set to any arbitrary value. + Use the appropriate client certificates for invoking the EST endpoints. + - For `simpleenroll`, use the bootstrapped/manufacturer client certificate. + - For `simplereenroll`, use a valid EST-issued client certificate. + When configuring the PKCS#12 objects for the client certificates, only include the leaf certificate and the private key. + + + + diff --git a/docs/documentation/platform/pki/enrollment-methods/overview.mdx b/docs/documentation/platform/pki/enrollment-methods/overview.mdx new file mode 100644 index 000000000..f1af9375d --- /dev/null +++ b/docs/documentation/platform/pki/enrollment-methods/overview.mdx @@ -0,0 +1,11 @@ +--- +title: "Overview" +sidebarTitle: "Overview" +--- + +Enrollment methods determine how certificates are issued and managed for a [certificate profile](/documentation/platform/pki/certificates/profiles). + +Refer to the documentation for each enrollment method to learn more about how to enroll certificates using it. + +- [API](/documentation/platform/pki/enrollment-methods/api): Enroll certificates via API. +- [EST](/documentation/platform/pki/enrollment-methods/est): Enroll certificates via EST protocol. diff --git a/docs/documentation/platform/pki/enrollment-methods/scep.mdx b/docs/documentation/platform/pki/enrollment-methods/scep.mdx new file mode 100644 index 000000000..977e7b388 --- /dev/null +++ b/docs/documentation/platform/pki/enrollment-methods/scep.mdx @@ -0,0 +1,8 @@ +--- +title: "Certificate Enrollment via SCEP" +sidebarTitle: "SCEP" +--- + + + SCEP-based certificate enrollment is currently under development and will be included in a future release. + diff --git a/docs/documentation/platform/pki/external-ca.mdx b/docs/documentation/platform/pki/external-ca.mdx deleted file mode 100644 index efe029149..000000000 --- a/docs/documentation/platform/pki/external-ca.mdx +++ /dev/null @@ -1,192 +0,0 @@ ---- -title: "External CA" -sidebarTitle: "External CA" -description: "Learn how to connect External Certificate Authorities with Infisical." ---- - -## Concept - -In addition to creating a Private CA hierarchy, Infisical allows you to integrate with External Certificate Authorities (CAs) to issue digital certificates for your [subscribers](/documentation/platform/pki/subscribers). This integration enables you to leverage established certificate authority infrastructure while centralizing your certificate management within Infisical. - -
- -```mermaid -graph TD - B[Infisical] -->|Manages Certificates| D[Subscribers] - - A1[Public CAs
Let's Encrypt, ZeroSSL] -->|ACME Protocol| B - A2[Enterprise CAs
Vault PKI, Step CA] -->|ACME Protocol| B - A3[Cloud CAs
ACME-compatible services] -->|ACME Protocol| B - - A4[Future: Enterprise CAs] -.->|EST/SCEP Protocols| B - A5[Future: Cloud CAs] -.->|REST APIs| B -``` - -
- -When you integrate an External CA with Infisical, you benefit from: - -1. **Trust by Default**: Certificates issued by public CAs are trusted by default in browsers and operating systems. -2. **Unified Management**: Manage all certificates—both internally and externally issued—from a single platform. -3. **Automation**: Leverage Infisical's automation capabilities for certificate lifecycle management. -4. **Compliance**: Meet requirements for publicly trusted certificates, especially for public-facing services. -5. **Flexibility**: Choose the most appropriate CA for different use cases while maintaining consistent management. - -## General Workflow - -A typical workflow for integrating an External CA with Infisical consists of the following steps: - -1. **Select External CA Type**: Choose the appropriate external CA based on your requirements and supported protocols. -2. **Configure Prerequisites**: Set up any required credentials, connections, or configurations specific to your chosen CA type. -3. **Register External CA**: Add the External CA configuration to your Infisical project. -4. **Create Subscribers**: Set up subscribers that use the External CA as their issuing authority. -5. **Manage Certificate Lifecycle**: Handle certificate issuance, renewal, and revocation through Infisical's unified interface. - -The specific steps and requirements vary depending on the External CA type you choose to integrate. - -## Supported Integration Methods - -Infisical currently supports integration with External Certificate Authorities through the following protocol: - -### ACME Protocol Integration - -ACME (Automatic Certificate Management Environment) is a widely adopted protocol for automated certificate issuance and management. Infisical can integrate with any CA that supports the ACME protocol, including: - -**Public Certificate Authorities:** -- Let's Encrypt - Free, automated SSL/TLS certificates -- ZeroSSL - Free and premium SSL certificates -- Buypass - Norwegian CA with free ACME certificates - -**Enterprise Certificate Authorities:** -- HashiCorp Vault PKI - Enterprise secret management with ACME support -- Step CA - Open-source certificate authority with ACME - -**Cloud Certificate Authorities:** -- Some managed certificate services that support ACME protocol - -[Learn more about ACME integration →](/documentation/platform/pki/acme-ca) - -## Use Cases - -External CA integration is ideal for various scenarios: - -### Public-Facing Services -Use publicly trusted CAs for websites and services that need browser compatibility: -- Web applications and APIs -- Load balancers and CDNs -- Public-facing microservices - -### Compliance Requirements -Meet specific compliance standards that require certificates from accredited CAs: -- PCI DSS compliance -- SOC 2 requirements -- Industry-specific regulations - -### Hybrid Infrastructure -Combine internal and external CAs for different use cases: -- Internal services with Private CAs -- Public services with External CAs -- Development vs. production environments - -### Legacy System Integration -Integrate with existing enterprise PKI infrastructure: -- Windows Active Directory Certificate Services -- Network device management -- IoT device provisioning - -## Benefits of Centralized Management - -Managing External CAs through Infisical provides several advantages over direct CA management: - -### Unified Certificate Inventory -- Single dashboard for all certificates -- Centralized expiration tracking -- Cross-CA certificate analytics - -### Automated Lifecycle Management -- Automatic certificate reissuance before expiration -- Proactive expiration alerts -- Standardized certificate management processes - -### Enhanced Security -- Centralized access controls -- Audit trails for all certificate operations -- Policy enforcement across CAs - -### Operational Efficiency -- Reduced manual certificate management -- Consistent deployment workflows -- API-driven automation -- Integration with existing tools - -## Available Integration Guides - -Get started with External CA integration: - - - - Set up automated certificate issuance with any ACME-compatible CA - - - Custom CA integrations via REST APIs (Coming Soon) - - - -## FAQ - - - - Currently, Infisical supports any Certificate Authority that implements the ACME protocol, including: - - - **Public CAs**: Let's Encrypt, ZeroSSL, Buypass - - **Enterprise CAs**: HashiCorp Vault PKI, Step CA - - **Cloud CAs**: ACME-compatible managed services - - Integration uses DNS-01 validation through Route53 or Cloudflare. Learn more about [supported DNS validation methods](/documentation/platform/pki/acme-ca#what-dns-validation-methods-are-supported). - - Support for additional integration protocols (EST, SCEP, direct APIs) is planned for future releases. - - - Yes. You can have both Private CAs (root and intermediate) and External CAs in the same project, allowing you flexibility in how you issue certificates for different use cases. This hybrid approach enables you to: - - - Use Private CAs for internal services and applications - - Use External CAs for public-facing services - - Apply consistent management practices across all certificate types - - Implement appropriate security controls based on certificate usage - - - The types of certificates you can issue depend on the External CA provider and type: - - - **Public CAs**: Typically support Domain Validation (DV) certificates, with some offering Organization Validation (OV) - - **Enterprise CAs**: Support internal certificates, device certificates, and custom certificate types - - **Cloud CAs**: Support various certificate types depending on the service - - Certificate capabilities vary by provider and integration method. - - - Certificate reissuance is handled automatically by Infisical based on the CA type: - - - **Public CAs**: Automatic reissuance using ACME protocol with the same certificate extensions before expiration - - **Other CA types**: Certificate management methods depend on the specific integration (when available) - - All certificate lifecycle events are tracked and managed through Infisical's unified interface, ensuring continuous certificate validity. - - - Authentication methods vary by CA type: - - - **Public CAs**: ACME account registration with email and account keys - - **Enterprise CAs**: Client certificates, username/password, or domain authentication (when available) - - **Cloud CAs**: API keys, OAuth tokens, or service account authentication (when available) - - Infisical securely stores and manages all authentication credentials. - - - Yes, Infisical provides policy enforcement capabilities: - - - Certificate template constraints - - Monitoring and alerting policies - - Access controls for certificate operations - - These policies ensure consistent governance across both internal and external certificate sources. - - diff --git a/docs/documentation/platform/pki/integration-guides/gloo-mesh.mdx b/docs/documentation/platform/pki/integration-guides/gloo-mesh.mdx index 2a04f5e3a..d1f1273fd 100644 --- a/docs/documentation/platform/pki/integration-guides/gloo-mesh.mdx +++ b/docs/documentation/platform/pki/integration-guides/gloo-mesh.mdx @@ -1,5 +1,5 @@ --- -title: "Gloo Mesh Integration" +title: "Gloo Mesh" description: "Learn how to automatically provision and manage Istio intermediate CA certificates for Gloo Mesh using Infisical PKI" --- @@ -7,8 +7,8 @@ This guide will provide a high level overview on how you can use Infisical PKI a ## 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. -To manage the lifecycle of Istio intermediate CA certificates, you'll also install [cert-manager](https://cert-manager.io/). +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. +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. With this approach, you get the following benefits: @@ -18,10 +18,10 @@ With this approach, you get the following benefits: - Use cert-manager to automatically issue and renew Istio intermediate CA certificates from the same root, ensuring cross-cluster workload communication. - Increased auditability of private key infrastructure. - ## 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 Infisical PKI Issuer controller to authenticate with Infisical using machine identity 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. @@ -35,5 +35,5 @@ For Gloo Mesh-specific configuration, ensure that: ## 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. \ No newline at end of file +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. diff --git a/docs/documentation/platform/pki/overview.mdx b/docs/documentation/platform/pki/overview.mdx index b2351813c..d31bd96d9 100644 --- a/docs/documentation/platform/pki/overview.mdx +++ b/docs/documentation/platform/pki/overview.mdx @@ -4,10 +4,16 @@ sidebarTitle: "Overview" description: "Learn how to create a Private CA hierarchy and issue X.509 certificates." --- -Infisical can be used to create and manage Certificate Authorities (CAs) and issue X.509 certificates. This allows you to manage PKI infrastructure and issue digital certificates for subscribers such as services, applications, and devices. +Infisical can be used to create and manage Certificate Authorities (CAs) and issue digital X.509 certificates. This allows you to manage PKI infrastructure and issue certificates for end-entities such as load balancers, web servers, devices, and more. -Infisical's PKI offering is split into three components: +It helps teams automate certificate management including enrollment and renewal, and adopt secure workflows to ensure certificates remain valid, trusted, and synchronized across infrastructure. -- [Certificate Authorities](/documentation/platform/pki/private-ca): Create and manage CAs, including root and intermediate CAs. -- [Subscribers](/documentation/platform/pki/subscribers): Define and manage entities that will request X.509 certificates from CAs. This module provides a centralized view of all subscribers, enabling you to issue certificates and monitor their status. -- [Certificates](/documentation/platform/pki/certificates): Track and monitor issued X.509 certificates, maintaining a comprehensive inventory of all active and expired certificates. +Core capabilities include: + +- [Private CA](/documentation/platform/pki/ca/private-ca): Create and manage your own private CA hierarchy including root and intermediate CAs. +- [External CA integration](/documentation/platform/pki/ca/external-ca): Integrate with external public and private CAs including [Azure ADCS](/documentation/platform/pki/ca/azure-adcs) and [ACME-compatible CAs](/documentation/platform/pki/ca/acme-ca) like Let's Encrypt and DigiCert. +- [Certificate Enrollment](/documentation/platform/pki/enrollment-methods/overview): Support enrollment methods including [API](/documentation/platform/pki/enrollment-methods/api), ACME, [EST](/documentation/platform/pki/enrollment-methods/est), and more to automate certificate issuance for services, devices, and workloads. +- Certificate Inventory: Track and monitor issued X.509 certificates, maintaining a comprehensive inventory of all active and expired certificates. +- Certificate Lifecycle Automation: Automate issuance, [renewal](/documentation/platform/pki/certificates/certificates#guide-to-renewing-certificates), and [revocation](/documentation/platform/pki/certificates/certificates#guide-to-revoking-certificates) with policy-based workflows, ensuring certificates remain valid, compliant, and up to date across your infrastructure. +- [Certificate Syncs](/documentation/platform/pki/certificate-syncs/overview): Push certificates to cloud certificate managers like [AWS Certificate Manager](/documentation/platform/pki/certificate-syncs/aws-certificate-manager) and [Azure Key Vault](/documentation/platform/pki/certificate-syncs/azure-key-vault). +- [Certificate Alerts](/documentation/platform/pki/alerting): Receive alerts and webhook events for certificate lifecycle changes such as certificate expiration. diff --git a/docs/documentation/platform/pki/pki-issuer.mdx b/docs/documentation/platform/pki/pki-issuer.mdx index 13214bfb7..a1d07c98b 100644 --- a/docs/documentation/platform/pki/pki-issuer.mdx +++ b/docs/documentation/platform/pki/pki-issuer.mdx @@ -1,5 +1,5 @@ --- -title: "Cert Manager Issuer" +title: "Kubernetes Issuer" description: "Learn how to automatically provision and manage TLS certificates in Kubernetes using Infisical PKI" --- diff --git a/docs/documentation/platform/secrets-mgmt/concepts/secrets-mgmt.mdx b/docs/documentation/platform/secrets-mgmt/concepts/secrets-mgmt.mdx index 9fe68fb07..8e19b0937 100644 --- a/docs/documentation/platform/secrets-mgmt/concepts/secrets-mgmt.mdx +++ b/docs/documentation/platform/secrets-mgmt/concepts/secrets-mgmt.mdx @@ -3,7 +3,7 @@ title: "Secrets Management" description: "Learn what is secrets management and why it matters for building secure systems." --- -## What is Secret? +## What is a Secret? A _secret_ is a confidential value used by an application such as database credential, API key, or other configuration. diff --git a/docs/images/app-connections/azure/client-secrets/create-certificate-method.png b/docs/images/app-connections/azure/client-secrets/create-certificate-method.png new file mode 100644 index 000000000..37f642310 Binary files /dev/null and b/docs/images/app-connections/azure/client-secrets/create-certificate-method.png differ diff --git a/docs/images/app-connections/azure/client-secrets/upload-certificate.png b/docs/images/app-connections/azure/client-secrets/upload-certificate.png new file mode 100644 index 000000000..518c70b2d Binary files /dev/null and b/docs/images/app-connections/azure/client-secrets/upload-certificate.png differ diff --git a/docs/images/certificate-syncs/aws-certificate-manager/acm-destination.png b/docs/images/certificate-syncs/aws-certificate-manager/acm-destination.png deleted file mode 100644 index 42a20fc99..000000000 Binary files a/docs/images/certificate-syncs/aws-certificate-manager/acm-destination.png and /dev/null differ diff --git a/docs/images/certificate-syncs/aws-certificate-manager/acm-details.png b/docs/images/certificate-syncs/aws-certificate-manager/acm-details.png deleted file mode 100644 index 483cee003..000000000 Binary files a/docs/images/certificate-syncs/aws-certificate-manager/acm-details.png and /dev/null differ diff --git a/docs/images/certificate-syncs/aws-certificate-manager/acm-options.png b/docs/images/certificate-syncs/aws-certificate-manager/acm-options.png deleted file mode 100644 index aa08b2d19..000000000 Binary files a/docs/images/certificate-syncs/aws-certificate-manager/acm-options.png and /dev/null differ diff --git a/docs/images/certificate-syncs/aws-certificate-manager/acm-review.png b/docs/images/certificate-syncs/aws-certificate-manager/acm-review.png deleted file mode 100644 index 5f7b216ad..000000000 Binary files a/docs/images/certificate-syncs/aws-certificate-manager/acm-review.png and /dev/null differ diff --git a/docs/images/certificate-syncs/aws-certificate-manager/acm-source.png b/docs/images/certificate-syncs/aws-certificate-manager/acm-source.png deleted file mode 100644 index 0d92fe69e..000000000 Binary files a/docs/images/certificate-syncs/aws-certificate-manager/acm-source.png and /dev/null differ diff --git a/docs/images/certificate-syncs/aws-certificate-manager/acm-synced.png b/docs/images/certificate-syncs/aws-certificate-manager/acm-synced.png deleted file mode 100644 index 7e1ed12c5..000000000 Binary files a/docs/images/certificate-syncs/aws-certificate-manager/acm-synced.png and /dev/null differ diff --git a/docs/images/certificate-syncs/aws-certificate-manager/select-acm-option.png b/docs/images/certificate-syncs/aws-certificate-manager/select-acm-option.png deleted file mode 100644 index 79515516a..000000000 Binary files a/docs/images/certificate-syncs/aws-certificate-manager/select-acm-option.png and /dev/null differ diff --git a/docs/images/certificate-syncs/azure-key-vault/select-key-vault-option.png b/docs/images/certificate-syncs/azure-key-vault/select-key-vault-option.png deleted file mode 100644 index 0a9093ee8..000000000 Binary files a/docs/images/certificate-syncs/azure-key-vault/select-key-vault-option.png and /dev/null differ diff --git a/docs/images/certificate-syncs/azure-key-vault/vault-destination.png b/docs/images/certificate-syncs/azure-key-vault/vault-destination.png deleted file mode 100644 index 96295362b..000000000 Binary files a/docs/images/certificate-syncs/azure-key-vault/vault-destination.png and /dev/null differ diff --git a/docs/images/certificate-syncs/azure-key-vault/vault-details.png b/docs/images/certificate-syncs/azure-key-vault/vault-details.png deleted file mode 100644 index cc2ebc691..000000000 Binary files a/docs/images/certificate-syncs/azure-key-vault/vault-details.png and /dev/null differ diff --git a/docs/images/certificate-syncs/azure-key-vault/vault-options.png b/docs/images/certificate-syncs/azure-key-vault/vault-options.png deleted file mode 100644 index 980dad0ef..000000000 Binary files a/docs/images/certificate-syncs/azure-key-vault/vault-options.png and /dev/null differ diff --git a/docs/images/certificate-syncs/azure-key-vault/vault-review.png b/docs/images/certificate-syncs/azure-key-vault/vault-review.png deleted file mode 100644 index 4853be086..000000000 Binary files a/docs/images/certificate-syncs/azure-key-vault/vault-review.png and /dev/null differ diff --git a/docs/images/certificate-syncs/azure-key-vault/vault-source.png b/docs/images/certificate-syncs/azure-key-vault/vault-source.png deleted file mode 100644 index 5ab8c5dc6..000000000 Binary files a/docs/images/certificate-syncs/azure-key-vault/vault-source.png and /dev/null differ diff --git a/docs/images/certificate-syncs/azure-key-vault/vault-synced.png b/docs/images/certificate-syncs/azure-key-vault/vault-synced.png deleted file mode 100644 index 2bacd1a7b..000000000 Binary files a/docs/images/certificate-syncs/azure-key-vault/vault-synced.png and /dev/null differ diff --git a/docs/images/certificate-syncs/general/certificate-sync-tab.png b/docs/images/certificate-syncs/general/certificate-sync-tab.png deleted file mode 100644 index f97a10aa3..000000000 Binary files a/docs/images/certificate-syncs/general/certificate-sync-tab.png and /dev/null differ diff --git a/docs/images/platform/pki/ca-crl.png b/docs/images/platform/pki/ca-crl.png deleted file mode 100644 index efe7d3b4a..000000000 Binary files a/docs/images/platform/pki/ca-crl.png and /dev/null differ diff --git a/docs/images/platform/pki/ca/ca-create-intermediate.png b/docs/images/platform/pki/ca/ca-create-intermediate.png index ac83db3e9..fe87345e6 100644 Binary files a/docs/images/platform/pki/ca/ca-create-intermediate.png and b/docs/images/platform/pki/ca/ca-create-intermediate.png differ diff --git a/docs/images/platform/pki/ca/ca-create-root.png b/docs/images/platform/pki/ca/ca-create-root.png index a8bf936a3..acf2d6a7a 100644 Binary files a/docs/images/platform/pki/ca/ca-create-root.png and b/docs/images/platform/pki/ca/ca-create-root.png differ diff --git a/docs/images/platform/pki/ca/ca-create.png b/docs/images/platform/pki/ca/ca-create.png index 915ed684c..f8cedd572 100644 Binary files a/docs/images/platform/pki/ca/ca-create.png and b/docs/images/platform/pki/ca/ca-create.png differ diff --git a/docs/images/platform/pki/ca/ca-crl.png b/docs/images/platform/pki/ca/ca-crl.png new file mode 100644 index 000000000..961297409 Binary files /dev/null and b/docs/images/platform/pki/ca/ca-crl.png differ diff --git a/docs/images/platform/pki/ca/ca-install-intermediate-csr.png b/docs/images/platform/pki/ca/ca-install-intermediate-csr.png index 77c7df0b9..b35b2ab10 100644 Binary files a/docs/images/platform/pki/ca/ca-install-intermediate-csr.png and b/docs/images/platform/pki/ca/ca-install-intermediate-csr.png differ diff --git a/docs/images/platform/pki/ca/ca-install-intermediate-opt.png b/docs/images/platform/pki/ca/ca-install-intermediate-opt.png index 16afd2f0b..4fdd64af7 100644 Binary files a/docs/images/platform/pki/ca/ca-install-intermediate-opt.png and b/docs/images/platform/pki/ca/ca-install-intermediate-opt.png differ diff --git a/docs/images/platform/pki/ca/ca-install-intermediate.png b/docs/images/platform/pki/ca/ca-install-intermediate.png index 10c9424ff..c0a0adde5 100644 Binary files a/docs/images/platform/pki/ca/ca-install-intermediate.png and b/docs/images/platform/pki/ca/ca-install-intermediate.png differ diff --git a/docs/images/platform/pki/ca/cas.png b/docs/images/platform/pki/ca/cas.png index d3189fd1a..628b03a34 100644 Binary files a/docs/images/platform/pki/ca/cas.png and b/docs/images/platform/pki/ca/cas.png differ diff --git a/docs/images/platform/pki/cert-revoke-modal.png b/docs/images/platform/pki/cert-revoke-modal.png deleted file mode 100644 index 07bc7fce8..000000000 Binary files a/docs/images/platform/pki/cert-revoke-modal.png and /dev/null differ diff --git a/docs/images/platform/pki/cert-revoke.png b/docs/images/platform/pki/cert-revoke.png deleted file mode 100644 index ff7fcc597..000000000 Binary files a/docs/images/platform/pki/cert-revoke.png and /dev/null differ diff --git a/docs/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-certificates.png b/docs/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-certificates.png new file mode 100644 index 000000000..42d83c0f7 Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-certificates.png differ diff --git a/docs/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-destination.png b/docs/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-destination.png new file mode 100644 index 000000000..6d0460601 Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-destination.png differ diff --git a/docs/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-details.png b/docs/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-details.png new file mode 100644 index 000000000..8d7757fd0 Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-details.png differ diff --git a/docs/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-options.png b/docs/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-options.png new file mode 100644 index 000000000..03254ee9f Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-options.png differ diff --git a/docs/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-review.png b/docs/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-review.png new file mode 100644 index 000000000..520b5e7f7 Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-review.png differ diff --git a/docs/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-synced.png b/docs/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-synced.png new file mode 100644 index 000000000..f6b40afa7 Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/aws-certificate-manager/acm-synced.png differ diff --git a/docs/images/platform/pki/certificate-syncs/aws-certificate-manager/select-acm-option.png b/docs/images/platform/pki/certificate-syncs/aws-certificate-manager/select-acm-option.png new file mode 100644 index 000000000..b1b79dda5 Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/aws-certificate-manager/select-acm-option.png differ diff --git a/docs/images/platform/pki/certificate-syncs/azure-key-vault/akv-certificates.png b/docs/images/platform/pki/certificate-syncs/azure-key-vault/akv-certificates.png new file mode 100644 index 000000000..04040365a Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/azure-key-vault/akv-certificates.png differ diff --git a/docs/images/platform/pki/certificate-syncs/azure-key-vault/akv-destination.png b/docs/images/platform/pki/certificate-syncs/azure-key-vault/akv-destination.png new file mode 100644 index 000000000..05361ae81 Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/azure-key-vault/akv-destination.png differ diff --git a/docs/images/platform/pki/certificate-syncs/azure-key-vault/akv-details.png b/docs/images/platform/pki/certificate-syncs/azure-key-vault/akv-details.png new file mode 100644 index 000000000..642eeef34 Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/azure-key-vault/akv-details.png differ diff --git a/docs/images/platform/pki/certificate-syncs/azure-key-vault/akv-options.png b/docs/images/platform/pki/certificate-syncs/azure-key-vault/akv-options.png new file mode 100644 index 000000000..69cd37d21 Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/azure-key-vault/akv-options.png differ diff --git a/docs/images/platform/pki/certificate-syncs/azure-key-vault/akv-review.png b/docs/images/platform/pki/certificate-syncs/azure-key-vault/akv-review.png new file mode 100644 index 000000000..ae0c96574 Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/azure-key-vault/akv-review.png differ diff --git a/docs/images/platform/pki/certificate-syncs/azure-key-vault/akv-synced.png b/docs/images/platform/pki/certificate-syncs/azure-key-vault/akv-synced.png new file mode 100644 index 000000000..157b4bf32 Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/azure-key-vault/akv-synced.png differ diff --git a/docs/images/platform/pki/certificate-syncs/azure-key-vault/select-akv-option.png b/docs/images/platform/pki/certificate-syncs/azure-key-vault/select-akv-option.png new file mode 100644 index 000000000..1fa0c0ca1 Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/azure-key-vault/select-akv-option.png differ diff --git a/docs/images/platform/pki/certificate-syncs/general/create-certificate-sync.png b/docs/images/platform/pki/certificate-syncs/general/create-certificate-sync.png new file mode 100644 index 000000000..f0f62f386 Binary files /dev/null and b/docs/images/platform/pki/certificate-syncs/general/create-certificate-sync.png differ diff --git a/docs/images/platform/pki/certificate/cert-body.png b/docs/images/platform/pki/certificate/cert-body.png index 8c1433b54..02c61840a 100644 Binary files a/docs/images/platform/pki/certificate/cert-body.png and b/docs/images/platform/pki/certificate/cert-body.png differ diff --git a/docs/images/platform/pki/certificate/cert-issue-modal.png b/docs/images/platform/pki/certificate/cert-issue-modal.png index 352c4979c..e3de549bb 100644 Binary files a/docs/images/platform/pki/certificate/cert-issue-modal.png and b/docs/images/platform/pki/certificate/cert-issue-modal.png differ diff --git a/docs/images/platform/pki/certificate/cert-issue.png b/docs/images/platform/pki/certificate/cert-issue.png index 614271d19..4506b9552 100644 Binary files a/docs/images/platform/pki/certificate/cert-issue.png and b/docs/images/platform/pki/certificate/cert-issue.png differ diff --git a/docs/images/platform/pki/certificate/cert-profile-modal.png b/docs/images/platform/pki/certificate/cert-profile-modal.png new file mode 100644 index 000000000..29280d01c Binary files /dev/null and b/docs/images/platform/pki/certificate/cert-profile-modal.png differ diff --git a/docs/images/platform/pki/certificate/cert-profile.png b/docs/images/platform/pki/certificate/cert-profile.png new file mode 100644 index 000000000..3337f8065 Binary files /dev/null and b/docs/images/platform/pki/certificate/cert-profile.png differ diff --git a/docs/images/platform/pki/certificate/cert-revoke-modal.png b/docs/images/platform/pki/certificate/cert-revoke-modal.png new file mode 100644 index 000000000..5b47dc7ca Binary files /dev/null and b/docs/images/platform/pki/certificate/cert-revoke-modal.png differ diff --git a/docs/images/platform/pki/certificate/cert-revoke.png b/docs/images/platform/pki/certificate/cert-revoke.png new file mode 100644 index 000000000..bc6bdb47c Binary files /dev/null and b/docs/images/platform/pki/certificate/cert-revoke.png differ diff --git a/docs/images/platform/pki/certificate/cert-template-modal.png b/docs/images/platform/pki/certificate/cert-template-modal.png index 2f6c88166..970b2f2f1 100644 Binary files a/docs/images/platform/pki/certificate/cert-template-modal.png and b/docs/images/platform/pki/certificate/cert-template-modal.png differ diff --git a/docs/images/platform/pki/certificate/cert-template.png b/docs/images/platform/pki/certificate/cert-template.png new file mode 100644 index 000000000..88080e7ca Binary files /dev/null and b/docs/images/platform/pki/certificate/cert-template.png differ diff --git a/docs/images/platform/pki/enrollment-methods/est/est-config.png b/docs/images/platform/pki/enrollment-methods/est/est-config.png new file mode 100644 index 000000000..63892a99d Binary files /dev/null and b/docs/images/platform/pki/enrollment-methods/est/est-config.png differ diff --git a/docs/images/platform/pki/enrollment-methods/est/est-label.png b/docs/images/platform/pki/enrollment-methods/est/est-label.png new file mode 100644 index 000000000..76f77a2cb Binary files /dev/null and b/docs/images/platform/pki/enrollment-methods/est/est-label.png differ diff --git a/docs/images/platform/pki/est/template-enroll-hover.png b/docs/images/platform/pki/est/template-enroll-hover.png deleted file mode 100644 index 7bec8e3f6..000000000 Binary files a/docs/images/platform/pki/est/template-enroll-hover.png and /dev/null differ diff --git a/docs/images/platform/pki/est/template-enrollment-est-label.png b/docs/images/platform/pki/est/template-enrollment-est-label.png deleted file mode 100644 index 4ad7bbeb1..000000000 Binary files a/docs/images/platform/pki/est/template-enrollment-est-label.png and /dev/null differ diff --git a/docs/images/platform/pki/est/template-enrollment-modal.png b/docs/images/platform/pki/est/template-enrollment-modal.png deleted file mode 100644 index 4ce08cfe0..000000000 Binary files a/docs/images/platform/pki/est/template-enrollment-modal.png and /dev/null differ diff --git a/docs/integrations/app-connections/azure-client-secrets.mdx b/docs/integrations/app-connections/azure-client-secrets.mdx index cb25fb596..4b55a7354 100644 --- a/docs/integrations/app-connections/azure-client-secrets.mdx +++ b/docs/integrations/app-connections/azure-client-secrets.mdx @@ -66,29 +66,72 @@ Infisical currently only supports two methods for connecting to Azure, which are - - Ensure your Azure application has the required permissions that Infisical needs for the Azure Client Secrets connection to work. - **Prerequisites:** - - An active Azure setup. + - - - For the Azure Client Secrets connection to work, assign the following permissions to your Azure application: + + Ensure your Azure application has the required permissions that Infisical needs for the Azure Client Secrets connection to work. + + **Prerequisites:** + - An active Azure setup. + + + + For the Azure Client Secrets connection to work, assign the following permissions to your Azure application: + + #### Required API Permissions + + **Microsoft Graph** + - `Application.ReadWrite.All` + - `Application.ReadWrite.OwnedBy` + - `Application.ReadWrite.All` (Delegated) + - `Directory.ReadWrite.All` (Delegated) + - `User.Read` (Delegated) + + ![Azure client secrets](/images/integrations/azure-client-secrets/app-api-permissions.png) + + + + + Ensure your Azure application has the required permissions that Infisical needs for the Azure Client Secrets connection to work. + + **Prerequisites:** + - An active Azure setup. + + + + For the Azure Client Secrets connection to work, assign the following permissions to your Azure application: + + #### Required API Permissions + + **Microsoft Graph** + - `Application.ReadWrite.All` + - `Application.ReadWrite.OwnedBy` + - `Application.ReadWrite.All` (Delegated) + - `Directory.ReadWrite.All` (Delegated) + - `User.Read` (Delegated) + + ![Azure client secrets](/images/integrations/azure-client-secrets/app-api-permissions.png) + + + + Navigate to the **Certificates & secrets** section of your Azure App Registration, and press the **Upload certificate** button. + + Select the **Upload** button and upload your certificate. + + ![Upload certificate](/images/app-connections/azure/client-secrets/upload-certificate.png) + + + Keep in mind that both the certificate and its private key are required to configure the Azure Client Secrets connection in Infisical. + + + + + + + - #### Required API Permissions - - **Microsoft Graph** - - `Application.ReadWrite.All` - - `Application.ReadWrite.OwnedBy` - - `Application.ReadWrite.All` (Delegated) - - `Directory.ReadWrite.All` (Delegated) - - `User.Read` (Delegated) - ![Azure client secrets](/images/integrations/azure-client-secrets/app-api-permissions.png) - - - ## Setup Azure Connection in Infisical @@ -123,6 +166,17 @@ Infisical currently only supports two methods for connecting to Azure, which are ![Connect via Azure OAUth](/images/app-connections/azure/client-secrets/create-client-secrets-method.png)
+ + + Fill in the **Tenant ID**, **Client ID**, **Certificate (PEM format)**, and **Private Key** fields with the Directory (Tenant) ID, Application (Client) ID, Certificate and Private Key you obtained in the [previous step](#certificate-authentication). + + + The private key is never transmitted to Azure, and it is only used to sign the client assertion used to authenticate with Azure. + + + ![Connect via Azure Certificate](/images/app-connections/azure/client-secrets/create-certificate-method.png) + +
diff --git a/docs/integrations/platforms/infisical-agent.mdx b/docs/integrations/platforms/infisical-agent.mdx index a93bfcdf0..43d322faa 100644 --- a/docs/integrations/platforms/infisical-agent.mdx +++ b/docs/integrations/platforms/infisical-agent.mdx @@ -48,6 +48,8 @@ While specifying an authentication method is mandatory to start the agent, confi | 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` | @@ -58,7 +60,7 @@ While specifying an authentication method is mandatory to start the agent, confi | `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[].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) | diff --git a/docs/integrations/platforms/kubernetes-injector.mdx b/docs/integrations/platforms/kubernetes-injector.mdx index 22262ffbf..9903dcbc3 100644 --- a/docs/integrations/platforms/kubernetes-injector.mdx +++ b/docs/integrations/platforms/kubernetes-injector.mdx @@ -53,25 +53,69 @@ $ kubectl logs deployment/infisical-agent-injector ## Windows support -The Infisical Agent Injector supports both running on Windows-based pods, and injecting the agent into Windows-based pods. +By default the agent injector is built for Linux-based pods, but supports injecting into Windows-based pods. -To run the agent injector on a Windows pod, it's important that you add the `nodeSelector.kubernetes.io/os` label to the pod's deployment with the value `windows`. -This can be done by changing the helm values.yaml by adding the following: +**To inject into Windows-based pods, no extra configuration is needed.** The agent injector will automatically detect and handle injections into Windows-based pods. -```yaml values.yaml -nodeSelector: - kubernetes.io/os: windows -``` +However, if you are trying to run the agent injector itself on a Windows-based pod, you'll need to configure your helm values.yaml file to point to a Windows-based image. -By default the agent injector will run on Linux-based pods, unless you specify otherwise like in the example above. -No extra configuration is needed to inject into Windows-based pods, as the agent injector will detect and handle the injection automatically. -The Agent Injector will only run and inject into Windows-based pods that are running on the supported Windows versions: +The Agent Injector will only run on and inject into Windows-based pods that are running on the supported Windows versions: - **Windows Server 2022** +- **Windows Server 2019** We're looking to add support for other Windows versions in the future. If you're using a different Windows version, please let us know by opening [an issue](https://github.com/Infisical/infisical-agent-injector/issues/new), and we'll look into adding support for your desired version as soon as possible. +You will need to set the `nodeSelector.kubernetes.io/os` label to `windows` and set the image tag to a Windows-based image. Below are two examples for Windows Server 2019 and Windows Server 2022. + + + + + Create your `values.yaml` file and add the following: + + ```yaml values.yaml + image: + repository: infisical/infisical-agent-injector + tag: "v0.1.4-windows-server-2019" + + nodeSelector: + kubernetes.io/os: windows + ``` + + Install the agent injector using the values.yaml file you created above. + + ```bash + helm install --generate-name infisical-helm-charts/infisical-agent-injector -f values.yaml + ``` + + + + + Create your `values.yaml` file and add the following: + + ```yaml values.yaml + image: + repository: infisical/infisical-agent-injector + tag: "v0.1.4-windows-server-2022" + + nodeSelector: + kubernetes.io/os: windows + ``` + + Install the agent injector using the values.yaml file you created above. + + ```bash + helm install --generate-name infisical-helm-charts/infisical-agent-injector -f values.yaml + ``` + + + + + + Note that Windows support is only supported in version `v0.1.4` and above. If you are using an older version, you will need to upgrade to `v0.1.4` or above to use Windows support. + + ## Supported annotations The Infisical Agent Injector supports the following annotations: @@ -101,6 +145,17 @@ The entire config needs to be of string format and needs to be assigned to the ` 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"`. + + 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)_. + **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. + + + The authentication type to use to connect to Infisical. Currently only the `kubernetes` authentication type is supported. You can refer to our [Kubernetes Auth](/documentation/platform/identities/kubernetes-auth) documentation for more information on how to create a machine identity for Kubernetes Auth. diff --git a/frontend/src/components/v2/SecretInput/SecretInput.tsx b/frontend/src/components/v2/SecretInput/SecretInput.tsx index 1745a84ec..f1d86335a 100644 --- a/frontend/src/components/v2/SecretInput/SecretInput.tsx +++ b/frontend/src/components/v2/SecretInput/SecretInput.tsx @@ -16,12 +16,14 @@ const syntaxHighlight = ( onHoverPart?: (part: string) => void, hoveredPart?: string, isCmdOrCtrlPressed?: boolean, - onClickSegment?: (segment: string, allSegments: string[]) => void + onClickSegment?: (segment: string, allSegments: string[]) => void, + placeholder?: string ) => { if (isLoadingValue) return HIDDEN_SECRET_VALUE; if (isErrorLoadingValue) return Error loading secret value.; if (isImport && !content) return "IMPORTED"; + if (placeholder && (content === "" || !content)) return placeholder; if (content === "") return "EMPTY"; if (!content) return "EMPTY"; if (!isVisible) return HIDDEN_SECRET_VALUE; @@ -132,6 +134,7 @@ export const SecretInput = forwardRef( isLoadingValue, isErrorLoadingValue, onClickSegment, + placeholder, ...props }, ref @@ -176,7 +179,12 @@ export const SecretInput = forwardRef(
             
-              
+              
                 {syntaxHighlight(
                   value,
                   isVisible || (isSecretFocused && !valueAlwaysHidden),
@@ -188,12 +196,14 @@ export const SecretInput = forwardRef(
                   },
                   hoveredPart,
                   isCmdOrCtrlPressed,
-                  onClickSegment
+                  onClickSegment,
+                  placeholder
                 )}